WebUI Redesign and Component 1: category mapper fixes

This commit is contained in:
Richard
2026-07-19 10:51:07 +02:00
parent c3e66d6cca
commit 7045002ca3
40 changed files with 7037 additions and 971 deletions
BIN
View File
Binary file not shown.
+144
View File
@@ -0,0 +1,144 @@
# Plan: Drei-Schichten-Architektur (Core / Lokal / Public)
> Stand: 2026-07-12 · Status: **Architektur bestätigt** (Nutzer-Entscheidung), Implementierung
> schrittweise. Kein Code in diesem Schritt.
> Kontext: Neues WebUI-Design (mit Claude Design vorbereitet) wird nach der Schicht-Trennung
> schrittweise implementiert. Public-Release ist noch fern, aber alles wird dafür vorbereitet.
## 0. Bestätigte Architektur
Drei Schichten mit **genau einer** Integrationsschnittstelle zwischen intern und öffentlich:
**der Datenbank**.
```
┌─────────────────────────── LOKAL (eigener Rechner/LAN) ───────────────────────────┐
│ │
│ Schicht 1: CORE Schicht 2: LOKALE API + WebUI │
│ ───────────────── ─────────────────────────── │
│ Crawler, Worker, PnL-Engine, volle API (Read + Control), │
│ Analyse, Ingest keine Auth (localhost), │
│ │ schreibt serviert WebUI-SPA, │
│ ▼ zum Testen von Analysen & Design │
│ ┌──────────────────┐ liest/schreibt ▲ │
│ │ Analyse-DB │◄────────────────────────┘ │
│ └────────┬─────────┘ │
│ │ (nur SELECT-Grant) │
└────────────┼──────────────────────────────────────────────────────────────────────┘
│ ← EINZIGE Verbindung nach außen: read-only DB-Zugriff
┌────────────┼──────────────────────── EXTERNER WEBSERVER ───────────────────────────┐
│ ▼ │
│ Schicht 3: PUBLIC API + WebUI │
│ ──────────────────────────── │
│ nur Read-Endpunkte (identisch zur lokalen Read-Seite), │
│ Auth + Accounts, öffentliches Rate-Limiting, TLS, │
│ serviert dasselbe WebUI-SPA │
│ │ read-only │ read/write │
│ ▼ ▼ │
│ [Analyse-DB, RO] [User-DB (Accounts), separat] │
└────────────────────────────────────────────────────────────────────────────────────┘
```
**Kernprinzipien (aus der Nutzer-Entscheidung):**
- **Core ist rein lokal** und die einzige Instanz, die in die Analyse-DB **schreibt**.
- **Public spricht ausschließlich mit der Datenbank** — nie mit dem Core-Prozess, nie mit der
lokalen API. Public bekommt einen **read-only** DB-User auf die Analyse-DB und eine
**separate User-DB** für Accounts.
- **Lokale und Public WebUI sind optisch UND funktional gleich** (auf der Analyse-/Ansichtsseite),
damit neue Analysen und Designs lokal getestet werden und 1:1 öffentlich aussehen.
## 1. Wie „identisch" technisch erreicht wird
Damit beide WebUIs garantiert gleich sind, wird die **Read-/Analyse-Seite genau einmal** gebaut
und von beiden Hosts wiederverwendet — kein Duplizieren:
1. **Geteilte Read-API-Library.** `Predictalytics.Api` ist bereits eine Library. Die
Endpoint-Registrierung wird in zwei Gruppen gespalten:
- `MapReadEndpoints()`**die Analyse-/Ansichts-Endpunkte** (Trader-Liste/-Detail/-Profil/
-Positionen, Markets, Dashboard, Search, Watchlist-Ansicht, Traits). Wird von **beiden**
Hosts registriert.
- `MapControlEndpoints()`**schreibende/steuernde** Endpunkte (Jobs-Trigger, `/api/dev`,
Trader-Anlage, Watchlist-Mutation, Priority, AI-Analyse-Trigger). Wird **nur lokal**
registriert.
2. **Ein einziges WebUI-SPA-Artefakt.** Dasselbe Build wird von der lokalen und der öffentlichen
Schicht ausgeliefert. Es ist reiner API-Client (kein Server-Code, keine DB-Kenntnis).
3. **Capability-gesteuerte Admin-Bedienelemente.** Da die Control-Endpunkte öffentlich fehlen,
fragt das SPA beim Start eine `GET /api/capabilities` ab (`{ canControl: bool, authRequired: bool }`)
und blendet Admin-Aktionen (Sync/Analyze/Deep-Resync/Trader-Anlegen) nur ein, wenn vorhanden.
→ Analyse-Ansichten sind überall identisch; nur die lokalen Steuer-Buttons fehlen öffentlich.
> **Wichtiger Audit-Punkt vor dem Teilen:** Manche „Read"-Endpunkte haben heute versteckte
> Nebenwirkungen. Beispiel: `GET /api/traders/{id}/deep-dive` **holt Preis-Historie vom Provider
> und speichert Snapshots** — also ein Schreibzugriff **und** ein externer API-Call. Solche
> Endpunkte sind **nicht** public-tauglich (read-only DB verbietet den Write, und Public darf
> Polymarket nicht anrufen). Vor dem Aufnehmen in `MapReadEndpoints()` jeden Endpunkt auf
> versteckte Writes/Provider-Calls prüfen; die Public-Read-Seite muss **rein aus persistierten
> Daten** bedienbar sein (das `/profile`-Design aus FIXPLAN E ist genau deshalb „persisted-only").
## 2. Was heute wo läuft (Ist → Ziel)
- **Heute:** Core + Lokale API + WebUI sind **ein** Prozess (WinFormsHost/EmbeddedWebServer:
Worker + volle API + `wwwroot`-SPA, localhost, keine Auth, CORS `*`). Public existiert nicht.
- **Ziel Lokal:** bleibt bequem **ein** Prozess (Core-Worker + Lokale API + WebUI zusammen ist
zum Testen praktisch). Wichtig ist nur die **Code-Schichtung** (Read-Library getrennt von
Control/Core), damit Public die Read-Library **ohne** Core/Worker/Control referenzieren kann.
- **Ziel Public:** eigener schlanker Host (neues Projekt `Predictalytics.PublicApi`), der
`MapReadEndpoints()` + Auth registriert, das SPA ausliefert, und **nur** die read-only
Analyse-DB + die User-DB kennt.
## 3. Implementierungsplan (schrittweise)
### Phase 0 — Endpunkte klassifizieren & Read/Control trennen (Fundament, klein)
- [ ] Jeden Endpunkt in `docs/API.md` als **Read** (public-tauglich) oder **Control** (nur lokal)
markieren. Kandidaten Control: alle `POST/PUT/DELETE`, `/api/dev/*`, Job-Trigger.
- [ ] Auf **versteckte Writes/Provider-Calls** in Read-Endpunkten prüfen (Deep-Dive!) und
bereinigen oder als Control einstufen.
- [ ] `MapPredictalyticsEndpoints()` in `MapReadEndpoints()` + `MapControlEndpoints()` splitten
(rein struktureller Refactor, Verhalten unverändert; bestehende Tests bleiben grün).
- [ ] `GET /api/capabilities` einführen.
### Phase 1 — WebUI als sauberes, gemeinsames SPA (parallel zum neuen Design)
- [ ] Neues Design als **ein statisches Artefakt** bauen, reiner API-Client, `API_BASE`
konfigurierbar (heute `''` = gleiche Origin bleibt lokal gültig).
- [ ] Admin-Aktionen an `capabilities.canControl` koppeln.
- [ ] Trait-Chips/-Filter im Design vorsehen — **erscheinen erst, wenn Backend-D2 steht**
(kein UI-Bug, das Backend liefert Traits noch nicht).
### Phase 2 — Lokale Grenze härten (klein, jetzt schon sinnvoll)
- [ ] CORS von `AllowAnyOrigin` auf konfigurierte Origin-Whitelist umstellen.
- [ ] Bind-Adresse explizit (localhost/LAN), nie `0.0.0.0` ohne Firewall; Konfig-Kommentar
„NIEMALS ins Internet — das ist die interne Schicht".
### Phase 3 — DB für Public vorbereiten (mittel, kann früh passieren)
- [ ] **Read-only DB-User** auf der Analyse-DB anlegen (nur `SELECT`). Der Nutzer führt das SQL
selbst aus (Grant-Statements liefern wir).
- [ ] `AppDbContext` public-seitig read-only konfigurieren (read-only Connection; keine
Migrations, kein SaveChanges). Sicherstellen, dass die Read-Endpunkte ohne Writes auskommen.
- [ ] **Schema-Kopplung dokumentieren:** Weil Public direkt auf der Analyse-DB liest, ist das
DB-Schema jetzt eine **Vertragsfläche**. Schema-Änderungen im Core müssen die Public-Read-
Views berücksichtigen (Views/stabile Spalten als Puffer erwägen, damit interne
Refactorings die öffentliche Sicht nicht brechen).
### Phase 4 — Public-Host bauen (groß, wenn Release näher rückt)
- [ ] Neues Projekt `Predictalytics.PublicApi` (eigener Host, TLS, öffentliche Bind-Adresse).
- [ ] `MapReadEndpoints()` + **Auth/Accounts** (separate **User-DB**; Passwörter/Sessions nach
Stand der Technik — Secret-Handling lebt **nur hier**, nie im Core).
- [ ] Öffentliches **Rate-Limiting & Quotas pro Account** (getrennt vom internen Polymarket-Limiter).
- [ ] Mandanten-Sicht: Kunde sieht seine Watchlist/kopierten Master, nicht das gesamte Universum.
- [ ] Dasselbe WebUI-SPA ausliefern.
## 4. Explizite Nicht-Ziele / Fallen
- ❌ Public bekommt **keinen** Zugriff auf Core-Prozess, lokale API oder Control-Endpunkte.
- ❌ Public bekommt **keinen** Schreibzugriff auf die Analyse-DB (nur `SELECT`).
-**Kein** Nachrüsten von Kunden-Auth auf die lokale Vollschicht — Auth lebt nur im Public-Host.
- ❌ Read-Endpunkte machen **keine** Provider-Calls und **keine** Writes (sonst public-untauglich).
- ❌ WebUI kennt die DB nicht — immer nur über die API.
## 5. Reihenfolge / Aufwand
1. **Phase 0** (klein): Read/Control-Split + Capabilities + Deep-Dive-Audit. **Jetzt** — es ist
die Voraussetzung dafür, dass das neue Design von Anfang an sauber sitzt.
2. **Phase 1+2** (klein, parallel): neues SPA als gemeinsames Artefakt, lokale Grenze härten.
3. **Phase 3** (mittel): read-only DB-User + Schema-als-Vertrag dokumentieren.
4. **Phase 4** (groß, später): Public-Host mit Auth/User-DB.
Phase 02 begleiten das neue Design sofort. Phase 3 kann jederzeit vorgezogen werden (billig).
Phase 4 erst zum Release — aber durch 03 ist dann nichts mehr umzubauen, nur zu ergänzen.
+137
View File
@@ -0,0 +1,137 @@
# Plan: Daten-Ingest skalieren (Rate-Limits, Egress-Kanäle, Blockchain)
> Stand: 2026-07-12 · Status: **Richtung bestätigt** (Nutzer: beide Egress-Wege umschaltbar
> umsetzen, sofern Aufwand vertretbar). Kein Code in diesem Schritt.
> Problem: Polymarket-Rate-Limits bremsen den Datenimport teils extrem aus.
## 0. Ist-Zustand (verifiziert)
- `RateLimiterService` = **globaler Singleton-Token-Bucket**, feste Delays je Endpoint-Gruppe
(Gamma ≈28/s, Data ≈18/s, Clob ≈66/s).
- `PolymarketApiClient` = Singleton mit 3 benannten HttpClients, **ohne** Proxy/IP-Konfig →
ein einziger Ausgangs-IP, ein globales Budget. Das ist die echte Bremse.
**Zwei Hebel:** Nachfrage senken (Abschnitt 1, größter Gewinn) und Angebot erhöhen (Abschnitt 2,
Egress-Kanäle). Der Nutzer möchte in Abschnitt 2 **beide Egress-Arten (eigene IPs UND Proxys)
umschaltbar** — und das ist billig, weil beide **derselbe HttpClient-Seam** sind (Abschnitt 2).
---
## 1. Nachfrage senken — größter Hebel (zuerst)
### 1a. Historische Massendaten aus Blockchain/Subgraph statt REST
Polymarket-Trades sind On-Chain-Events auf Polygon. Für **Backfill/Deep-Resync** (größter
REST-Verbraucher) ist `/activity` die falsche Quelle.
- [ ] The-Graph-/Goldsky-Subgraph für Polymarket evaluieren: ein GraphQL-Call → tausende Fills
eines Wallets, statt seitenweiser rate-limitierter REST-Abrufe.
- [ ] Neue `IHistoricalTradeSource` neben dem REST-Provider; DeepResync (FIXPLAN A6) zieht Bulk
hierüber. **Effekt:** nimmt die teuerste Last komplett von der REST-API.
### 1b. Markt-Stammdaten aggressiv cachen
- [ ] Metadaten (Frage, Kategorie, Outcomes, `ConditionId``TokenId`) einmal ziehen, lange cachen;
nur Volume/Liquidity/Auflösung periodisch aktualisieren. Geschlossene Märkte nie voll re-syncen.
### 1c. Ingest-Tiering (FIXPLAN D3)
- [ ] Ultra-HF-Trader im `SnapshotOnly`-Modus erzeugen null Trade-Calls (PnL aus `/positions` +
Leaderboard, wöchentliche Biopsie). Entfernt genau die Wallets, die das Budget auffressen.
### 1d. Redundanz vermeiden
- [ ] Worker-übergreifender Kurzzeit-Cache „gerade geholt", damit nicht mehrere Worker denselben
Markt/Trader kurz hintereinander abrufen.
---
## 2. Angebot erhöhen: umschaltbare **Egress-Kanäle** (eigene IPs UND Proxys — eine Mechanik)
**Die Schlüssel-Einsicht:** Ob eine Anfrage über eine **eigene Quell-IP** oder über einen
**Proxy** rausgeht, ist im HttpClient nur eine andere Konfiguration desselben `SocketsHttpHandler`.
Deshalb wird **nicht** zweimal gebaut, sondern **ein** Konzept: der **Egress-Kanal**. Umschalten =
Konfiguration, nicht Code. Damit bekommt der Nutzer „beides, umschaltbar" zu geringen Kosten.
### 2a. Abstraktion `EgressChannel`
Ein Kanal ist genau eine Ausgangsroute, per Config als einer von zwei Typen definiert:
```jsonc
"Egress": {
"Channels": [
{ "id": "ip-a", "type": "SourceIp", "value": "203.0.113.10" }, // eigene IP
{ "id": "ip-b", "type": "SourceIp", "value": "203.0.113.11" },
{ "id": "prox-1", "type": "Proxy", "value": "http://user:pass@proxy.example:8080" }
]
}
```
- [ ] Pro Kanal **ein** `SocketsHttpHandler`:
- `SourceIp``ConnectCallback`, Socket vor Connect an die lokale IP binden
(`socket.Bind(new IPEndPoint(ip, 0))`).
- `Proxy``handler.Proxy = new WebProxy(url); handler.UseProxy = true;`.
- [ ] `IEgressPool` verteilt Requests round-robin/least-loaded über die aktiven Kanäle.
Leere/❑ Kanalliste = heutiges Verhalten (ein Default-Ausgang).
- [ ] Umschalten „nur eigene IPs" ↔ „nur Proxys" ↔ „Mix" = Config ändern, kein Deploy-Umbau.
### 2b. Rate-Limiter **pro Kanal** (Generalisierung des globalen Limiters)
- [ ] Limiter-Schlüssel wird `{platform}-{endpointGroup}-{channelId}`. Jeder Kanal hält sein
eigenes Budget → N Kanäle ≈ N× Durchsatz, jeder Kanal bleibt unter dem Per-Route-Limit.
- [ ] 429 sperrt **nur den betroffenen Kanal** kurz, nicht alle.
### 2c. Betrieb
- [ ] Bei eigenen IPs prüfen: sind es echte getrennte Egress-IPs (multi-homed), nicht NAT hinter einer.
- [ ] Health/Statistik je Kanal (Erfolg, 429-Rate, Latenz), damit tote Proxys automatisch pausiert werden.
**Aufwand:** moderat und **einmalig** — durch die gemeinsame Abstraktion kostet „beides
umschaltbar" kaum mehr als „nur IPs".
---
## 3. Proxys & Header-Rotation — Einordnung (korrigiert)
**Klarstellung (Korrektur einer früheren Fassung):** Predictalytics betreibt **kein Trading und
keine Wallet** — es ist reine Analysesoftware, die ausschließlich **öffentliche** Marktdaten liest.
Das frühere „Ban gefährdet die Trading-Wallet"-Argument gehört zu **PolyTrader** (getrenntes
Projekt) und trifft hier **nicht** zu. Damit ist die Proxy-Wahl eine reine Engineering-Entscheidung
des Nutzers — freie Proxys eingeschlossen.
Was real bleibt (ehrliche Hinweise, keine Blocker — Entscheidung liegt beim Nutzer):
- **ToS-Grauzone:** Rate-Limit-Umgehung per Routen-/Header-Rotation widerspricht vermutlich
Polymarkets Nutzungsbedingungen. Realistische Konsequenz **hier**: einzelne IPs/Proxys werden
geblockt und müssen ersetzt werden — mehr nicht (kein Kapital, keine Wallet betroffen).
- **Datenintegrität (der eigentlich relevante Punkt):** Ein kaputter/bösartiger (v. a. gratis)
Proxy kann Antworten **verfälschen** → korrupte Analyse („garbage in, garbage out"). Da die
gesamte Auswertung darauf aufbaut, lohnt sich eine **Plausibilitätsprüfung** der Antworten
(Feldtypen, Wertebereiche, z. B. Preise ∈ [0,1]) und — wo Genauigkeit zählt — das Bevorzugen
kontrollierter Proxys/eigener IPs. Rein informativ, kein Zwang.
- **DB-Verbindung NIE über Proxy** (ausdrücklicher Nutzer-Wille): Die MySQL-Verbindung läuft
**immer direkt**. Der Egress-Pool gilt **ausschließlich** für ausgehende Polymarket-HTTP-Calls,
niemals für die DB.
### 3a. Header-Rotation (separat aktivierbar, standardmäßig AUS)
Auf Wunsch integriert, bewusst opt-in:
- [ ] Config `Egress.HeaderRotation.Enabled`**Default `false`**. Bei `false` wird ein einziger,
konsistenter Standard-Header-Satz gesendet (heutiges Verhalten).
- [ ] Rotiert **kohärente Header-SETS**, nicht nur den User-Agent isoliert: je Eintrag ein
zusammenpassendes Bündel (`User-Agent` + `Accept` + `Accept-Language` + `Sec-CH-UA`…), damit
die Kombination realistisch bleibt — ein moderner UA mit widersprüchlichen Accept-Headern
fällt eher auf als gar keine Rotation. Set-Pool aus Config ladbar.
- [ ] Auswahl pro Kanal **oder** pro Request (konfigurierbar).
- [ ] Greift nur für Polymarket-Read-Calls; unabhängig vom Kanaltyp (IP oder Proxy) nutzbar.
---
## 4. Reihenfolge
1. **1b + 1d** (Caching/Dedup) — sofort, klein, spürbar.
2. **2a2c** (Egress-Kanäle + Per-Kanal-Limiter) — moderat, liefert „eigene IPs UND Proxys umschaltbar".
3. **1c** (Tiering) — hängt an FIXPLAN D3.
4. **1a** (Blockchain/Subgraph-Bulk) — größter struktureller Hebel; eigener Rechercheschritt
(welcher Subgraph deckt Fills sauber ab?), dann als `IHistoricalTradeSource`.
## 5. Tests / Abnahme
- [ ] Per-Kanal-Limiter: unabhängige Budgets (kein globales Blocken); 429 sperrt nur einen Kanal.
- [ ] Egress-Binding: Smoke gegen einen Dienst, der die Quell-IP zurückgibt → Round-Robin nutzt
wirklich verschiedene IPs; Proxy-Kanal geht über den Proxy.
- [ ] Kanal-Health: toter Proxy wird automatisch pausiert, Pool weicht aus.
- [ ] Blockchain-Quelle: Stichproben-Abgleich Bulk-Historie ↔ REST-`/activity` eines Wallets,
bevor sie produktiv wird.
- [ ] **Header-Rotation:** bei `Enabled=false` wird genau ein konsistenter Standard-Satz gesendet
(kein Rotieren); bei `Enabled=true` stammt jeder gesendete Header-Satz **unverändert** aus dem
Pool (kohärent, keine zusammengewürfelten Felder).
- [ ] **DB-Guard:** die MySQL-Verbindung nutzt nie den Egress-Pool/Proxy (Regressionsschutz).
- [ ] **Response-Plausibilität:** grob unplausible Provider-Antworten (z. B. Preis außerhalb
[0,1]) werden erkannt und verworfen statt in die DB zu wandern.
+40 -17
View File
@@ -1,5 +1,10 @@
using Predictalytics.Api.Endpoints; using Predictalytics.Api.Endpoints;
using Predictalytics.Infrastructure; using Predictalytics.Infrastructure;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using System;
namespace Predictalytics.Api; namespace Predictalytics.Api;
@@ -14,8 +19,10 @@ public static class ApiConfiguration
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
new() { Title = "Predictalytics Analytics API", Version = "v1" })); new() { Title = "Predictalytics Analytics API", Version = "v1" }));
var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>()
?? new[] { "http://localhost:5000" };
builder.Services.AddCors(o => o.AddDefaultPolicy(p => builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
var app = builder.Build(); var app = builder.Build();
@@ -29,28 +36,44 @@ public static class ApiConfiguration
OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache" OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache"
}); });
app.MapPredictalyticsEndpoints(); // Register both Read and Control endpoints for the local host
app.MapPredictalyticsReadEndpoints();
app.MapPredictalyticsControlEndpoints();
return app; return app;
} }
/// <summary> /// <summary>
/// Single source of truth for ALL API endpoint registrations. /// Registers all Read-only endpoints designed for viewing and analysis.
/// Both the standalone API and the embedded WinForms Kestrel server MUST use /// Safely exposed publicly since these endpoints perform no DB writes.
/// this method — never register endpoints individually in either host, or the
/// two servers drift apart (missing /api/watchlist in the embedded host was
/// exactly this class of bug).
/// </summary> /// </summary>
public static void MapPredictalyticsEndpoints(this WebApplication app) public static void MapPredictalyticsReadEndpoints(this IEndpointRouteBuilder routes)
{ {
app.MapDashboardEndpoints(); routes.MapDashboardEndpoints();
app.MapTraderEndpoints(); routes.MapTraderReadEndpoints();
app.MapAlertEndpoints(); routes.MapAlertReadEndpoints();
app.MapMarketEndpoints(); routes.MapMarketEndpoints();
app.MapSearchEndpoints(); routes.MapSearchEndpoints();
app.MapJobEndpoints(); routes.MapWatchlistEndpoints();
app.MapDevEndpoints(); routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
app.MapWatchlistEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow })); routes.MapGet("/api/capabilities", (IConfiguration config) =>
{
var canControl = config.GetValue<bool>("ApiSettings:CanControl", true);
var authRequired = config.GetValue<bool>("ApiSettings:AuthRequired", false);
return Results.Ok(new { CanControl = canControl, AuthRequired = authRequired });
});
}
/// <summary>
/// Registers all Control endpoints that trigger crawls, modifications, or database updates.
/// Excluded from the Public API routing to ensure system security.
/// </summary>
public static void MapPredictalyticsControlEndpoints(this IEndpointRouteBuilder routes)
{
routes.MapTraderControlEndpoints();
routes.MapAlertControlEndpoints();
routes.MapJobEndpoints();
routes.MapDevEndpoints();
} }
} }
@@ -1,15 +1,23 @@
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Predictalytics.Api.Endpoints; namespace Predictalytics.Api.Endpoints;
public static class AlertEndpoints public static class AlertEndpoints
{ {
public static void MapAlertEndpoints(this WebApplication app) public static void MapAlertReadEndpoints(this IEndpointRouteBuilder routes)
{ {
var group = app.MapGroup("/api/alerts").WithTags("Alerts"); var group = routes.MapGroup("/api/alerts").WithTags("Alerts");
group.MapGet("/", async (IAlertService svc, int? count, bool? unreadOnly, CancellationToken ct) => group.MapGet("/", async (IAlertService svc, int? count, bool? unreadOnly, CancellationToken ct) =>
Results.Ok(await svc.GetRecentAlertsAsync(count ?? 50, unreadOnly ?? false, ct))); Results.Ok(await svc.GetRecentAlertsAsync(count ?? 50, unreadOnly ?? false, ct)));
}
public static void MapAlertControlEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/alerts").WithTags("Alerts");
group.MapPut("/{id:int}/read", async (int id, IAlertService svc, CancellationToken ct) => group.MapPut("/{id:int}/read", async (int id, IAlertService svc, CancellationToken ct) =>
{ {
@@ -1,12 +1,15 @@
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Predictalytics.Api.Endpoints; namespace Predictalytics.Api.Endpoints;
public static class DashboardEndpoints public static class DashboardEndpoints
{ {
public static void MapDashboardEndpoints(this WebApplication app) public static void MapDashboardEndpoints(this IEndpointRouteBuilder routes)
{ {
app.MapGet("/api/dashboard", async (IAnalyticsService svc, CancellationToken ct) => routes.MapGet("/api/dashboard", async (IAnalyticsService svc, CancellationToken ct) =>
Results.Ok(await svc.GetDashboardAsync(ct))) Results.Ok(await svc.GetDashboardAsync(ct)))
.WithTags("Dashboard"); .WithTags("Dashboard");
} }
@@ -1,12 +1,15 @@
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Predictalytics.Api.Endpoints; namespace Predictalytics.Api.Endpoints;
public static class MarketEndpoints public static class MarketEndpoints
{ {
public static void MapMarketEndpoints(this WebApplication app) public static void MapMarketEndpoints(this IEndpointRouteBuilder routes)
{ {
var group = app.MapGroup("/api/markets").WithTags("Markets"); var group = routes.MapGroup("/api/markets").WithTags("Markets");
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, string? category, string? query, CancellationToken ct) => group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, string? category, string? query, CancellationToken ct) =>
{ {
@@ -7,9 +7,9 @@ namespace Predictalytics.Api.Endpoints;
public static class SearchEndpoints public static class SearchEndpoints
{ {
public static void MapSearchEndpoints(this WebApplication app) public static void MapSearchEndpoints(this IEndpointRouteBuilder routes)
{ {
app.MapGet("/api/search", async (string q, IAnalyticsService svc, CancellationToken ct) => routes.MapGet("/api/search", async (string q, IAnalyticsService svc, CancellationToken ct) =>
{ {
if (string.IsNullOrWhiteSpace(q)) return Results.BadRequest("Query cannot be empty"); if (string.IsNullOrWhiteSpace(q)) return Results.BadRequest("Query cannot be empty");
var results = await svc.SearchAsync(q, ct); var results = await svc.SearchAsync(q, ct);
@@ -1,14 +1,17 @@
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
using Predictalytics.Application.Services; using Predictalytics.Application.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Predictalytics.Api.Endpoints; namespace Predictalytics.Api.Endpoints;
public static class TraderEndpoints public static class TraderEndpoints
{ {
public static void MapTraderEndpoints(this WebApplication app) public static void MapTraderReadEndpoints(this IEndpointRouteBuilder routes)
{ {
var group = app.MapGroup("/api/traders").WithTags("Traders"); var group = routes.MapGroup("/api/traders").WithTags("Traders");
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, string? trait, CancellationToken ct) => group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, string? trait, CancellationToken ct) =>
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, trait, ct))); Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, trait, ct)));
@@ -22,48 +25,12 @@ public static class TraderEndpoints
return detail is not null ? Results.Ok(detail) : Results.NotFound(); return detail is not null ? Results.Ok(detail) : Results.NotFound();
}); });
group.MapGet("/{id:int}/deep-dive", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{
var dd = await svc.GetTraderDeepDiveAsync(id, ct);
return dd is not null ? Results.Ok(dd) : Results.NotFound();
});
group.MapGet("/{id:int}/positions", async (int id, IAnalyticsService svc, CancellationToken ct) => group.MapGet("/{id:int}/positions", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{ {
var positions = await svc.GetTraderPositionsAsync(id, ct); var positions = await svc.GetTraderPositionsAsync(id, ct);
return Results.Ok(positions); return Results.Ok(positions);
}); });
group.MapPost("/{id:int}/priority", async (int id, int? score, IScoringService svc, CancellationToken ct) =>
{
await svc.SetManualOverrideAsync(id, score, ct);
return Results.Ok();
});
group.MapPost("/{id:int}/watchlist", async (int id, WatchlistService svc, CancellationToken ct) =>
{
await svc.AddAsync(id, "Watched via UI", null, ct);
return Results.Ok();
});
group.MapDelete("/{id:int}/watchlist", async (int id, WatchlistService svc, CancellationToken ct) =>
{
await svc.RemoveByTraderIdAsync(id, ct);
return Results.Ok();
});
group.MapPost("/{id:int}/ai-analysis", async (int id, bool manual, IAiStrategyAnalysisService aiSvc, CancellationToken ct) =>
{
var summary = await aiSvc.AnalyzeTraderStrategyAsync(id, manual, ct);
return Results.Ok(new { summary });
});
group.MapPost("/", async (string platform, string wallet, IAnalyticsService svc, CancellationToken ct) =>
{
var id = await svc.AddTraderAsync(platform, wallet, ct);
return Results.Ok(new { id });
});
group.MapGet("/{id:int}/profile", async (int id, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) => group.MapGet("/{id:int}/profile", async (int id, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
{ {
var trader = await db.Traders.Include(t => t.Analytics).FirstOrDefaultAsync(t => t.Id == id, ct); var trader = await db.Traders.Include(t => t.Analytics).FirstOrDefaultAsync(t => t.Id == id, ct);
@@ -128,4 +95,45 @@ public static class TraderEndpoints
)); ));
}); });
} }
public static void MapTraderControlEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/traders").WithTags("Traders");
group.MapGet("/{id:int}/deep-dive", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{
var dd = await svc.GetTraderDeepDiveAsync(id, ct);
return dd is not null ? Results.Ok(dd) : Results.NotFound();
});
group.MapPost("/{id:int}/priority", async (int id, int? score, IScoringService svc, CancellationToken ct) =>
{
await svc.SetManualOverrideAsync(id, score, ct);
return Results.Ok();
});
group.MapPost("/{id:int}/watchlist", async (int id, WatchlistService svc, CancellationToken ct) =>
{
await svc.AddAsync(id, "Watched via UI", null, ct);
return Results.Ok();
});
group.MapDelete("/{id:int}/watchlist", async (int id, WatchlistService svc, CancellationToken ct) =>
{
await svc.RemoveByTraderIdAsync(id, ct);
return Results.Ok();
});
group.MapPost("/{id:int}/ai-analysis", async (int id, bool manual, IAiStrategyAnalysisService aiSvc, CancellationToken ct) =>
{
var summary = await aiSvc.AnalyzeTraderStrategyAsync(id, manual, ct);
return Results.Ok(new { summary });
});
group.MapPost("/", async (string platform, string wallet, IAnalyticsService svc, CancellationToken ct) =>
{
var id = await svc.AddTraderAsync(platform, wallet, ct);
return Results.Ok(new { id });
});
}
} }
+9
View File
@@ -10,5 +10,14 @@
"OpenRouter": { "OpenRouter": {
"BaseUrl": "https://openrouter.ai/api/v1", "BaseUrl": "https://openrouter.ai/api/v1",
"ApiKey": "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" "ApiKey": "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"
},
"Egress": {
"Channels": []
},
"ApiSettings": {
"CanControl": true,
"AuthRequired": false,
"AllowedOrigins": [ "http://localhost:5000" ],
"ReadOnlyDatabase": false
} }
} }
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Predictalytics API Reference</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; }
* { box-sizing: border-box; }
a { color: #5b9dff; text-decoration: none; }
a:hover { color: #8ab8ff; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
pre { margin: 0; font-family: 'Roboto Mono', monospace; }
</style>
</head>
<body>
<div style="position:relative; min-height:100vh; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.22), transparent 60%), #0a0d14; color:#EDEFF5;">
<header style="position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:space-between; padding:16px 48px; backdrop-filter:blur(20px); background:rgba(10,13,20,0.6); border-bottom:1px solid rgba(255,255,255,0.07);">
<div style="display:flex; align-items:center; gap:10px;">
<a href="./landing.html" style="display:flex; align-items:center; gap:10px;">
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em; color:white;">Predictalytics<span style="color:#5b9dff;">.</span></div>
</a>
</div>
<nav style="display:flex; align-items:center; gap:32px;">
<a href="./landing.html#features" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Features</a>
<a href="./landing.html#pricing" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Preise</a>
<a href="./landing.html#faq" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">FAQ</a>
<div style="font-size:13.5px; font-weight:700; color:#5b9dff;">API Docs</div>
</nav>
<a href="./index.html" style="padding:9px 20px; border-radius:10px; font-size:13.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); color:white;">Dashboard</a>
</header>
<div style="display:flex; max-width:1280px; margin:0 auto; padding:0 24px;">
<!-- SIDEBAR -->
<aside style="width:230px; flex:none; padding:36px 16px; position:sticky; top:68px; align-self:flex-start; height:calc(100vh - 68px); overflow-y:auto;">
<div style="font-size:11px; font-weight:700; color:#5B6377; letter-spacing:0.06em; padding:0 12px 8px;">GETTING STARTED</div>
<div onclick="scrollToSection('sec-auth')" style="cursor:pointer; padding:9px 12px; border-radius:9px; font-size:13.5px; font-weight:600; color:#EDEFF5; margin-bottom:2px;">Authentication</div>
<div onclick="scrollToSection('sec-limits')" style="cursor:pointer; padding:9px 12px; border-radius:9px; font-size:13.5px; font-weight:600; color:#8B93A7; margin-bottom:2px;">Rate Limits</div>
<div style="font-size:11px; font-weight:700; color:#5B6377; letter-spacing:0.06em; padding:16px 12px 8px;">ENDPOINTS</div>
<div id="sidebarEndpoints" style="display:flex; flex-direction:column; gap:2px;"></div>
</aside>
<!-- CONTENT -->
<main style="flex:1; min-width:0; padding:36px 40px 100px;">
<h1 style="margin:0 0 10px; font-size:30px; font-weight:800; letter-spacing:-0.02em;">API Reference</h1>
<p style="margin:0 0 32px; color:#8B93A7; font-size:14.5px; max-width:640px; line-height:1.6;">Programmatischer Zugriff auf jeden Trader, Markt und historischen Trade in Predictalytics. Alle Endpunkte liefern JSON über HTTP(S).</p>
<!-- Auth -->
<section id="sec-auth" style="margin-bottom:40px; scroll-margin-top:90px;">
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Authentication</h2>
<p style="color:#8B93A7; font-size:13.5px; line-height:1.7; margin:0 0 14px;">Übergebe deinen API-Key (sofern aktiv) als Bearer-Token in jedem Request-Header.</p>
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:18px 20px; font-family:'Roboto Mono'; font-size:13px; color:#C7CCDA; overflow-x:auto;">
<pre>curl http://localhost:5000/api/dashboard \
-H "Authorization: Bearer DEIN_API_KEY"</pre>
</div>
</section>
<!-- Rate limits -->
<section id="sec-limits" style="margin-bottom:40px; scroll-margin-top:90px;">
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Rate limits</h2>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:14px;">
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px;">
<div style="font-size:12.5px; font-weight:700; color:#8B93A7; margin-bottom:6px;">Lokal / Dev</div>
<div style="font-size:20px; font-weight:800; font-family:'Roboto Mono';">Unlimitiert</div>
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px;">
<div style="font-size:12.5px; font-weight:700; color:#8B93A7; margin-bottom:6px;">Pro-Plan</div>
<div style="font-size:20px; font-weight:800; font-family:'Roboto Mono';">2.000 / Tag</div>
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px;">
<div style="font-size:12.5px; font-weight:700; color:#8B93A7; margin-bottom:6px;">Enterprise</div>
<div style="font-size:20px; font-weight:800; font-family:'Roboto Mono';">Custom</div>
</div>
</div>
</section>
<!-- Dynamic Endpoints -->
<div id="endpointsContainer"></div>
</main>
</div>
</div>
<script>
const endpoints = [
{
id: "ep-capabilities",
method: "GET",
path: "/api/capabilities",
desc: "Gibt die Berechtigungen (CanControl, AuthRequired) des aktuellen Webservers zurück.",
params: [],
response: '{\n "canControl": true,\n "authRequired": false\n}'
},
{
id: "ep-dashboard",
method: "GET",
path: "/api/dashboard",
desc: "Liefert globale Statistiken sowie Listen von Top-Tradern und aktuellen Trades.",
params: [],
response: '{\n "totalTraders": 142,\n "activeTraders24h": 45,\n "volume24h": 850420.50,\n "topTraders": [...],\n "recentTrades": [...]\n}'
},
{
id: "ep-traders",
method: "GET",
path: "/api/traders",
desc: "Liefert eine gefilterte Liste aller überwachten Trader.",
params: [
{ name: "platform", type: "string", desc: "Z.B. Polymarket, Limitless" },
{ name: "sortBy", type: "string", desc: "score, winrate, pnl, name" },
{ name: "minWinRate", type: "number", desc: "Mindest-Win-Rate in %" }
],
response: '[\n {\n "id": 12,\n "displayName": "quant_owl",\n "platform": "Polymarket",\n "combinedScore": 91.5,\n "winRate": 64.2,\n "totalPnl": 216420.00\n }\n]'
},
{
id: "ep-trader-detail",
method: "GET",
path: "/api/traders/{id}",
desc: "Detaillierte Informationen, P&L-Statistiken, Traits und Trade-Historie eines Traders.",
params: [
{ name: "id", type: "integer", desc: "Interne ID des Traders" }
],
response: '{\n "id": 12,\n "displayName": "quant_owl",\n "platform": "Polymarket",\n "traits": [{"trait": "Whale", "value": 1.0}],\n "recentTrades": [...]\n}'
},
{
id: "ep-markets",
method: "GET",
path: "/api/markets",
desc: "Gibt alle erfassten Wetten/Märkte zurück.",
params: [
{ name: "platform", type: "string", desc: "Filter nach Plattform" }
],
response: '[\n {\n "id": 8,\n "question": "Will Ethereum exceed $4,000 in July?",\n "volume": 2541090.00,\n "isResolved": false\n }\n]'
}
];
// Render Sidebar
document.getElementById('sidebarEndpoints').innerHTML = endpoints.map(ep => {
let methodColor = ep.method === 'GET' ? '#12D48A' : '#ff9d4c';
return `
<div onclick="scrollToSection('${ep.id}')" style="cursor:pointer; display:flex; align-items:center; gap:8px; padding:9px 12px; border-radius:9px; font-size:13px; font-weight:600; color:#8B93A7; margin-bottom:2px;">
<span style="font-size:10px; font-weight:800; font-family:'Roboto Mono'; color:${methodColor};">${ep.method}</span>
<span>${ep.path}</span>
</div>
`;
}).join('');
// Render Content Endpoints
document.getElementById('endpointsContainer').innerHTML = endpoints.map(ep => {
let methodColor = ep.method === 'GET' ? '#12D48A' : '#ff9d4c';
let methodBg = ep.method === 'GET' ? 'rgba(18,212,138,0.12)' : 'rgba(255,157,76,0.12)';
let paramsHtml = ep.params.length > 0 ? ep.params.map(p => `
<div style="display:flex; align-items:baseline; gap:8px; margin-bottom:8px; font-size:12.5px;">
<span style="font-family:'Roboto Mono'; font-weight:700; color:#5b9dff;">${p.name}</span>
<span style="color:#5B6377; font-size:11.5px;">${p.type}</span>
<span style="color:#8B93A7;">${p.desc}</span>
</div>
`).join('') : '<div style="font-size:12.5px; color:#5B6377;">Keine Parameter erforderlich.</div>';
return `
<section id="${ep.id}" style="margin-bottom:40px; scroll-margin-top:90px;">
<div style="display:flex; align-items:center; gap:12px; margin-bottom:10px;">
<span style="font-size:11px; font-weight:800; font-family:'Roboto Mono'; padding:4px 9px; border-radius:6px; background:${methodBg}; color:${methodColor};">${ep.method}</span>
<span style="font-family:'Roboto Mono'; font-size:14.5px; font-weight:700; color:#EDEFF5;">${ep.path}</span>
</div>
<p style="color:#8B93A7; font-size:13.5px; line-height:1.6; margin:0 0 14px;">${ep.desc}</p>
<div style="display:grid; grid-template-columns:1fr 1.2fr; gap:16px;">
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:16px 18px;">
<div style="font-size:11px; font-weight:700; color:#5B6377; text-transform:uppercase; margin-bottom:10px;">Parameters</div>
${paramsHtml}
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:16px 18px; overflow-x:auto;">
<div style="font-size:11px; font-weight:700; color:#5B6377; text-transform:uppercase; margin-bottom:10px;">Response (JSON)</div>
<pre style="font-size:12px; color:#C7CCDA; line-height:1.4;">${ep.response}</pre>
</div>
</div>
</section>
`;
}).join('');
window.scrollToSection = function(id) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth' });
};
</script>
</body>
</html>
+424 -230
View File
@@ -1,204 +1,298 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Predictalytics — Multi-Platform Prediction Market Smart-Money Tracker & Analytics"> <meta name="description" content="Predictalytics — Multi-Platform Prediction Market Smart-Money Tracker & Analytics">
<title>Predictalytics</title> <title>Predictalytics</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="css/style.css?v=20260715">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=20260710">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
</head> </head>
<body> <body>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar"> <!-- Decorative glow layer -->
<div class="glow-layer"></div>
<div class="app-wrapper">
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-logo"> <div class="sidebar-logo">
<div class="logo-icon">P</div> <div class="logo-icon"></div>
<span class="logo-text">Predictalytics</span> <div class="logo-text">Predictalytics<span style="color:#5b9dff;">.</span></div>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<div class="nav-title">Navigation</div>
<a href="#" class="nav-item active" data-page="dashboard"> <a href="#" class="nav-item active" data-page="dashboard">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg> <div class="nav-dot"></div>
<span>Dashboard</span> <span>Dashboard</span>
</a> </a>
<a href="#" class="nav-item" data-page="traders"> <a href="#" class="nav-item" data-page="traders">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> <div class="nav-dot"></div>
<span>Traders</span> <span>Trader</span>
</a> </a>
<a href="#" class="nav-item" data-page="watchlist"> <a href="#" class="nav-item" data-page="watchlist">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> <div class="nav-dot"></div>
<span>Watchlist</span> <span>Watchlist</span>
</a> </a>
<a href="#" class="nav-item" data-page="markets"> <a href="#" class="nav-item" data-page="markets">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg> <div class="nav-dot"></div>
<span>Markets</span> <span>Märkte</span>
</a> </a>
<a href="#" class="nav-item" data-page="alerts"> <a href="#" class="nav-item" data-page="alerts">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> <div class="nav-dot"></div>
<span>Alerts</span> <span>Alerts</span>
<span class="badge" id="alertBadge" style="display:none">0</span> <span class="badge" id="alertBadge" style="display:none">0</span>
</a> </a>
<a href="#" class="nav-item" data-page="jobs"> <a href="#" class="nav-item" data-page="jobs" id="nav-jobs-container">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg> <div class="nav-dot"></div>
<span>Jobs</span> <span>Background Jobs</span>
</a> </a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<a href="/swagger" target="_blank" class="nav-item"> <div class="data-feed-badge">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> <div class="label">Daten-Feed</div>
<span>API Docs</span> <div class="status">
</a> <div class="dot"></div>
<span>Live · On-chain</span>
</div>
</div>
<div class="user-profile">
<div class="user-avatar"></div>
<div class="user-info">
<div class="name">Analyst</div>
<div class="workspace">Pro Workspace</div>
</div>
</div>
</div> </div>
</aside> </aside>
<!-- Main Content --> <!-- MAIN CONTENT -->
<main class="main-content"> <main class="main-content">
<!-- Top Bar -->
<!-- TOPBAR -->
<header class="topbar"> <header class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<select id="platformSelect" class="platform-select"> <select id="platformSelect" class="platform-select">
<option value="All">All Platforms</option> <option value="All">Alle Plattformen</option>
<option value="Polymarket">Polymarket</option> <option value="Polymarket">Polymarket</option>
<option value="Limitless">Limitless</option> <option value="Limitless">Limitless</option>
<option value="Azuro">Azuro</option> <option value="Azuro">Azuro</option>
</select> </select>
<div class="search-box"> <div class="search-box">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="searchInput" placeholder="Search traders, markets..." autocomplete="off"> <input type="text" id="searchInput" placeholder="Suche nach Tradern, Märkten, Wallets..." autocomplete="off">
</div> </div>
<select id="sortSelect" class="platform-select"> <select id="sortSelect" class="platform-select">
<option value="default">Sort by: Default</option> <option value="default">Sortieren: Standard</option>
<option value="score">Sort by: Score/Vol</option> <option value="score">Sortieren: Score/Vol</option>
<option value="name">Sort by: Name/Date</option> <option value="name">Sortieren: Name/Datum</option>
<option value="pnl">Sort by: PnL/Liq</option> <option value="pnl">Sortieren: PnL/Liq</option>
</select> </select>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
<div class="timeframe-toggle"> <div class="header-vol-badge" id="headerVolumeBadge">24H VOL: Loading...</div>
<button class="tf-btn active" data-tf="24h">24H</button> <div class="theme-toggle" id="themeToggle" title="Design wechseln">
<button class="tf-btn" data-tf="7d">7D</button> <svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<button class="tf-btn" data-tf="30d">30D</button> <svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</div>
<div class="theme-toggle" id="themeToggle" title="Toggle Dark/Light Mode">
<svg class="icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</div> </div>
</div> </div>
</header> </header>
<!-- Page Content --> <!-- PAGE VIEWS -->
<div class="page-content" id="pageContent"> <div id="pageContent">
<!-- Dashboard Page -->
<!-- 1. Dashboard View -->
<section class="page active" id="page-dashboard"> <section class="page active" id="page-dashboard">
<h1 class="page-title">Dashboard</h1> <div class="page-title-wrap">
<!-- Metric Cards --> <div>
<div class="metrics-grid" id="metricsGrid"> <h1 class="page-title">Märkte Übersicht</h1>
<div class="metric-card"><div class="metric-label">Total Traders</div><div class="metric-value" id="metricTraders"></div><div class="metric-delta positive">tracking</div></div> <div class="page-subtitle">Echtzeit-Analysen über alle Prognosemärkte</div>
<div class="metric-card"><div class="metric-label">Active (24h)</div><div class="metric-value" id="metricActive"></div><div class="metric-delta positive">live</div></div>
<div class="metric-card"><div class="metric-label">Total Trades</div><div class="metric-value" id="metricTrades"></div><div class="metric-delta">all time</div></div>
<div class="metric-card accent"><div class="metric-label">Volume (24h)</div><div class="metric-value" id="metricVolume"></div><div class="metric-delta positive">USD</div></div>
</div> </div>
<div class="timeframe-toggle" style="display:flex; background:rgba(255,255,255,0.04); border-radius:8px; padding:3px; border:1px solid var(--border);">
<button class="tf-btn active" data-tf="24h" style="padding:6px 12px; border:none; background:transparent; color:var(--text-muted); cursor:pointer; font-size:12px; font-weight:700; border-radius:6px; transition:var(--transition);">24H</button>
<button class="tf-btn" data-tf="7d" style="padding:6px 12px; border:none; background:transparent; color:var(--text-muted); cursor:pointer; font-size:12px; font-weight:700; border-radius:6px; transition:var(--transition);">7D</button>
<button class="tf-btn" data-tf="30d" style="padding:6px 12px; border:none; background:transparent; color:var(--text-muted); cursor:pointer; font-size:12px; font-weight:700; border-radius:6px; transition:var(--transition);">30D</button>
</div>
</div>
<!-- Global Metrics Grid -->
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Überwachte Trader</div>
<div class="metric-value" id="metricTraders"></div>
<div class="metric-delta positive">aktiv erfasst</div>
</div>
<div class="metric-card">
<div class="metric-label">Aktive Trader (24h)</div>
<div class="metric-value" id="metricActive"></div>
<div class="metric-delta positive">live on-chain</div>
</div>
<div class="metric-card">
<div class="metric-label">Erfasste Trades</div>
<div class="metric-value" id="metricTrades"></div>
<div class="metric-delta">gesamt</div>
</div>
<div class="metric-card accent">
<div class="metric-label">Handelsvolumen (24h)</div>
<div class="metric-value" id="metricVolume"></div>
<div class="metric-delta">USD</div>
</div>
</div>
<!-- Charts Row --> <!-- Charts Row -->
<div class="charts-row"> <div class="charts-row">
<div class="card chart-card"> <div class="card chart-card">
<div class="card-header"><h2>Platform Breakdown</h2></div> <div class="card-header"><h2>Verteilung nach Plattformen</h2></div>
<div class="chart-container"><canvas id="platformChart"></canvas></div> <div class="chart-container"><canvas id="platformChart"></canvas></div>
</div> </div>
<div class="card chart-card"> <div class="card chart-card">
<div class="card-header"><h2>Trader Tiers</h2></div> <div class="card-header"><h2>Trader Einstufungen</h2></div>
<div class="chart-container"><canvas id="tierChart"></canvas></div> <div class="chart-container"><canvas id="tierChart"></canvas></div>
</div> </div>
</div> </div>
<!-- Top Traders Table -->
<!-- Top Traders and Recent Trades -->
<div class="glass-row">
<div class="card"> <div class="card">
<div class="card-header"><h2>Top Traders</h2></div> <div class="card-header"><h2>Top-Performer</h2></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="topTradersTable"> <table class="data-table" id="topTradersTable">
<thead><tr><th>#</th><th>Trader</th><th>Platform</th><th>Score</th><th>Win Rate</th><th>PnL</th><th>Trades (30d|All)</th></tr></thead> <thead>
<tr>
<th>#</th>
<th>Trader</th>
<th>Plattform</th>
<th class="num-col">Score</th>
<th class="num-col">Win Rate</th>
<th class="num-col">PnL</th>
<th>Trades (30d|All)</th>
</tr>
</thead>
<tbody id="topTradersBody"></tbody> <tbody id="topTradersBody"></tbody>
</table> </table>
</div> </div>
</div> </div>
<!-- Recent Trades -->
<div class="card"> <div class="card">
<div class="card-header"><h2>Recent Trades</h2></div> <div class="card-header"><h2>Letzte Aktivitäten</h2></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="recentTradesTable"> <table class="data-table" id="recentTradesTable">
<thead><tr><th>Time</th><th>Trader</th><th>Market</th><th>Side</th><th>Price</th><th>Size</th><th>Amount</th></tr></thead> <thead>
<tr>
<th>Zeit</th>
<th>Trader</th>
<th>Seite</th>
<th class="num-col">Preis</th>
<th class="num-col">Menge</th>
</tr>
</thead>
<tbody id="recentTradesBody"></tbody> <tbody id="recentTradesBody"></tbody>
</table> </table>
</div> </div>
</div> </div>
</div>
</section> </section>
<!-- Traders Page --> <!-- 2. Traders List View -->
<section class="page" id="page-traders"> <section class="page" id="page-traders">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;"> <div class="page-title-wrap">
<div style="display:flex; align-items:center; gap:16px; flex-wrap:wrap;"> <div>
<h1 class="page-title" style="margin-bottom:0">Traders</h1> <h1 class="page-title">Trader-Bibliothek</h1>
<div class="page-subtitle">Verwalte und analysiere überwachte Wallets</div>
</div>
<div>
<select id="tradersSort" class="platform-select" onchange="loadTraders()"> <select id="tradersSort" class="platform-select" onchange="loadTraders()">
<option value="score">Sort: Combined Score</option> <option value="score">Sortieren: Combined Score</option>
<option value="winrate">Sort: Win Rate</option> <option value="winrate">Sortieren: Win Rate</option>
<option value="copyability">Sort: Copyability</option> <option value="copyability">Sortieren: Copyability</option>
<option value="pnl">Sort: Total PnL</option> <option value="pnl">Sortieren: Gesamt-PnL</option>
<option value="name">Sort: Name</option> <option value="name">Sortieren: Name</option>
</select> </select>
</div>
<div style="display:flex; align-items:center; gap:4px;">
<span style="font-size:13px; font-weight:600;">Win Rate ></span>
<input type="number" id="filterWinrateMin" class="platform-select" style="width:70px" placeholder="%" onchange="loadTraders()">
</div> </div>
<div style="display:flex; align-items:center; gap:4px;"> <div style="display:grid; grid-template-columns:1.5fr 1fr; gap:16px; margin-bottom:20px; align-items:stretch;">
<span style="font-size:13px; font-weight:600;">Copyability ></span> <!-- Custom Filter Panel -->
<input type="number" id="filterCopyabilityMin" class="platform-select" style="width:70px" placeholder="%" onchange="loadTraders()"> <div class="filter-card">
<div class="filter-section">
<div class="filter-label">Filter nach Kennzahlen</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<div>
<span style="font-size:12px; color:var(--text-muted); font-weight:600;">Mindest-Win-Rate:</span>
<input type="number" id="filterWinrateMin" class="platform-select" style="width:100%; margin-top:6px;" placeholder="%" onchange="loadTraders()">
</div>
<div>
<span style="font-size:12px; color:var(--text-muted); font-weight:600;">Mindest-Copyability:</span>
<input type="number" id="filterCopyabilityMin" class="platform-select" style="width:100%; margin-top:6px;" placeholder="%" onchange="loadTraders()">
</div>
</div>
</div> </div>
<select id="filterTrait" class="platform-select" onchange="loadTraders()"> <div class="filter-section" style="margin-bottom:0;">
<option value="">All Traits</option> <div class="filter-label">Auswahl nach Traits</div>
<div class="traits-grid">
<select id="filterTrait" class="platform-select" onchange="loadTraders()" style="width:100%;">
<option value="">Alle Traits anzeigen</option>
</select> </select>
<label style="display:flex; align-items:center; gap:8px; font-weight:600; cursor:pointer; font-size:12px; margin-top:10px; color:var(--text-secondary);">
<label style="display:flex; align-items:center; gap:8px; font-weight:600; cursor:pointer; background:var(--bg-surface); padding:8px 12px; border-radius:6px; border:1px solid var(--border);"> <input type="checkbox" id="chk-highly-copyable" onchange="loadTraders()"> Nur sehr gut kopierbare (Copyability > 70%)
<input type="checkbox" id="chk-highly-copyable" onchange="loadTraders()"> Highly Copyable
</label> </label>
</div> </div>
<div class="card" style="margin-bottom:0; padding:12px 20px; display:flex; gap:12px; align-items:center;"> </div>
<span style="font-size:13px; font-weight:600">Add Trader:</span> </div>
<!-- Add Trader Panel (Control-Mode Guarded) -->
<div class="filter-card" id="control-add-trader-panel" style="display:flex; flex-direction:column; justify-content:center;">
<div class="filter-label">Trader hinzufügen</div>
<div style="display:flex; flex-direction:column; gap:12px;">
<select id="addPlatform" class="platform-select"> <select id="addPlatform" class="platform-select">
<option value="Polymarket">Polymarket</option> <option value="Polymarket">Polymarket</option>
<option value="Limitless">Limitless</option> <option value="Limitless">Limitless</option>
</select> </select>
<input type="text" id="addWallet" placeholder="Wallet Address" class="platform-select" style="width:200px"> <input type="text" id="addWallet" placeholder="Wallet-Adresse / Handle" class="platform-select">
<button class="btn-sm" onclick="manualAddTrader()" style="padding:6px 16px; background:var(--accent); color:white; border:none">Add</button> <button class="btn-premium" onclick="manualAddTrader()" style="justify-content:center; padding:12px;">
Hinzufügen
</button>
</div> </div>
</div> </div>
</div>
<!-- Traders Table Card -->
<div class="card"> <div class="card">
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"><thead><tr> <table class="data-table">
<thead>
<tr>
<th style="cursor:pointer" onclick="setTraderSort('score')">#</th> <th style="cursor:pointer" onclick="setTraderSort('score')">#</th>
<th style="cursor:pointer" onclick="setTraderSort('name')">Name ↕</th> <th style="cursor:pointer" onclick="setTraderSort('name')">Name ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('platform')">Platform ↕</th> <th style="cursor:pointer" onclick="setTraderSort('platform')">Plattform ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('score')">Combined Score ↕</th> <th style="cursor:pointer" onclick="setTraderSort('score')" class="num-col">Score ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('quality')">Quality ↕</th> <th style="cursor:pointer" onclick="setTraderSort('quality')" class="num-col">Quality ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('copyability')">Copyability ↕</th> <th style="cursor:pointer" onclick="setTraderSort('copyability')" class="num-col">Copyability ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('winrate')">Win Rate ↕</th> <th style="cursor:pointer" onclick="setTraderSort('winrate')" class="num-col">Win Rate ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('pnl')">PnL ↕</th> <th style="cursor:pointer" onclick="setTraderSort('pnl')" class="num-col">PnL ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('trades')">Trades (30d|All) ↕</th> <th>Trades (30d|All) ↕</th>
<th>Strategy</th> <th>Strategie</th>
<th>Actions</th> </tr>
</tr></thead> </thead>
<tbody id="allTradersBody"></tbody> <tbody id="allTradersBody"></tbody>
</table> </table>
</div> </div>
</div> </div>
</section> </section>
<!-- Watchlist Page --> <!-- 3. Watchlist View -->
<section class="page" id="page-watchlist"> <section class="page" id="page-watchlist">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;"> <div class="page-title-wrap">
<h1 class="page-title" style="margin-bottom:0">Watchlist</h1> <div>
<h1 class="page-title">Beobachtungsliste</h1>
<div class="page-subtitle">Deine fokussierten Smart-Money Wallets</div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="table-wrap"> <div class="table-wrap">
@@ -206,13 +300,12 @@
<thead> <thead>
<tr> <tr>
<th>Trader</th> <th>Trader</th>
<th>Platform</th> <th>Plattform</th>
<th>Score</th> <th class="num-col">Score</th>
<th>Win Rate</th> <th class="num-col">Win Rate</th>
<th>PnL</th> <th class="num-col">PnL</th>
<th>Label</th> <th>Label</th>
<th>Added</th> <th>Hinzugefügt</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="watchlistBody"></tbody> <tbody id="watchlistBody"></tbody>
@@ -221,36 +314,71 @@
</div> </div>
</section> </section>
<!-- Markets Page --> <!-- 4. Markets List View -->
<section class="page" id="page-markets"> <section class="page" id="page-markets">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;"> <div class="page-title-wrap">
<div style="display:flex; align-items:center; gap:16px; flex-wrap:wrap;"> <div>
<h1 class="page-title" style="margin-bottom:0">Markets</h1> <h1 class="page-title">Wettmärkte</h1>
<div class="page-subtitle">On-chain Indizes und Volumenaktivitäten</div>
</div>
<div style="display:flex; align-items:center; gap:10px;">
<select id="marketsCategory" class="platform-select" onchange="loadMarkets()"> <select id="marketsCategory" class="platform-select" onchange="loadMarkets()">
<option value="All">All Categories</option> <option value="All">Alle Kategorien</option>
<option value="Politics">Politics</option> <option value="Politics">Politik</option>
<option value="Crypto">Crypto</option> <option value="Crypto">Krypto</option>
<option value="Sports">Sports</option> <option value="Sports">Sport</option>
<option value="PopCulture">PopCulture</option> <option value="PopCulture">Popkultur</option>
<option value="Other">Other</option> <option value="Other">Andere</option>
</select> </select>
<div class="search-box"> <div class="search-box" style="width:240px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="marketsSearchInput" placeholder="Search markets..." autocomplete="off" onkeypress="if(event.key==='Enter') loadMarkets()"> <input type="text" id="marketsSearchInput" placeholder="Suchen..." autocomplete="off" onkeypress="if(event.key==='Enter') loadMarkets()">
</div> </div>
</div> </div>
</div> </div>
<div class="card"><div class="table-wrap"><table class="data-table"><thead><tr><th>Platform</th><th>Question</th><th>Volume</th><th>Liquidity</th><th>End Date</th><th>Status</th></tr></thead><tbody id="allMarketsBody"></tbody></table></div></div>
<div class="card">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Plattform</th>
<th>Wettfrage</th>
<th class="num-col">Volumen</th>
<th class="num-col">Liquidität</th>
<th>Enddatum</th>
<th>Status</th>
</tr>
</thead>
<tbody id="allMarketsBody"></tbody>
</table>
</div>
</div>
</section> </section>
<!-- Alerts Page --> <!-- 5. Alerts View -->
<section class="page" id="page-alerts"><h1 class="page-title">Alerts</h1><div class="card" id="alertsList"></div></section> <section class="page" id="page-alerts">
<div class="page-title-wrap">
<div>
<h1 class="page-title">Sicherheitsmeldungen &amp; Alerts</h1>
<div class="page-subtitle">Auffällige Walletbewegungen und Großorders</div>
</div>
</div>
<div class="card" id="alertsList"></div>
</section>
<!-- Jobs Page --> <!-- 6. Background Jobs View (Control-Mode Guarded) -->
<section class="page" id="page-jobs"> <section class="page" id="page-jobs">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;"> <div class="page-title-wrap">
<h1 class="page-title" style="margin-bottom:0">Background Jobs</h1> <div>
<button class="btn-sm" onclick="queueBacklogAnalysis()" style="padding:6px 16px; background:var(--accent); color:white; border:none; cursor:pointer; border-radius:4px;">Analyze Backlog (50)</button> <h1 class="page-title">System-Hintergrundprozesse</h1>
<div class="page-subtitle">Statusberichte der Daten-Ingestions-Worker</div>
</div>
<div>
<button class="btn-premium" onclick="queueBacklogAnalysis()">
Backlog analysieren (50)
</button>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="table-wrap"> <div class="table-wrap">
@@ -258,42 +386,60 @@
<thead> <thead>
<tr> <tr>
<th>Job ID</th> <th>Job ID</th>
<th>Job Type</th> <th>Typ</th>
<th>Status</th> <th>Status</th>
<th>Trader</th> <th>Trader</th>
<th>Created At</th> <th>Erstellt am</th>
<th>Started At</th> <th>Gestartet</th>
<th>Completed At</th> <th>Abgeschlossen</th>
<th>Error Message</th> <th>Meldung</th>
<th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody id="jobsBody"> <tbody id="jobsBody">
<tr><td colspan="9" style="text-align:center;">Loading jobs...</td></tr> <tr><td colspan="8" style="text-align:center;">Lade Jobs...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</section> </section>
<!-- Search Results Page --> <!-- 7. Search Results View -->
<section class="page" id="page-search"> <section class="page" id="page-search">
<h1 class="page-title" id="search-title">Search Results</h1> <div class="page-title-wrap">
<div>
<h1 class="page-title" id="search-title">Suchergebnisse</h1>
</div>
</div>
<div id="searchResults"> <div id="searchResults">
<div class="card"> <div class="card" style="margin-bottom:20px;">
<div class="card-header"><h2>Traders</h2></div> <div class="card-header"><h2>Gefundene Trader</h2></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead><tr><th>#</th><th>Name</th><th>Platform</th><th>Score</th><th>Tier</th><th>Actions</th></tr></thead> <thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Plattform</th>
<th class="num-col">Score</th>
<th>Kategorie</th>
</tr>
</thead>
<tbody id="searchTradersBody"></tbody> <tbody id="searchTradersBody"></tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"><h2>Markets</h2></div> <div class="card-header"><h2>Gefundene Märkte</h2></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead><tr><th>Platform</th><th>Question</th><th>Volume</th><th>Status</th><th>Actions</th></tr></thead> <thead>
<tr>
<th>Plattform</th>
<th>Wettfrage</th>
<th class="num-col">Volumen</th>
<th>Status</th>
</tr>
</thead>
<tbody id="searchMarketsBody"></tbody> <tbody id="searchMarketsBody"></tbody>
</table> </table>
</div> </div>
@@ -301,125 +447,155 @@
</div> </div>
</section> </section>
<!-- Trader Detail Page --> <!-- 8. Trader Detail View -->
<section class="page" id="page-trader-detail"> <section class="page" id="page-trader-detail">
<!-- Trader Detail Menu Bar --> <div class="detail-actions-bar">
<div class="trader-menubar"> <div class="detail-actions-bar-left">
<div class="trader-menubar-left"> <button class="btn-sm" onclick="navigateBack()">← Zurück</button>
<button class="btn-back" onclick="navigateBack()">← Back</button> <div class="actions-divider"></div>
<div class="menubar-divider"></div> <h1 class="page-title" id="td-name" style="margin-bottom:0;">Trader Profil</h1>
<h1 class="page-title" id="td-name" style="margin-bottom:0;">Trader Name</h1>
</div> </div>
<div class="trader-menubar-right"> <div class="detail-actions-bar-right">
<button class="btn-sm btn-outline" id="btn-toggle-watchlist"> <button class="btn-sm" id="btn-toggle-watchlist">☆ Watchlist</button>
☆ Watchlist <button class="btn-sm" id="btn-open-platform" style="display:none;">🌐 Plattform öffnen</button>
</button>
<button class="btn-sm btn-outline" id="btn-open-platform" style="display:none;"> <!-- Control-Mode Guarded Buttons -->
🌐 Open Platform <span id="control-trader-actions" style="display:inline-flex; gap:10px;">
</button> <button class="btn-sm btn-premium" id="btn-sync-trader">⟱ Sync</button>
<button class="btn-sm btn-primary" id="btn-sync-trader"> <button class="btn-sm" id="btn-deep-resync-trader" style="border-color:var(--danger); color:var(--danger);">Deep Resync</button>
⟱ Sync <button class="btn-sm" id="btn-analyze-trader">⚙ Berechnen</button>
</button> </span>
<button class="btn-sm btn-outline" id="btn-deep-resync-trader" style="border-color:var(--pnl-negative); color:var(--pnl-negative);">
⟱ Deep Resync
</button>
<button class="btn-sm btn-primary" id="btn-analyze-trader">
⚙ Analyze
</button>
</div> </div>
</div> </div>
<div class="detail-grid"> <div class="detail-grid">
<!-- Left Sidebar -->
<div class="detail-sidebar"> <div class="detail-sidebar">
<div class="card"> <div class="profile-header">
<div class="profile-avatar" id="td-avatar" style="background:var(--primary)">P</div>
<div class="profile-name" id="td-displayName"></div>
<div class="profile-sub" id="td-platformId"></div>
</div>
<div class="stat-group"> <div class="stat-group">
<div class="stat-label">Platform</div> <div class="stat-label">Plattform</div>
<div class="stat-value" id="td-platform"></div> <div class="stat-value" id="td-platform"></div>
</div> </div>
<div class="stat-group"> <div class="stat-group">
<div class="stat-label">Platform ID</div> <div class="stat-label">Einstufung (Tier)</div>
<div class="stat-value small" id="td-platformId"></div>
</div>
<div class="stat-group">
<div class="stat-label">Tier</div>
<div class="stat-value" id="td-tier"></div> <div class="stat-value" id="td-tier"></div>
</div> </div>
<div class="stat-group"> <div class="stat-group">
<div class="stat-label">Strategy</div> <div class="stat-label">Handelsstil</div>
<div class="stat-value" id="td-strategy"></div> <div class="stat-value" id="td-strategy"></div>
</div> </div>
<div class="stat-group" id="td-traits-container" style="display:none; margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px;"> <div class="stat-group" id="td-traits-container" style="display:none;">
<div class="stat-label">Algorithmic Traits</div> <div class="stat-label">Identifizierte Verhaltensmerkmale</div>
<div class="stat-value small" id="td-traits" style="display:flex; flex-wrap:wrap; gap:4px; margin-top:6px;"></div> <div id="td-traits" style="display:flex; flex-wrap:wrap; gap:6px; margin-top:8px;"></div>
</div>
</div> </div>
</div> </div>
<!-- Right Main Area -->
<div class="detail-main"> <div class="detail-main">
<!-- Tabs Navigation -->
<div class="tabs-nav"> <div class="tabs-nav">
<button class="btn-tab active" data-tab="td-tab-analytics" onclick="switchTraderTab('td-tab-analytics')">Analytics & AI</button> <button class="btn-tab active" data-tab="td-tab-analytics" onclick="switchTraderTab('td-tab-analytics')">Analyse &amp; KI</button>
<button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')">Recent Trades</button> <button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')">Handelshistorie</button>
<button class="btn-tab" data-tab="td-tab-positions" onclick="switchTraderTab('td-tab-positions')">Positions</button> <button class="btn-tab" data-tab="td-tab-positions" onclick="switchTraderTab('td-tab-positions')">Offene Positionen</button>
</div> </div>
<!-- Tab Content: Analytics --> <!-- Content: Analytics -->
<div class="tab-content" id="td-tab-analytics" style="display:block;"> <div class="tab-content" id="td-tab-analytics" style="display:block;">
<div class="metrics-grid"> <div class="metrics-grid" style="grid-template-columns: repeat(3, 1fr);">
<div class="metric-card"><div class="metric-label">Win Rate</div><div class="metric-value" id="td-winrate">---</div></div> <div class="metric-card"><div class="metric-label">Win Rate (All-Time)</div><div class="metric-value" id="td-winrate"></div></div>
<div class="metric-card"><div class="metric-label">Win Rate (30d)</div><div class="metric-value" id="td-winrate30d">---</div></div> <div class="metric-card"><div class="metric-label">Win Rate (30d)</div><div class="metric-value" id="td-winrate30d"></div></div>
<div class="metric-card"><div class="metric-label">Total PnL</div><div class="metric-value" id="td-pnl">---</div></div> <div class="metric-card"><div class="metric-label">Gesamt-P&amp;L</div><div class="metric-value" id="td-pnl"></div></div>
<div class="metric-card"><div class="metric-label">PnL (30d)</div><div class="metric-value" id="td-pnl30d">---</div></div> <div class="metric-card"><div class="metric-label">P&amp;L (30d)</div><div class="metric-value" id="td-pnl30d"></div></div>
<div class="metric-card"><div class="metric-label">Total Trades</div><div class="metric-value" id="td-trades">---</div></div> <div class="metric-card"><div class="metric-label">Handelsanzahl</div><div class="metric-value" id="td-trades"></div></div>
<div class="metric-card"><div class="metric-label">Est. Bankroll</div><div class="metric-value" id="td-bankroll">---</div></div> <div class="metric-card"><div class="metric-label">Est. Bankroll</div><div class="metric-value" id="td-bankroll"></div></div>
<div class="metric-card"><div class="metric-label">Quality Edge</div><div class="metric-value" id="td-quality-score">---</div></div> <div class="metric-card"><div class="metric-label">Quality Edge</div><div class="metric-value" id="td-quality-score"></div></div>
<div class="metric-card"><div class="metric-label">Copyability</div><div class="metric-value" id="td-copyability-score">---</div></div> <div class="metric-card"><div class="metric-label">Copyability</div><div class="metric-value" id="td-copyability-score"></div></div>
<div class="metric-card"><div class="metric-label">Med. Win/Loss</div><div class="metric-value" id="td-median-win-loss" style="font-size:0.9em;">---</div></div> <div class="metric-card"><div class="metric-label">Med. Win/Loss</div><div class="metric-value" id="td-median-win-loss" style="font-size:15px; font-weight:700;"></div></div>
<div class="metric-card"><div class="metric-label">Profit Factor</div><div class="metric-value" id="td-profit-factor">---</div></div> <div class="metric-card"><div class="metric-label">Profit Faktor</div><div class="metric-value" id="td-profit-factor"></div></div>
<div class="metric-card"><div class="metric-label">Expectancy / Trade</div><div class="metric-value" id="td-expectancy">---</div></div> <div class="metric-card"><div class="metric-label">Erwartungswert / Trade</div><div class="metric-value" id="td-expectancy"></div></div>
<div class="metric-card accent"> <div class="metric-card accent">
<div class="metric-value" id="td-score">---</div> <div class="metric-label" style="color:rgba(255,255,255,0.7);">Kombinierter Score</div>
<div class="metric-value" id="td-score"></div>
</div> </div>
</div> </div>
<div class="card" style="margin-bottom: 24px;"> <!-- AI Summary Card (Control-Mode Guarded Button) -->
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;"> <div class="card">
<h2>AI Strategy Analysis</h2> <div class="card-header">
<button class="btn-sm" id="btn-ai-analysis" style="padding:6px 12px; background:var(--primary); color:#000;">Run Deep Analysis</button> <h2>Künstliche Intelligenz Analyse</h2>
<button class="btn-sm" id="btn-ai-analysis" style="background:var(--primary); color:white; border:none;">Deep AI-Audit</button>
</div>
<div id="td-ai-summary" style="margin: 16px; font-size:13px; line-height:1.6; white-space:pre-wrap; background:rgba(255,255,255,0.02); padding:16px; border-radius:12px; border:1px solid var(--border);">
Führe ein KI-Audit aus, um detaillierte Strategieprofile dieses Händlers zu generieren.
</div> </div>
<div class="stat-value small" id="td-ai-summary" style="margin-top: 16px; font-weight:normal; line-height:1.6; white-space:pre-wrap; background:var(--bg-input); padding:16px; border-radius:8px; border:1px solid var(--border);">Click 'Run Deep Analysis' to generate a detailed summary of this trader's behavior.</div>
</div> </div>
<div class="card" style="margin-bottom: 24px;"> <!-- Category Performance -->
<div class="card-header"><h2>Category Specialization</h2></div> <div class="card">
<div class="card-header"><h2>Erfolge nach Kategorien</h2></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead><tr><th>Category</th><th>Subcategory</th><th>Win Rate</th><th>PnL</th><th>Volume</th><th>Trades</th></tr></thead> <thead>
<tr>
<th>Hauptkategorie</th>
<th>Unterkategorie</th>
<th class="num-col">Win Rate</th>
<th class="num-col">PnL</th>
<th class="num-col">Volumen</th>
<th>Trades</th>
</tr>
</thead>
<tbody id="td-categoryBody"></tbody> <tbody id="td-categoryBody"></tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<!-- Tab Content: Recent Trades --> <!-- Content: Recent Trades -->
<div class="tab-content" id="td-tab-recent" style="display:none;"> <div class="tab-content" id="td-tab-recent" style="display:none;">
<div class="card"> <div class="card">
<div class="card-header"><h2>Recent Trades</h2></div> <div class="card-header"><h2>Handelshistorie</h2></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead><tr><th>Time</th><th>Market</th><th>Side</th><th>Price</th><th>Size</th><th>Amount</th></tr></thead> <thead>
<tr>
<th>Zeit</th>
<th>Wettmarkt</th>
<th>Seite</th>
<th class="num-col">Ausführungspreis</th>
<th class="num-col">Menge</th>
<th class="num-col">Gesamtwert</th>
</tr>
</thead>
<tbody id="td-tradesBody"></tbody> <tbody id="td-tradesBody"></tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<!-- Tab Content: Positions --> <!-- Content: Positions -->
<div class="tab-content" id="td-tab-positions" style="display:none;"> <div class="tab-content" id="td-tab-positions" style="display:none;">
<div class="card"> <div class="card">
<div class="card-header"><h2>Active Positions</h2></div> <div class="card-header"><h2>Aktive Positionen</h2></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead><tr><th>Market / Outcome</th><th>Shares</th><th>Avg Price</th><th>Current Price</th><th>Realized PnL</th><th>Unrealized PnL</th></tr></thead> <thead>
<tbody id="td-positionsBody"><tr><td colspan="6" style="text-align:center;">Loading...</td></tr></tbody> <tr>
<th>Markt / Wette</th>
<th class="num-col">Anteile</th>
<th class="num-col">Kaufpreis Ø</th>
<th class="num-col">Marktpreis</th>
<th class="num-col">Realisierte P&amp;L</th>
<th class="num-col">Unrealisierte P&amp;L</th>
</tr>
</thead>
<tbody id="td-positionsBody">
<tr><td colspan="6" style="text-align:center;">Lade Positionen...</td></tr>
</tbody>
</table> </table>
</div> </div>
</div> </div>
@@ -428,52 +604,66 @@
</div> </div>
</section> </section>
<!-- Market Detail Page --> <!-- 9. Market Detail View -->
<section class="page" id="page-market-detail"> <section class="page" id="page-market-detail">
<div class="detail-header"> <div class="detail-actions-bar">
<button class="btn-back" onclick="navigateBack()">← Back</button> <div class="detail-actions-bar-left">
<h1 class="page-title" id="md-question">Market Question</h1> <button class="btn-sm" onclick="navigateBack()">← Zurück</button>
<div class="actions-divider"></div>
<h1 class="page-title" id="md-question" style="margin-bottom:0;">Wettfrage</h1>
</div> </div>
</div>
<div class="detail-grid"> <div class="detail-grid">
<div class="card detail-sidebar"> <!-- Sidebar Stats -->
<div class="detail-sidebar">
<div id="md-image" class="market-img-container"></div> <div id="md-image" class="market-img-container"></div>
<div class="stat-group"> <div class="stat-group">
<div class="stat-label">Platform</div> <div class="stat-label">Plattform</div>
<div class="stat-value" id="md-platform"></div> <div class="stat-value" id="md-platform"></div>
</div> </div>
<ul class="nav-links"> <div class="stat-group">
<li><a href="#" onclick="showPage('page-dashboard')" class="active" id="nav-dashboard">Dashboard</a></li> <div class="stat-label">Kategorie</div>
<li><a href="#" onclick="showPage('page-traders')" id="nav-traders">Traders</a></li>
<li><a href="#" onclick="showPage('page-markets')" id="nav-markets">Markets</a></li>
<li><a href="#" onclick="showPage('page-alerts')" id="nav-alerts">Alerts</a></li>
<li><a href="#" onclick="showPage('page-jobs')" id="nav-jobs">Jobs</a></li>
</ul> <div class="stat-group">
<div class="stat-label">Category</div>
<div class="stat-value" id="md-category"></div> <div class="stat-value" id="md-category"></div>
</div> </div>
<div class="stat-group"> <div class="stat-group">
<div class="stat-label">Ends</div> <div class="stat-label">Ablaufdatum</div>
<div class="stat-value" id="md-ends"></div> <div class="stat-value" id="md-ends"></div>
</div> </div>
</div> </div>
<!-- Main stats and elements -->
<div class="detail-main"> <div class="detail-main">
<div class="metrics-grid"> <div class="metrics-grid" style="grid-template-columns: repeat(3, 1fr);">
<div class="metric-card"><div class="metric-label">Volume</div><div class="metric-value" id="md-volume"></div></div> <div class="metric-card"><div class="metric-label">Volumen</div><div class="metric-value" id="md-volume"></div></div>
<div class="metric-card"><div class="metric-label">Liquidity</div><div class="metric-value" id="md-liquidity"></div></div> <div class="metric-card"><div class="metric-label">Liquidität</div><div class="metric-value" id="md-liquidity"></div></div>
<div class="metric-card"><div class="metric-label">Status</div><div class="metric-value" id="md-status"></div></div> <div class="metric-card"><div class="metric-label">Status</div><div class="metric-value" id="md-status"></div></div>
<div class="metric-card accent"><div class="metric-label">Bot Activity</div><div class="metric-value" id="md-bot-activity"></div></div> <div class="metric-card accent"><div class="metric-label">Bot-Aktivität Score</div><div class="metric-value" id="md-bot-activity"></div></div>
<div class="metric-card"><div class="metric-label">Unique Traders</div><div class="metric-value" id="md-unique-traders"></div></div> <div class="metric-card"><div class="metric-label">Eindeutige Trader</div><div class="metric-value" id="md-unique-traders"></div></div>
<div class="metric-card"><div class="metric-label">Avg Trade Size</div><div class="metric-value" id="md-avg-trade-size"></div></div> <div class="metric-card"><div class="metric-label">Durchschnittsgröße</div><div class="metric-value" id="md-avg-trade-size"></div></div>
</div> </div>
<!-- Outcomes -->
<div class="card"> <div class="card">
<div class="card-header"><h2>Outcomes</h2></div> <div class="card-header"><h2>Wahrscheinlichkeiten &amp; Kurse</h2></div>
<div class="outcomes-list" id="md-outcomes"></div> <div class="outcomes-list" id="md-outcomes"></div>
</div> </div>
<!-- Market Trades -->
<div class="card"> <div class="card">
<div class="card-header"><h2>Recent Trades</h2></div> <div class="card-header"><h2>Letzte Trades in diesem Markt</h2></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead><tr><th>Time</th><th>Trader</th><th>Side</th><th>Price</th><th>Size</th><th>Amount</th></tr></thead> <thead>
<tr>
<th>Zeit</th>
<th>Händler</th>
<th>Seite</th>
<th class="num-col">Preis</th>
<th class="num-col">Menge</th>
<th class="num-col">Gesamtwert</th>
</tr>
</thead>
<tbody id="md-tradesBody"></tbody> <tbody id="md-tradesBody"></tbody>
</table> </table>
</div> </div>
@@ -481,8 +671,12 @@
</div> </div>
</div> </div>
</section> </section>
</div> </div>
</main> </main>
<script src="js/app.js?v=20260710"></script>
</div>
<script src="js/app.js?v=20260715"></script>
</body> </body>
</html> </html>
+190 -48
View File
@@ -168,21 +168,125 @@ const fmt = {
let platformChart, tierChart; let platformChart, tierChart;
function getChartColors() { function getChartColors() {
const isDark = html.getAttribute('data-theme') === 'dark';
return { return {
text: isDark ? '#AAAAAA' : '#666666', text: '#8B93A7',
grid: isDark ? '#2A2C33' : '#E8E9EC', grid: 'rgba(255,255,255,0.07)',
bg: isDark ? '#16181D' : '#FFFFFF' bg: 'rgba(255,255,255,0.045)'
}; };
} }
function updateChartColors() { function getAvatarBg(id) {
const c = getChartColors(); const gradients = [
[platformChart, tierChart].forEach(chart => { 'linear-gradient(135deg,#1652F0,#4c8dff)',
if (!chart) return; 'linear-gradient(135deg,#12D48A,#0a8f5f)',
if (chart.options.plugins?.legend) chart.options.plugins.legend.labels.color = c.text; 'linear-gradient(135deg,#7c5cff,#4c8dff)',
chart.update(); 'linear-gradient(135deg,#ff9d4c,#ff6b8a)',
}); 'linear-gradient(135deg,#F6465D,#ff6b8a)'
];
return gradients[id % gradients.length];
}
function getTraitClass(trait) {
if (!trait) return 'tier-unknown';
const lower = trait.toLowerCase();
if (lower.includes('winrate') || lower.includes('wins') || lower.includes('farming') || lower.includes('insider')) return 'trait-winrate';
if (lower.includes('volume') || lower.includes('amounts') || lower.includes('sizes') || lower.includes('stake')) return 'trait-volume';
if (lower.includes('whale') || lower.includes('scalper') || lower.includes('martingale')) return 'trait-whales';
if (lower.includes('sentiment') || lower.includes('cadence') || lower.includes('24_7') || lower.includes('copyable') || lower.includes('wallet')) return 'trait-sentiment';
return 'tier-unknown';
}
const TraitMetadata = {
"sub_second_cadence": {
name: "Sub-Second Cadence",
desc: "Executes trades in sub-second intervals, highly indicative of algorithmic execution."
},
"always_on_24_7": {
name: "24/7 Activity",
desc: "Trades at all hours of the day and night with very short gaps, indicating bot operation."
},
"uniform_sizes": {
name: "Uniform Sizes",
desc: "Executes trades with identical position sizes, suggesting a systematic layout."
},
"round_amounts": {
name: "Round Amounts",
desc: "Executes trades with round amounts (e.g. 100, 500, 1000 USD), common for manual traders."
},
"uses_split_merge": {
name: "Split & Merge",
desc: "Splits large positions into smaller orders or merges multiple positions to optimize slippage."
},
"both_sides_same_market": {
name: "Two-Sided Market Maker",
desc: "Buys and sells in the same market, extracting spreads or resolving inventory."
},
"resolution_farming": {
name: "Resolution Farmer",
desc: "Buys options at high probabilities (e.g., >93%) to collect predictable small returns."
},
"longshot_buyer": {
name: "Longshot Buyer",
desc: "Buys options at low probabilities (e.g., <10%), seeking rare high-payoff events."
},
"scalper": {
name: "Scalper",
desc: "Holds positions for very short periods (typically <1 hour) to lock in quick profits."
},
"holds_to_resolution": {
name: "Holds to Resolution",
desc: "Keeps positions open until the market officially resolves, avoiding early exits."
},
"fresh_wallet": {
name: "Fresh Wallet",
desc: "Wallet was created recently (less than 30 days of active history)."
},
"stable_stake_fraction": {
name: "Stable Stake",
desc: "Risk per trade is a highly stable fraction of estimated total bankroll."
},
"possible_insider": {
name: "Possible Insider",
desc: "Consistently wins low-probability bets with very low volume, indicating asymmetric information."
},
"thin_margin_wins": {
name: "Thin Margin Winner",
desc: "Consistently resolves trades with small margins of win (low ROI)."
},
"high_payoff_wins": {
name: "High Payoff Winner",
desc: "Consistently resolves trades with high margins of win (high ROI)."
},
"sells_at_loss": {
name: "Sells at Loss",
desc: "Uses strict stop-loss rules, selling options at a loss when the market goes against them."
},
"days_active": {
name: "Days Active",
desc: "The number of days this wallet has been active on-chain."
},
"trades_last_30_days": {
name: "Trades Last 30d",
desc: "The total number of trades executed in the last 30 days."
},
"martingale_pattern": {
name: "Martingale Trader",
desc: "Increases bet size after losses to attempt to recoup losses, typical of high-risk strategies."
},
"not_copyable_hf": {
name: "Uncopyable (High Freq)",
desc: "Trades at frequencies too high to replicate manually or with standard copytrading setups."
}
};
function getTraitDisplayName(traitKey) {
const meta = TraitMetadata[traitKey];
return meta ? meta.name : traitKey.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function getTraitDescription(traitKey) {
const meta = TraitMetadata[traitKey];
return meta ? meta.desc : 'No description available.';
} }
// ─── Dashboard Load ─── // ─── Dashboard Load ───
@@ -224,19 +328,20 @@ async function loadDashboard() {
// Recent Trades table // Recent Trades table
const rBody = document.getElementById('recentTradesBody'); const rBody = document.getElementById('recentTradesBody');
rBody.innerHTML = data.recentTrades.map(t => ` rBody.innerHTML = data.recentTrades.map(t => `
<tr> <tr onclick="viewTrader(${t.traderId})">
<td>${fmt.time(t.executedAt)}</td> <td>${fmt.time(t.executedAt)}</td>
<td onclick="viewTrader(${t.traderId})" style="cursor:pointer; color:var(--primary)">${t.traderName}</td> <td><strong>${t.traderName}</strong></td>
<td onclick="${t.dbMarketId ? `viewMarket(${t.dbMarketId})` : `''`}" style="cursor:${t.dbMarketId ? 'pointer' : 'default'}" title="Market ID: ${t.marketId}">
${t.marketName || (t.marketId.length > 20 ? t.marketId.substring(0,20)+'...' : t.marketId)}
</td>
<td>${fmt.side(t.side)}</td> <td>${fmt.side(t.side)}</td>
<td>${Number(t.price).toFixed(2)}</td> <td class="num-col">${Number(t.price).toFixed(2)}</td>
<td>${fmt.num(t.size)}</td> <td class="num-col">${fmt.num(t.size)}</td>
<td>${fmt.usd(t.amount)}</td>
</tr> </tr>
`).join(''); `).join('');
const headerVol = document.getElementById('headerVolumeBadge');
if (headerVol) {
headerVol.textContent = `24H VOL: ${fmt.usd(data.volume24h)}`;
}
// Platform Chart // Platform Chart
const cc = getChartColors(); const cc = getChartColors();
const pLabels = Object.keys(data.platformBreakdown.traderCounts); const pLabels = Object.keys(data.platformBreakdown.traderCounts);
@@ -245,25 +350,25 @@ async function loadDashboard() {
if (platformChart) platformChart.destroy(); if (platformChart) platformChart.destroy();
platformChart = new Chart(document.getElementById('platformChart'), { platformChart = new Chart(document.getElementById('platformChart'), {
type: 'doughnut', type: 'doughnut',
data: { labels: pLabels.length ? pLabels : ['No Data'], datasets: [{ data: pData.length ? pData : [1], data: { labels: pLabels.length ? pLabels : ['Keine Daten'], datasets: [{ data: pData.length ? pData : [1],
backgroundColor: ['#FF2D55', '#5AC8FA', '#FF9500', '#34C759', '#AF52DE', '#FF6B8A', '#30D158'], backgroundColor: ['#1652F0', '#12D48A', '#7c5cff', '#ff9d4c', '#F6465D', '#5b9dff'],
borderWidth: 0 }] }, borderWidth: 0 }] },
options: { responsive: true, maintainAspectRatio: false, cutout: '70%', options: { responsive: true, maintainAspectRatio: false, cutout: '70%',
plugins: { legend: { position: 'bottom', labels: { color: cc.text, padding: 16, font: { family: "'Inter'", size: 12 } } } } } plugins: { legend: { position: 'bottom', labels: { color: cc.text, padding: 16, font: { family: "'Manrope'", size: 12 } } } } }
}); });
// Tier chart // Tier chart
const tierData = data.topTraders.reduce((acc, t) => { acc[t.tier] = (acc[t.tier] || 0) + 1; return acc; }, {}); const tierData = data.topTraders.reduce((acc, t) => { acc[t.tier] = (acc[t.tier] || 0) + 1; return acc; }, {});
if (tierChart) tierChart.destroy(); if (tierChart) tierChart.destroy();
const tLabels = Object.keys(tierData).length ? Object.keys(tierData) : ['No Data']; const tLabels = Object.keys(tierData).length ? Object.keys(tierData) : ['Keine Daten'];
const tData = Object.values(tierData).length ? Object.values(tierData) : [1]; const tData = Object.values(tierData).length ? Object.values(tierData) : [1];
tierChart = new Chart(document.getElementById('tierChart'), { tierChart = new Chart(document.getElementById('tierChart'), {
type: 'bar', type: 'bar',
data: { labels: tLabels, datasets: [{ label: 'Traders', data: tData, data: { labels: tLabels, datasets: [{ label: 'Trader', data: tData,
backgroundColor: '#FF2D55', borderRadius: 6, barThickness: 32 }] }, backgroundColor: '#1652F0', borderRadius: 6, barThickness: 32 }] },
options: { responsive: true, maintainAspectRatio: false, options: { responsive: true, maintainAspectRatio: false,
scales: { x: { grid: { display: false }, ticks: { color: cc.text, font: { family: "'Inter'" } } }, scales: { x: { grid: { display: false }, ticks: { color: cc.text, font: { family: "'Manrope'" } } },
y: { grid: { color: cc.grid }, ticks: { color: cc.text, font: { family: "'Inter'" } } } }, y: { grid: { color: cc.grid }, ticks: { color: cc.text, font: { family: "'Manrope'" } } } },
plugins: { legend: { display: false } } } plugins: { legend: { display: false } } }
}); });
} }
@@ -328,26 +433,24 @@ async function loadTraders() {
}); });
tbody.innerHTML = data.map((t, i) => ` tbody.innerHTML = data.map((t, i) => `
<tr> <tr onclick="viewTrader(${t.id})">
<td>${i + 1}</td> <td>${i + 1}</td>
<td><strong><a href="#" onclick="viewTrader(${t.id}); return false;" style="color:var(--primary);text-decoration:none;">${t.displayName}</a></strong>${t.isSuspectedBot ? ' 🤖' : ''}</td> <td><strong>${t.displayName}</strong>${t.isSuspectedBot ? ' 🤖' : ''}</td>
<td>${t.platform}</td> <td>${t.platform}</td>
<td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td> <td class="num-col"><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
<td>${Number(t.copytradingQualityScore || 0).toFixed(1)}</td> <td class="num-col">${Number(t.copytradingQualityScore || 0).toFixed(1)}</td>
<td>${Number(t.copytradingCopyabilityScore || 0).toFixed(1)}</td> <td class="num-col">${Number(t.copytradingCopyabilityScore || 0).toFixed(1)}</td>
<td>${fmt.pct(t.winRate)}</td> <td class="num-col">${fmt.pct(t.winRate)}</td>
<td>${fmt.pnl(t.totalPnl)}</td> <td class="num-col">${fmt.pnl(t.totalPnl)}</td>
<td>${t.trades30d} | ${t.totalTrades}</td> <td>${t.trades30d} | ${t.totalTrades}</td>
<td> <td>
${t.strategy} ${t.strategy || '—'}
${t.traits ? '<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">' + t.traits.map(tr => `<span style="font-size:10px; padding:2px 6px; background:var(--bg-input); border-radius:10px;">${tr}</span>`).join('') + '</div>' : ''} ${t.traits ? '<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">' + t.traits.map(tr => {
</td> const rawKey = tr.trait || tr;
<td> const name = getTraitDisplayName(rawKey);
<div style="display:flex; gap:4px;"> const desc = getTraitDescription(rawKey);
<button class="btn-sm" onclick="viewTrader(${t.id})">Details</button> return `<span class="tier-badge ${getTraitClass(rawKey)}" title="${desc}">${name}</span>`;
<button class="btn-sm" onclick="queueHistorySync(${t.id})" style="background:var(--bg-input); border:1px solid var(--border); color:var(--text)">Sync</button> }).join('') + '</div>' : ''}
<button class="btn-sm" onclick="queueTraderAnalysis(${t.id})" style="background:var(--bg-input); border:1px solid var(--border); color:var(--text)">Analyze</button>
</div>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -454,17 +557,30 @@ async function viewTrader(id) {
// Reset tabs to default (Analytics) // Reset tabs to default (Analytics)
switchTraderTab('td-tab-analytics'); switchTraderTab('td-tab-analytics');
document.getElementById('td-name').textContent = t.displayName; document.getElementById('td-name').textContent = `Trader-Profil: ${t.displayName}`;
document.getElementById('td-displayName').textContent = t.displayName;
document.getElementById('td-platform').textContent = t.platform; document.getElementById('td-platform').textContent = t.platform;
document.getElementById('td-platformId').textContent = t.platformUserId; document.getElementById('td-platformId').textContent = t.platformUserId;
document.getElementById('td-tier').innerHTML = fmt.tier(t.tier); document.getElementById('td-tier').innerHTML = fmt.tier(t.tier);
document.getElementById('td-strategy').textContent = t.strategy; document.getElementById('td-strategy').textContent = t.strategy;
const avatarEl = document.getElementById('td-avatar');
if (avatarEl) {
avatarEl.textContent = t.displayName ? t.displayName[0].toUpperCase() : 'P';
avatarEl.style.background = getAvatarBg(id);
}
const traitsContainer = document.getElementById('td-traits-container'); const traitsContainer = document.getElementById('td-traits-container');
const traitsEl = document.getElementById('td-traits'); const traitsEl = document.getElementById('td-traits');
if (t.traits && t.traits.length > 0) { if (t.traits && t.traits.length > 0) {
traitsContainer.style.display = 'block'; traitsContainer.style.display = 'block';
traitsEl.innerHTML = t.traits.map(tr => `<span title="Value: ${Number(tr.value).toFixed(4)}" style="font-size:11px; padding:2px 8px; background:var(--bg-input); border-radius:12px; border:1px solid var(--border);">${tr.trait}</span>`).join(''); traitsEl.innerHTML = t.traits.map(tr => {
const rawKey = tr.trait || tr;
const name = getTraitDisplayName(rawKey);
const desc = getTraitDescription(rawKey);
const scoreVal = tr.value !== undefined ? ` (Value: ${Number(tr.value).toFixed(4)})` : '';
return `<span class="tier-badge ${getTraitClass(rawKey)}" title="${desc}${scoreVal}">${name}</span>`;
}).join('');
} else { } else {
traitsContainer.style.display = 'none'; traitsContainer.style.display = 'none';
traitsEl.innerHTML = ''; traitsEl.innerHTML = '';
@@ -487,7 +603,7 @@ async function viewTrader(id) {
document.getElementById('td-median-win-loss').innerHTML = `<span class="side-buy">+${Number(medianWin).toFixed(1)}%</span> / <span class="side-sell">${Number(medianLoss).toFixed(1)}%</span>`; document.getElementById('td-median-win-loss').innerHTML = `<span class="side-buy">+${Number(medianWin).toFixed(1)}%</span> / <span class="side-sell">${Number(medianLoss).toFixed(1)}%</span>`;
document.getElementById('td-profit-factor').textContent = t.profitFactor ? Number(t.profitFactor).toFixed(2) : '—'; document.getElementById('td-profit-factor').textContent = t.profitFactor ? Number(t.profitFactor).toFixed(2) : '—';
document.getElementById('td-expectancy').innerHTML = expectancy > 0 ? `<span class="side-buy">+${expectancy.toFixed(1)}%</span>` : `<span class="side-sell">${expectancy.toFixed(1)}%</span>`; document.getElementById('td-expectancy').innerHTML = expectancy > 0 ? `<span class="side-buy">+${expectancy.toFixed(1)}%</span>` : `<span class="side-sell">${expectancy.toFixed(1)}%</span>`;
document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Not analyzed yet.'; document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Bisher keine KI-Strategieanalyse durchgeführt.';
const syncBtn = document.getElementById('btn-sync-trader'); const syncBtn = document.getElementById('btn-sync-trader');
if (syncBtn) { if (syncBtn) {
@@ -497,7 +613,7 @@ async function viewTrader(id) {
const deepSyncBtn = document.getElementById('btn-deep-resync-trader'); const deepSyncBtn = document.getElementById('btn-deep-resync-trader');
if (deepSyncBtn) { if (deepSyncBtn) {
deepSyncBtn.onclick = () => { deepSyncBtn.onclick = () => {
if (confirm("Are you sure? This will delete all compacted trades and reset positions, then fetch all historical trades via pagination.")) { if (confirm("Bist du sicher? Dies löscht alle komprimierten Trades, setzt die Positionen zurück und lädt den gesamten Verlauf neu.")) {
queueDeepResync(id); queueDeepResync(id);
} }
}; };
@@ -509,7 +625,12 @@ async function viewTrader(id) {
} }
const aiBtn = document.getElementById('btn-ai-analysis'); const aiBtn = document.getElementById('btn-ai-analysis');
if (window.capabilities && !window.capabilities.canControl) {
aiBtn.style.display = 'none';
} else {
aiBtn.style.display = 'inline-block';
aiBtn.onclick = () => triggerAiAnalysis(id, true); aiBtn.onclick = () => triggerAiAnalysis(id, true);
}
const wlBtn = document.getElementById('btn-toggle-watchlist'); const wlBtn = document.getElementById('btn-toggle-watchlist');
wlBtn.textContent = t.isOnWatchlist ? 'Watchlist (Remove)' : 'Watchlist (Add)'; wlBtn.textContent = t.isOnWatchlist ? 'Watchlist (Remove)' : 'Watchlist (Add)';
@@ -750,6 +871,27 @@ async function queueBacklogAnalysis() {
// Initialize Dashboard // Initialize Dashboard
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
// Load capabilities first
try {
const caps = await api('/api/capabilities');
if (caps) {
window.capabilities = caps;
if (!caps.canControl) {
// Hide control-mode guarded elements
const jobsNav = document.getElementById('nav-jobs-container');
if (jobsNav) jobsNav.style.display = 'none';
const addTraderPanel = document.getElementById('control-add-trader-panel');
if (addTraderPanel) addTraderPanel.style.display = 'none';
const traderActions = document.getElementById('control-trader-actions');
if (traderActions) traderActions.style.display = 'none';
}
}
} catch (e) {
console.error('Failed to load capabilities', e);
}
// Load traits for filter // Load traits for filter
try { try {
const traits = await api('/api/traders/traits'); const traits = await api('/api/traders/traits');
@@ -759,7 +901,7 @@ document.addEventListener('DOMContentLoaded', async () => {
traits.forEach(t => { traits.forEach(t => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = t; opt.value = t;
opt.textContent = t; opt.textContent = getTraitDisplayName(t);
filterTrait.appendChild(opt); filterTrait.appendChild(opt);
}); });
} }
+302
View File
@@ -0,0 +1,302 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Predictalytics — See every trader and market before the crowd does</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; }
* { box-sizing: border-box; }
a { color: #5b9dff; text-decoration: none; transition: all 0.2s; }
a:hover { color: #8ab8ff; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fadeIn 0.4s ease-out; }
</style>
</head>
<body>
<div style="position:relative; min-height:100vh; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.28), transparent 60%), radial-gradient(900px 600px at 110% 10%, rgba(18,212,138,0.10), transparent 55%), #0a0d14; color:#EDEFF5; overflow-x:hidden;">
<!-- NAV -->
<header style="position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:space-between; padding:16px 48px; backdrop-filter:blur(20px); background:rgba(10,13,20,0.6); border-bottom:1px solid rgba(255,255,255,0.07);">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em;">Predictalytics<span style="color:#5b9dff;">.</span></div>
</div>
<nav style="display:flex; align-items:center; gap:32px;">
<a href="#features" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Features</a>
<a href="#pricing" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Preise</a>
<a href="#faq" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">FAQ</a>
<a href="./docs.html" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">API Docs</a>
</nav>
<div style="display:flex; align-items:center; gap:10px;">
<div onclick="openAuth('login')" style="cursor:pointer; padding:9px 18px; border-radius:10px; font-size:13.5px; font-weight:700; color:#EDEFF5;">Login</div>
<div onclick="openAuth('register')" style="cursor:pointer; padding:9px 20px; border-radius:10px; font-size:13.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 4px 20px rgba(22,82,240,0.4);">Registrieren</div>
</div>
</header>
<!-- HERO -->
<section style="padding:100px 48px 80px; max-width:1080px; margin:0 auto; text-align:center; display:flex; flex-direction:column; align-items:center; gap:22px;">
<div style="padding:6px 14px; border-radius:20px; background:rgba(22,82,240,0.12); border:1px solid rgba(22,82,240,0.3); font-size:12.5px; font-weight:700; color:#5b9dff;">Echtzeit-Onchain-Analysen · Polymarket &amp; Co.</div>
<h1 style="margin:0; font-size:52px; font-weight:800; letter-spacing:-0.03em; line-height:1.08; max-width:760px;">Erkenne jeden Trader und Markt vor der Masse</h1>
<p style="margin:0; font-size:17px; color:#8B93A7; max-width:600px; line-height:1.6;">Detaillierte Trader-Profilierung, Wallet-Clustering und Echtzeit-Marktanalysen für professionelle Akteure in Prognosemärkten.</p>
<div style="display:flex; gap:14px; margin-top:8px;">
<a href="./index.html" style="cursor:pointer; padding:14px 26px; border-radius:12px; font-size:14.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 30px rgba(22,82,240,0.4); color:white;">Dashboard starten</a>
<a href="#features" style="padding:14px 26px; border-radius:12px; font-size:14.5px; font-weight:700; color:#EDEFF5; border:1px solid rgba(255,255,255,0.14); background:rgba(255,255,255,0.04); white-space:nowrap;">Features ansehen</a>
</div>
<!-- Product Preview Mock -->
<div style="margin-top:40px; width:100%; border-radius:20px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px); box-shadow:0 20px 60px rgba(0,0,0,0.4); padding:20px; display:grid; grid-template-columns:1.4fr 1fr; gap:14px; text-align:left;">
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:18px;">
<div style="font-size:12px; font-weight:700; color:#8B93A7; margin-bottom:10px;">Kursverlauf YES-Token</div>
<svg viewBox="0 0 500 140" style="width:100%; height:140px;" id="heroChartSvg"></svg>
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:18px; display:flex; flex-direction:column; gap:10px;">
<div style="font-size:12px; font-weight:700; color:#8B93A7;">Top-Performer (24h)</div>
<div id="heroTradersContainer" style="display:flex; flex-direction:column; gap:12px;"></div>
</div>
</div>
</section>
<!-- FEATURES -->
<section id="features" style="padding:80px 48px; max-width:1200px; margin:0 auto;">
<div style="text-align:center; margin-bottom:48px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">FEATURES</div>
<h2 style="margin:0 0 12px; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Alles was du brauchst, um den Markt zu lesen</h2>
<p style="margin:0; color:#8B93A7; font-size:15px;">Konstruiert für Trader, die echtes Signal statt Rauschen suchen.</p>
</div>
<div id="featuresContainer" style="display:grid; grid-template-columns:repeat(3,1fr); gap:18px;"></div>
</section>
<!-- PRICING -->
<section id="pricing" style="padding:80px 48px; max-width:1160px; margin:0 auto;">
<div style="text-align:center; margin-bottom:48px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">PREISE</div>
<h2 style="margin:0 0 12px; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Flexible Pläne für jede Phase</h2>
<p style="margin:0; color:#8B93A7; font-size:15px;">Skaliere unkompliziert hoch, wenn deine Aktivität wächst.</p>
</div>
<div id="pricingContainer" style="display:grid; grid-template-columns:repeat(3,1fr); gap:20px; align-items:stretch;"></div>
</section>
<!-- FAQ -->
<section id="faq" style="padding:80px 48px 100px; max-width:820px; margin:0 auto;">
<div style="text-align:center; margin-bottom:44px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">FAQ</div>
<h2 style="margin:0; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Häufige Fragen</h2>
</div>
<div id="faqContainer" style="display:flex; flex-direction:column; gap:10px;"></div>
</section>
<!-- FOOTER -->
<footer style="padding:32px 48px; border-top:1px solid rgba(255,255,255,0.07); display:flex; align-items:center; justify-content:space-between; color:#5B6377; font-size:12.5px;">
<div>© 2026 Predictalytics. Alle Rechte vorbehalten.</div>
<div style="display:flex; gap:20px;">
<a href="./docs.html" style="color:#5B6377;">API Docs</a>
<a href="#faq" style="color:#5B6377;">FAQ</a>
<a href="#pricing" style="color:#5B6377;">Preise</a>
</div>
</footer>
<!-- AUTH MODAL -->
<div id="authModal" onclick="closeAuth()" style="display:none; position:fixed; inset:0; z-index:100; background:rgba(6,8,13,0.7); backdrop-filter:blur(6px); align-items:center; justify-content:center;">
<div onclick="event.stopPropagation()" style="width:400px; max-width:92vw; border-radius:20px; background:rgba(16,20,29,0.9); border:1px solid rgba(255,255,255,0.1); backdrop-filter:blur(30px); box-shadow:0 30px 80px rgba(0,0,0,0.5); padding:32px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px;">
<div style="font-size:19px; font-weight:800;" id="authTitle">Log in</div>
<div onclick="closeAuth()" style="cursor:pointer; width:28px; height:28px; border-radius:8px; background:rgba(255,255,255,0.06); display:flex; align-items:center; justify-content:center; font-size:14px; color:#8B93A7;"></div>
</div>
<div style="display:flex; flex-direction:column; gap:14px;">
<div id="authNameGroup" style="display:none; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Name</label>
<input placeholder="Jane Trader" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">E-Mail</label>
<input placeholder="you@domain.com" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Passwort</label>
<input type="password" placeholder="••••••••" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
<a href="./index.html" style="margin-top:6px; padding:13px; border-radius:11px; text-align:center; font-size:14px; font-weight:700; cursor:pointer; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 24px rgba(22,82,240,0.4); color:white;" id="authSubmitLabel">Anmelden</a>
<div style="text-align:center; font-size:12.5px; color:#8B93A7; margin-top:4px;">
<span id="authSwitchPrompt">Noch kein Konto?</span> <span onclick="switchAuthMode()" style="color:#5b9dff; font-weight:700; cursor:pointer;" id="authSwitchAction">Registrieren</span>
</div>
</div>
</div>
</div>
</div>
<script>
let currentAuthMode = 'login';
function openAuth(mode) {
currentAuthMode = mode;
const modal = document.getElementById('authModal');
modal.style.display = 'flex';
modal.classList.add('fade-in');
updateAuthUI();
}
function closeAuth() {
document.getElementById('authModal').style.display = 'none';
}
function switchAuthMode() {
currentAuthMode = currentAuthMode === 'login' ? 'register' : 'login';
updateAuthUI();
}
function updateAuthUI() {
const isReg = currentAuthMode === 'register';
document.getElementById('authTitle').textContent = isReg ? 'Konto erstellen' : 'Anmelden';
document.getElementById('authSubmitLabel').textContent = isReg ? 'Konto erstellen' : 'Anmelden';
document.getElementById('authNameGroup').style.display = isReg ? 'flex' : 'none';
document.getElementById('authSwitchPrompt').textContent = isReg ? 'Bereits registriert?' : 'Noch kein Konto?';
document.getElementById('authSwitchAction').textContent = isReg ? 'Anmelden' : 'Registrieren';
}
// --- Dynamic calculations and rendering ---
function buildPath(series, w, h, pad) {
const min = Math.min(...series);
const max = Math.max(...series);
const range = max - min || 1;
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const pts = series.map((v, i) => {
const x = pad + (i / (series.length - 1)) * innerW;
const y = pad + innerH - ((v - min) / range) * innerH;
return [x, y];
});
let line = "M" + pts[0][0].toFixed(1) + "," + pts[0][1].toFixed(1);
for (let i = 1; i < pts.length; i++) line += " L" + pts[i][0].toFixed(1) + "," + pts[i][1].toFixed(1);
const area = line + ` L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z`;
return { line, area };
}
// Render SVG Hero Chart
const heroSeries = [40, 44, 41, 48, 52, 50, 58, 55, 62, 66, 63, 70, 68, 74, 71.4];
const { line, area } = buildPath(heroSeries, 500, 140, 8);
document.getElementById('heroChartSvg').innerHTML = `
<defs>
<linearGradient id="hfill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#12D48A" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#12D48A" stop-opacity="0"/>
</linearGradient>
</defs>
<path d="${area}" fill="url(#hfill)" stroke="none"></path>
<path d="${line}" fill="none" stroke="#12D48A" stroke-width="2.5"></path>
`;
// Render Hero Traders
const heroTraders = [
{ handle: "quant_owl", pnl: "+$216.420", bg: "linear-gradient(135deg,#1652F0,#4c8dff)" },
{ handle: "arb_meridian", pnl: "+$454.100", bg: "linear-gradient(135deg,#12D48A,#0a8f5f)" },
{ handle: "resolvr", pnl: "+$105.800", bg: "linear-gradient(135deg,#7c5cff,#4c8dff)" },
];
document.getElementById('heroTradersContainer').innerHTML = heroTraders.map(t => `
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:24px; height:24px; border-radius:7px; background:${t.bg};"></div>
<div style="font-size:12.5px; font-weight:700; flex:1;">${t.handle}</div>
<div style="font-size:12px; font-family:'Roboto Mono'; font-weight:700; color:#12D48A;">${t.pnl}</div>
</div>
`).join('');
// Render Features
const features = [
{ title: "Trader-Verfolgung", desc: "Verfolge das vollständige Portfolio, die P&L-Kurve und das Verhalten jedes Wallets plattformübergreifend.", iconBg: "rgba(22,82,240,0.14)", dotColor: "#5b9dff" },
{ title: "Wallet-Clustering", desc: "Erkenne verknüpfte Wallets und koordinierte Accounts hinter einer gemeinsamen Handelsstrategie.", iconBg: "rgba(124,92,255,0.14)", dotColor: "#7c5cff" },
{ title: "Markttiefe & Analysen", desc: "Orderbuch-Verläufe, Liquiditätsmetriken und Halterkonzentrationen für jeden aktiven Markt.", iconBg: "rgba(18,212,138,0.14)", dotColor: "#12D48A" },
{ title: "Echtzeit-Alerts", desc: "Erhalte Benachrichtigungen, sobald ein beobachteter Trader eine neue Position öffnet oder der Markt dreht.", iconBg: "rgba(255,157,76,0.14)", dotColor: "#ff9d4c" },
{ title: "Trader-Tags & Traits", desc: "Automatische Charakterisierung wie Arbitrageur, bot-ähnlich oder Market-Maker direkt auf einen Blick.", iconBg: "rgba(246,70,93,0.14)", dotColor: "#F6465D" },
{ title: "Programmatischer API-Zugriff", desc: "Integriere alle gesammelten Rohdaten und Auswertungen direkt in deine eigenen Skripte.", iconBg: "rgba(22,82,240,0.14)", dotColor: "#5b9dff" },
];
document.getElementById('featuresContainer').innerHTML = features.map(f => `
<div style="padding:26px; border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px);">
<div style="width:42px; height:42px; border-radius:12px; background:${f.iconBg}; margin-bottom:16px; display:flex; align-items:center; justify-content:center;">
<div style="width:16px; height:16px; border-radius:5px; background:${f.dotColor};"></div>
</div>
<div style="font-size:16px; font-weight:700; margin-bottom:8px;">${f.title}</div>
<div style="font-size:13.5px; color:#8B93A7; line-height:1.6;">${f.desc}</div>
</div>
`).join('');
// Render Pricing
const plans = [
{ name: "Free", price: "$0", period: "/ Monat", featured: false, border: "rgba(255,255,255,0.08)", bg: "rgba(255,255,255,0.04)", btnBg: "rgba(255,255,255,0.06)", btnBorder: "rgba(255,255,255,0.12)", btnText: "#EDEFF5",
items: ["20 API-Calls / Tag", "Eingeschränkter Web-Zugang", "Grundlegende Marktübersicht", "Community-Support"] },
{ name: "Pro", price: "$99", period: "/ Monat", featured: true, border: "rgba(22,82,240,0.35)", bg: "rgba(22,82,240,0.08)", btnBg: "linear-gradient(135deg,#1652F0,#4c8dff)", btnBorder: "none", btnText: "white",
items: ["2.000 API-Calls / Tag", "Erweiterte KI-Trader-Analysen", "Direkter MCP-Zugriff für deinen KI-Agenten", "Priority-Support"] },
{ name: "Enterprise", price: "Kontakt", period: "", featured: false, border: "rgba(255,255,255,0.08)", bg: "rgba(255,255,255,0.04)", btnBg: "rgba(255,255,255,0.06)", btnBorder: "rgba(255,255,255,0.12)", btnText: "#EDEFF5",
items: ["10.000+ API-Calls / Tag", "Eigene Prompts für KI-Auswertungen", "Individuelle Quotas", "24/7 Enterprise-Support"] },
];
document.getElementById('pricingContainer').innerHTML = plans.map(p => `
<div style="position:relative; padding:30px 26px; border-radius:20px; background:${p.bg}; border:1px solid ${p.border}; backdrop-filter:blur(24px); display:flex; flex-direction:column; ${p.featured ? 'box-shadow:0 20px 50px rgba(22,82,240,0.2);' : ''}">
${p.featured ? '<div style="position:absolute; top:-13px; left:50%; transform:translateX(-50%); padding:5px 14px; border-radius:20px; background:linear-gradient(135deg,#1652F0,#4c8dff); font-size:11px; font-weight:800; letter-spacing:0.04em;">AM BELIEBTESTEN</div>' : ''}
<div style="font-size:15px; font-weight:700; margin-bottom:6px;">${p.name}</div>
<div style="display:flex; align-items:baseline; gap:4px; margin-bottom:18px;">
<div style="font-size:36px; font-weight:800; font-family:'Roboto Mono'; letter-spacing:-0.02em;">${p.price}</div>
<div style="font-size:13px; color:#8B93A7;">${p.period}</div>
</div>
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:22px;">
${p.items.map(item => `
<div style="display:flex; align-items:center; gap:9px; font-size:13.5px; color:#C7CCDA;">
<div style="width:6px; height:6px; border-radius:50%; background:#12D48A; flex:none;"></div>
<span>${item}</span>
</div>
`).join('')}
</div>
<div onclick="openAuth('register')" style="cursor:pointer; padding:12px; border-radius:11px; text-align:center; font-size:13.5px; font-weight:700; margin-top:auto; background:${p.btnBg}; border:1px solid ${p.btnBorder}; color:${p.btnText}; ${p.featured ? 'box-shadow:0 8px 24px rgba(22,82,240,0.4);' : ''}">${p.name === 'Enterprise' ? 'Vertrieb kontaktieren' : 'Auswählen'}</div>
</div>
`).join('');
// Render FAQs
const faqs = [
{ q: "Woher stammen die gezeigten Daten?", a: "Wir überwachen Onchain-Transaktionen direkt auf der Blockchain und über Subgraphs von Polymarket und anderen Prognosemärkten in Echtzeit, kombiniert mit Auflösungs-Metadaten." },
{ q: "Wie stuft ihr Trader als Bots oder Arbitrageure ein?", a: "Unser System wertet Verhaltensmuster wie Transaktionstaktung, Order-Größe und marktübergreifende Ausführungen aus, um Wallets automatisch mit Traits wie 'Bot' oder 'Arbitrageur' zu taggen." },
{ q: "Kann ich beliebige Wallets hinzufügen?", a: "Ja. Gib einfach ein Wallet im Dashboard ein, um die historische P&L, alle Trades und Verknüpfungen direkt analysieren zu lassen." },
{ q: "Was bietet der API-Zugriff?", a: "Voller Lesezugriff auf aggregierte Bestenlisten, Detailstatistiken und Orderbuchverläufe, damit du diese Daten in deine eigenen Trading-Modelle einbetten kannst." },
];
document.getElementById('faqContainer').innerHTML = faqs.map((f, i) => `
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); overflow:hidden;">
<div onclick="toggleFaq(${i})" style="cursor:pointer; padding:18px 22px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div style="font-size:14.5px; font-weight:700;">${f.q}</div>
<div id="faqIcon-${i}" style="flex:none; width:20px; height:20px; display:flex; align-items:center; justify-content:center; font-size:16px; color:#5b9dff; transition:transform 0.2s;">+</div>
</div>
<div id="faqAnswer-${i}" style="display:none; padding:0 22px 18px; font-size:13.5px; color:#8B93A7; line-height:1.7;">${f.a}</div>
</div>
`).join('');
let activeFaq = -1;
window.toggleFaq = function(i) {
const ans = document.getElementById(`faqAnswer-${i}`);
const icon = document.getElementById(`faqIcon-${i}`);
if (activeFaq === i) {
ans.style.display = 'none';
icon.style.transform = 'rotate(0deg)';
activeFaq = -1;
} else {
// Close previous
if (activeFaq !== -1) {
document.getElementById(`faqAnswer-${activeFaq}`).style.display = 'none';
document.getElementById(`faqIcon-${activeFaq}`).style.transform = 'rotate(0deg)';
}
ans.style.display = 'block';
icon.style.transform = 'rotate(45deg)';
activeFaq = i;
}
};
</script>
</body>
</html>
@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Infrastructure.Providers.Polymarket;
using Predictalytics.Infrastructure.Services;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class BackendRedesignTests
{
[Fact]
public async Task AppDbContext_ShouldThrow_WhenReadOnlyDatabaseTrue()
{
// Arrange
var inMemorySettings = new Dictionary<string, string?> {
{"ApiSettings:ReadOnlyDatabase", "true"}
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(inMemorySettings)
.Build();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: "ReadOnlyTestDb")
.Options;
using var db = new AppDbContext(options, configuration);
db.Traders.Add(new Trader
{
Id = 1,
Platform = PlatformType.Polymarket,
PlatformUserId = "0x123",
DisplayName = "Test"
});
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () => await db.SaveChangesAsync());
}
[Fact]
public async Task PolymarketApiClient_ShouldUseCache_ForGetMarketAsync()
{
// Arrange
var services = new ServiceCollection();
services.AddMemoryCache();
var serviceProvider = services.BuildServiceProvider();
var cache = serviceProvider.GetRequiredService<IMemoryCache>();
// Set up mock HTTP handler returning a list containing a GammaMarketResponse
var mockResponse = new List<GammaMarketResponse>
{
new() { ConditionId = "cond-1", Question = "Will it rain?" }
};
var handlerCallCount = 0;
var mockHandler = new MockHttpMessageHandler(req =>
{
handlerCallCount++;
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Content = JsonContent.Create(mockResponse);
return response;
});
var httpClient = new HttpClient(mockHandler);
var httpFactoryMock = new MockHttpClientFactory(httpClient);
var rateLimiterMock = new MockRateLimiter();
var egressPoolMock = new MockEgressPoolService();
var client = new PolymarketApiClient(
httpFactoryMock,
rateLimiterMock,
egressPoolMock,
cache,
NullLogger<PolymarketApiClient>.Instance
);
// Act
// 1. First fetch: should trigger HTTP handler
var m1 = await client.GetMarketAsync("cond-1");
// 2. Second fetch: should serve from cache
var m2 = await client.GetMarketAsync("cond-1");
// Assert
Assert.NotNull(m1);
Assert.Equal("cond-1", m1.ConditionId);
Assert.Equal("cond-1", m2?.ConditionId);
Assert.Equal(1, handlerCallCount); // Only 1 HTTP call made
}
private class MockHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public MockHttpClientFactory(HttpClient client) => _client = client;
public HttpClient CreateClient(string name) => _client;
}
private class MockRateLimiter : IRateLimiter
{
public Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default", string? channelId = null) => Task.CompletedTask;
public bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default", string? channelId = null) => true;
public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default", string? channelId = null) { }
}
private class MockEgressPoolService : IEgressPoolService
{
public string? GetNextChannelId() => null;
public HttpMessageInvoker? GetInvoker(string channelId) => null;
public void ReportFailure(string channelId, Exception exception) { }
public void ReportSuccess(string channelId) { }
}
private class MockHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _sender;
public MockHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> sender)
{
_sender = sender;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(_sender(request));
}
}
}
@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Predictalytics.Application.Interfaces;
using Predictalytics.Application.Services;
using Predictalytics.Domain.Enums;
using Predictalytics.Infrastructure.Configuration;
using Predictalytics.Infrastructure.Services;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class EgressPoolTests
{
[Fact]
public void EgressPoolService_ShouldRoundRobin()
{
// Arrange
var options = Options.Create(new EgressOptions
{
Channels = new List<EgressChannelOptions>
{
new() { Id = "ch-1", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8080" },
new() { Id = "ch-2", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8081" },
}
});
var service = new EgressPoolService(options, NullLogger<EgressPoolService>.Instance);
// Act & Assert
var first = service.GetNextChannelId();
var second = service.GetNextChannelId();
var third = service.GetNextChannelId();
Assert.Equal("ch-1", first);
Assert.Equal("ch-2", second);
Assert.Equal("ch-1", third);
}
[Fact]
public void EgressPoolService_ShouldFallbackToNull_WhenNoChannelsConfigured()
{
// Arrange
var options = Options.Create(new EgressOptions());
var service = new EgressPoolService(options, NullLogger<EgressPoolService>.Instance);
// Act
var channel = service.GetNextChannelId();
// Assert
Assert.Null(channel);
}
[Fact]
public void EgressPoolService_ShouldCooldown_OnFailures()
{
// Arrange
var options = Options.Create(new EgressOptions
{
Channels = new List<EgressChannelOptions>
{
new() { Id = "ch-1", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8080" },
new() { Id = "ch-2", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8081" },
}
});
var service = new EgressPoolService(options, NullLogger<EgressPoolService>.Instance);
// Fail ch-1 three times
service.ReportFailure("ch-1", new Exception("Fail 1"));
service.ReportFailure("ch-1", new Exception("Fail 2"));
service.ReportFailure("ch-1", new Exception("Fail 3"));
// Act
// ch-1 is in cooldown, so it should only return ch-2
var first = service.GetNextChannelId();
var second = service.GetNextChannelId();
// Assert
Assert.Equal("ch-2", first);
Assert.Equal("ch-2", second);
}
[Fact]
public async Task RateLimiterService_ShouldLimitPerChannel()
{
// Arrange
var limiter = new RateLimiterService();
// Act
// Set a block penalty for ch-1
limiter.ReportRateLimitExceeded(PlatformType.Polymarket, TimeSpan.FromSeconds(5), "Data", "ch-1");
// Assert
// ch-1 should be limited
var canRequestCh1 = limiter.CanMakeRequest(PlatformType.Polymarket, "Data", "ch-1");
// ch-2 should NOT be limited
var canRequestCh2 = limiter.CanMakeRequest(PlatformType.Polymarket, "Data", "ch-2");
// Global/Default channel should NOT be limited
var canRequestGlobal = limiter.CanMakeRequest(PlatformType.Polymarket, "Data");
Assert.False(canRequestCh1);
Assert.True(canRequestCh2);
Assert.True(canRequestGlobal);
}
}
@@ -52,4 +52,34 @@ public class MarketCategoryMapperTests
var (_, subcategory) = MarketCategoryMapper.Map("", "NBA, Basketball", "Lakers to win?"); var (_, subcategory) = MarketCategoryMapper.Map("", "NBA, Basketball", "Lakers to win?");
Assert.Equal("NBA", subcategory); Assert.Equal("NBA", subcategory);
} }
[Fact]
public void Map_CanonicalTag_WinsOverHeuristics()
{
// "Politics" is a canonical tag, even though it's last in tags, it should map to Politics.
var (category, subcategory) = MarketCategoryMapper.Map("", "Ethiopia, Elections, Politics", "Next PM of Ethiopia?");
Assert.Equal(MarketCategory.Politics, category);
Assert.Equal("Ethiopia", subcategory);
}
[Fact]
public void Map_Subcategory_FiltersNoiseAndCategorySelf()
{
// Category is Sports.
// "Sports" should be skipped as subcategory because it is the category itself.
// "Hide From New" and "2025 Predictions" should be skipped as noise tags.
// "Soccer" should be picked.
var (category, subcategory) = MarketCategoryMapper.Map("", "Hide From New, Sports, 2025 Predictions, Soccer, FIFA World Cup", "Lakers to win?");
Assert.Equal(MarketCategory.Sports, category);
Assert.Equal("Soccer", subcategory);
}
[Fact]
public void Map_Subcategory_EmptyNormalization()
{
// When all tags are noise or category names, subcategory should normalize to empty string.
var (category, subcategory) = MarketCategoryMapper.Map("", "Sports, Hide From New, 2026", "Lakers to win?");
Assert.Equal(MarketCategory.Sports, category);
Assert.Equal(string.Empty, subcategory);
}
} }
@@ -0,0 +1,12 @@
using System;
using System.Net.Http;
namespace Predictalytics.Application.Interfaces;
public interface IEgressPoolService
{
string? GetNextChannelId();
HttpMessageInvoker? GetInvoker(string channelId);
void ReportFailure(string channelId, Exception exception);
void ReportSuccess(string channelId);
}
@@ -8,11 +8,11 @@ namespace Predictalytics.Application.Interfaces;
public interface IRateLimiter public interface IRateLimiter
{ {
/// <summary>Wait until a request can be made to the given platform.</summary> /// <summary>Wait until a request can be made to the given platform.</summary>
Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default"); Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default", string? channelId = null);
/// <summary>Check if a request can be made immediately.</summary> /// <summary>Check if a request can be made immediately.</summary>
bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default"); bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default", string? channelId = null);
/// <summary>Report that a 429 Too Many Requests was received.</summary> /// <summary>Report that a 429 Too Many Requests was received.</summary>
void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default"); void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default", string? channelId = null);
} }
@@ -28,10 +28,12 @@ public class RateLimiterService : IRateLimiter
{ "Stake-Default", 1000 } { "Stake-Default", 1000 }
}; };
public async Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default") public async Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default", string? channelId = null)
{ {
var key = $"{platform}-{endpointGroup}"; var configKey = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(key)) key = $"{platform}-Default"; if (!Delays.ContainsKey(configKey)) configKey = $"{platform}-Default";
var key = string.IsNullOrEmpty(channelId) ? configKey : $"{configKey}-{channelId}";
var sem = _semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); var sem = _semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await sem.WaitAsync(ct); await sem.WaitAsync(ct);
@@ -48,7 +50,7 @@ public class RateLimiterService : IRateLimiter
if (_lastRequest.TryGetValue(key, out var last)) if (_lastRequest.TryGetValue(key, out var last))
{ {
var delayMs = Delays.GetValueOrDefault(key, 1000); var delayMs = Delays.GetValueOrDefault(configKey, 1000);
var elapsed = (DateTime.UtcNow - last).TotalMilliseconds; var elapsed = (DateTime.UtcNow - last).TotalMilliseconds;
if (elapsed < delayMs) if (elapsed < delayMs)
await Task.Delay((int)(delayMs - elapsed), ct); await Task.Delay((int)(delayMs - elapsed), ct);
@@ -58,23 +60,27 @@ public class RateLimiterService : IRateLimiter
finally { sem.Release(); } finally { sem.Release(); }
} }
public bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default") public bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default", string? channelId = null)
{ {
var key = $"{platform}-{endpointGroup}"; var configKey = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(key)) key = $"{platform}-Default"; if (!Delays.ContainsKey(configKey)) configKey = $"{platform}-Default";
var key = string.IsNullOrEmpty(channelId) ? configKey : $"{configKey}-{channelId}";
if (_blockedUntil.TryGetValue(key, out var blockedUntil) && blockedUntil > DateTime.UtcNow) if (_blockedUntil.TryGetValue(key, out var blockedUntil) && blockedUntil > DateTime.UtcNow)
return false; return false;
if (!_lastRequest.TryGetValue(key, out var last)) return true; if (!_lastRequest.TryGetValue(key, out var last)) return true;
var delayMs = Delays.GetValueOrDefault(key, 1000); var delayMs = Delays.GetValueOrDefault(configKey, 1000);
return (DateTime.UtcNow - last).TotalMilliseconds >= delayMs; return (DateTime.UtcNow - last).TotalMilliseconds >= delayMs;
} }
public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default") public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default", string? channelId = null)
{ {
var key = $"{platform}-{endpointGroup}"; var configKey = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(key)) key = $"{platform}-Default"; if (!Delays.ContainsKey(configKey)) configKey = $"{platform}-Default";
var key = string.IsNullOrEmpty(channelId) ? configKey : $"{configKey}-{channelId}";
var penalty = retryAfter ?? TimeSpan.FromSeconds(30); var penalty = retryAfter ?? TimeSpan.FromSeconds(30);
_blockedUntil[key] = DateTime.UtcNow.Add(penalty); _blockedUntil[key] = DateTime.UtcNow.Add(penalty);
@@ -0,0 +1,21 @@
using System.Collections.Generic;
namespace Predictalytics.Infrastructure.Configuration;
public enum EgressChannelType
{
SourceIp,
Proxy
}
public class EgressOptions
{
public List<EgressChannelOptions> Channels { get; set; } = new();
}
public class EgressChannelOptions
{
public string Id { get; set; } = string.Empty;
public EgressChannelType Type { get; set; }
public string Value { get; set; } = string.Empty;
}
@@ -1,5 +1,6 @@
using Predictalytics.Domain.Entities; using Predictalytics.Domain.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Predictalytics.Infrastructure.Data; namespace Predictalytics.Infrastructure.Data;
@@ -25,7 +26,49 @@ public class AppDbContext : DbContext
public DbSet<TraderTrait> TraderTraits => Set<TraderTrait>(); public DbSet<TraderTrait> TraderTraits => Set<TraderTrait>();
public DbSet<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>(); public DbSet<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } private readonly bool _isReadOnly;
public AppDbContext(DbContextOptions<AppDbContext> options, Microsoft.Extensions.Configuration.IConfiguration? configuration = null)
: base(options)
{
_isReadOnly = configuration?.GetValue<bool>("ApiSettings:ReadOnlyDatabase", false) ?? false;
}
public override int SaveChanges()
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChanges();
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChangesAsync(cancellationToken);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
protected override void OnModelCreating(ModelBuilder mb) protected override void OnModelCreating(ModelBuilder mb)
{ {
@@ -10,6 +10,7 @@ using Predictalytics.Infrastructure.Providers.Polymarket;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Predictalytics.Infrastructure.Configuration;
namespace Predictalytics.Infrastructure; namespace Predictalytics.Infrastructure;
@@ -79,6 +80,12 @@ public static class DependencyInjection
services.AddScoped<WatchlistService>(); services.AddScoped<WatchlistService>();
services.AddSingleton<IRateLimiter, RateLimiterService>(); services.AddSingleton<IRateLimiter, RateLimiterService>();
services.AddSingleton<IPlatformStatisticsService, PlatformStatisticsService>(); services.AddSingleton<IPlatformStatisticsService, PlatformStatisticsService>();
services.AddMemoryCache();
// Egress pool config & services
services.Configure<EgressOptions>(configuration.GetSection("Egress"));
services.AddSingleton<IEgressPoolService, EgressPoolService>();
services.AddTransient<EgressPoolHandler>();
// Platform Providers // Platform Providers
services.AddHttpClient(); services.AddHttpClient();
@@ -89,6 +96,15 @@ public static class DependencyInjection
c.Timeout = TimeSpan.FromSeconds(60); c.Timeout = TimeSpan.FromSeconds(60);
}); });
services.AddHttpClient("PolymarketData")
.AddHttpMessageHandler<EgressPoolHandler>();
services.AddHttpClient("PolymarketGamma")
.AddHttpMessageHandler<EgressPoolHandler>();
services.AddHttpClient("PolymarketClob")
.AddHttpMessageHandler<EgressPoolHandler>();
services.AddSingleton<PolymarketApiClient>(); services.AddSingleton<PolymarketApiClient>();
services.AddSingleton<LimitlessApiClient>(); services.AddSingleton<LimitlessApiClient>();
services.AddHttpClient<Predictalytics.Application.Interfaces.IOpenRouterApiClient, Predictalytics.Infrastructure.Providers.OpenRouter.OpenRouterApiClient>(); services.AddHttpClient<Predictalytics.Application.Interfaces.IOpenRouterApiClient, Predictalytics.Infrastructure.Providers.OpenRouter.OpenRouterApiClient>();
@@ -109,6 +125,14 @@ public static class DependencyInjection
public static async Task EnsureDatabaseAsync(IServiceProvider services, bool dbDebug = false) public static async Task EnsureDatabaseAsync(IServiceProvider services, bool dbDebug = false)
{ {
using var scope = services.CreateScope(); using var scope = services.CreateScope();
var config = scope.ServiceProvider.GetService<IConfiguration>();
if (config != null && config.GetValue<bool>("ApiSettings:ReadOnlyDatabase", false))
{
Serilog.Log.Warning("⚠️ EnsureDatabaseAsync: Skipping EF Core migrations and platform seeding because the database is configured as Read-Only.");
return;
}
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
try try
@@ -57,6 +57,45 @@ public static class MarketCategoryMapper
new[] { "fed", "fomc", "cpi", "gdp", "nasdaq", "dow", "ipo" }), new[] { "fed", "fomc", "cpi", "gdp", "nasdaq", "dow", "ipo" }),
}; };
private static readonly Dictionary<string, MarketCategory> CanonicalTags = new(StringComparer.OrdinalIgnoreCase)
{
{ "politics", MarketCategory.Politics },
{ "crypto", MarketCategory.Crypto },
{ "sports", MarketCategory.Sports },
{ "pop culture", MarketCategory.PopCulture },
{ "popculture", MarketCategory.PopCulture },
{ "science", MarketCategory.Science },
{ "global news", MarketCategory.GlobalNews },
{ "globalnews", MarketCategory.GlobalNews },
{ "news", MarketCategory.GlobalNews },
{ "economy", MarketCategory.Economy },
{ "business", MarketCategory.Economy },
{ "finance", MarketCategory.Economy }
};
private static readonly HashSet<string> BlacklistTags = new(StringComparer.OrdinalIgnoreCase)
{
"hide from new",
"hide_from_new",
"tournament futures",
"main election",
"recurring",
"exchange",
"overall",
"other",
"none"
};
private static bool IsNoiseTag(string tag)
{
if (string.IsNullOrWhiteSpace(tag)) return true;
var trimmed = tag.Trim();
if (BlacklistTags.Contains(trimmed)) return true;
if (int.TryParse(trimmed, out _)) return true;
if (Regex.IsMatch(trimmed, @"^\d{4}\s+predictions$", RegexOptions.IgnoreCase)) return true;
return false;
}
/// <summary> /// <summary>
/// Classifies a market. The question text is a first-class signal: the Gamma /// Classifies a market. The question text is a first-class signal: the Gamma
/// /markets endpoint delivers neither a category field nor event tags, so for /// /markets endpoint delivers neither a category field nor event tags, so for
@@ -64,27 +103,60 @@ public static class MarketCategoryMapper
/// </summary> /// </summary>
public static (MarketCategory Category, string Subcategory) Map(string rawCategory, string tags, string question = "") public static (MarketCategory Category, string Subcategory) Map(string rawCategory, string tags, string question = "")
{ {
var tagList = (tags ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim())
.Where(t => !string.IsNullOrEmpty(t))
.ToList();
if (!string.IsNullOrWhiteSpace(rawCategory))
{
tagList.Insert(0, rawCategory.Trim());
}
// F1: Check canonical tags first over the entire tag set
foreach (var tag in tagList)
{
if (CanonicalTags.TryGetValue(tag, out var canonicalCategory))
{
return (canonicalCategory, GetSubcategory(rawCategory, tags ?? string.Empty, canonicalCategory.ToString()));
}
}
// Fallback: heuristic search on rawCategory + tags + question
var searchString = $"{rawCategory} {tags} {question}".ToLowerInvariant(); var searchString = $"{rawCategory} {tags} {question}".ToLowerInvariant();
var tokens = new HashSet<string>(Regex.Split(searchString, "[^a-z0-9.]+")); var tokens = new HashSet<string>(Regex.Split(searchString, "[^a-z0-9.]+"));
foreach (var rule in Rules) foreach (var rule in Rules)
{ {
if (rule.Words.Any(tokens.Contains) || rule.Substrings.Any(searchString.Contains)) if (rule.Words.Any(tokens.Contains) || rule.Substrings.Any(searchString.Contains))
return (rule.Category, GetSubcategory(rawCategory, tags, rule.Category.ToString())); return (rule.Category, GetSubcategory(rawCategory, tags ?? string.Empty, rule.Category.ToString()));
} }
return (MarketCategory.Other, GetSubcategory(rawCategory, tags, "Other")); return (MarketCategory.Other, GetSubcategory(rawCategory, tags ?? string.Empty, "Other"));
} }
private static string GetSubcategory(string rawCategory, string tags, string fallback) private static string GetSubcategory(string rawCategory, string tags, string categoryName)
{ {
if (!string.IsNullOrWhiteSpace(rawCategory) && !rawCategory.Equals("OVERALL", StringComparison.OrdinalIgnoreCase)) var candidates = tags.Split(',', StringSplitOptions.RemoveEmptyEntries)
return rawCategory.Trim(); .Select(t => t.Trim())
.ToList();
var firstTag = tags.Split(',', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); if (!string.IsNullOrWhiteSpace(rawCategory))
if (!string.IsNullOrWhiteSpace(firstTag)) {
return firstTag; candidates.Insert(0, rawCategory.Trim());
}
return fallback; foreach (var c in candidates)
{
if (IsNoiseTag(c)) continue;
// F2: Skip tag if it is the category itself or matches any canonical category tag
if (c.Equals(categoryName, StringComparison.OrdinalIgnoreCase)) continue;
if (CanonicalTags.ContainsKey(c)) continue;
return c;
}
return string.Empty;
} }
} }
@@ -1,8 +1,10 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Caching.Memory;
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums; using Predictalytics.Domain.Enums;
using Predictalytics.Infrastructure.Services;
namespace Predictalytics.Infrastructure.Providers.Polymarket; namespace Predictalytics.Infrastructure.Providers.Polymarket;
@@ -16,13 +18,15 @@ public class PolymarketApiClient
private readonly HttpClient _gammaClient; private readonly HttpClient _gammaClient;
private readonly HttpClient _clobClient; private readonly HttpClient _clobClient;
private readonly IRateLimiter _rateLimiter; private readonly IRateLimiter _rateLimiter;
private readonly IEgressPoolService _egressPool;
private readonly IMemoryCache _cache;
private readonly ILogger<PolymarketApiClient> _logger; private readonly ILogger<PolymarketApiClient> _logger;
private const string DataApiBase = "https://data-api.polymarket.com"; private const string DataApiBase = "https://data-api.polymarket.com";
private const string GammaApiBase = "https://gamma-api.polymarket.com"; private const string GammaApiBase = "https://gamma-api.polymarket.com";
private const string ClobApiBase = "https://clob.polymarket.com"; private const string ClobApiBase = "https://clob.polymarket.com";
public PolymarketApiClient(IHttpClientFactory httpFactory, IRateLimiter rateLimiter, ILogger<PolymarketApiClient> logger) public PolymarketApiClient(IHttpClientFactory httpFactory, IRateLimiter rateLimiter, IEgressPoolService egressPool, IMemoryCache cache, ILogger<PolymarketApiClient> logger)
{ {
_client = httpFactory.CreateClient("PolymarketData"); _client = httpFactory.CreateClient("PolymarketData");
_client.BaseAddress = new Uri(DataApiBase); _client.BaseAddress = new Uri(DataApiBase);
@@ -37,6 +41,8 @@ public class PolymarketApiClient
_clobClient.DefaultRequestHeaders.Add("Accept", "application/json"); _clobClient.DefaultRequestHeaders.Add("Accept", "application/json");
_rateLimiter = rateLimiter; _rateLimiter = rateLimiter;
_egressPool = egressPool;
_cache = cache;
_logger = logger; _logger = logger;
} }
@@ -93,9 +99,42 @@ public class PolymarketApiClient
public async Task<GammaMarketResponse?> GetMarketAsync(string conditionId, CancellationToken ct = default) public async Task<GammaMarketResponse?> GetMarketAsync(string conditionId, CancellationToken ct = default)
{ {
var cacheKey = $"market-{conditionId}";
if (_cache.TryGetValue<GammaMarketResponse>(cacheKey, out var cached))
{
return cached;
}
var url = $"/markets?condition_id={conditionId}"; var url = $"/markets?condition_id={conditionId}";
var results = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, "Gamma", ct); var results = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, "Gamma", ct);
return results?.FirstOrDefault(); var market = results?.FirstOrDefault();
if (market != null)
{
_cache.Set(cacheKey, market, TimeSpan.FromMinutes(30));
}
return market;
}
public async Task<GammaEventResponse?> GetEventAsync(long eventId, CancellationToken ct = default)
{
var cacheKey = $"event-{eventId}";
if (_cache.TryGetValue<GammaEventResponse>(cacheKey, out var cached))
{
return cached;
}
var url = $"/events?id={eventId}";
var results = await ExecuteWithRetryAsync<List<GammaEventResponse>>(_gammaClient, url, "Gamma", ct);
var ev = results?.FirstOrDefault();
if (ev != null)
{
_cache.Set(cacheKey, ev, TimeSpan.FromMinutes(60));
}
return ev;
} }
/// <summary> /// <summary>
@@ -156,11 +195,18 @@ public class PolymarketApiClient
private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, string endpointGroup, CancellationToken ct, int attempt = 1) private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, string endpointGroup, CancellationToken ct, int attempt = 1)
{ {
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct, endpointGroup); var channelId = _egressPool.GetNextChannelId();
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct, endpointGroup, channelId);
try try
{ {
var response = await client.GetAsync(url, ct); using var request = new HttpRequestMessage(HttpMethod.Get, url);
if (!string.IsNullOrEmpty(channelId))
{
request.Options.Set(EgressRequestOptions.ChannelIdKey, channelId);
}
var response = await client.SendAsync(request, ct);
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{ {
@@ -180,9 +226,9 @@ public class PolymarketApiClient
waitTime = TimeSpan.FromSeconds(30); waitTime = TimeSpan.FromSeconds(30);
} }
_logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket {Group}. Pausing for {WaitTime}s...", endpointGroup, (int)waitTime.TotalSeconds); _logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket {Group} (Channel: {Channel}). Pausing for {WaitTime}s...", endpointGroup, channelId ?? "Default", (int)waitTime.TotalSeconds);
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime, endpointGroup); _rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime, endpointGroup, channelId);
if (attempt < 3) if (attempt < 3)
{ {
@@ -208,7 +254,7 @@ public class PolymarketApiClient
{ {
_logger.LogCritical("Unhandled 429 in PolymarketApiClient for {Url}. This should have been caught by the status code check.", url); _logger.LogCritical("Unhandled 429 in PolymarketApiClient for {Url}. This should have been caught by the status code check.", url);
} }
_logger.LogError(ex, "Failed to fetch from {Url} (attempt {Attempt})", url, attempt); _logger.LogError(ex, "Failed to fetch from {Url} (attempt {Attempt}, Channel: {Channel})", url, attempt, channelId ?? "Default");
return default; return default;
} }
} }
@@ -175,7 +175,25 @@ public class PolymarketProvider : IPlatformProvider
if (raw.Events != null && raw.Events.Count > 0) if (raw.Events != null && raw.Events.Count > 0)
{ {
var ev = raw.Events[0]; var ev = raw.Events[0];
parentTags = ev.Tags != null ? string.Join(", ", ev.Tags.Select(t => t.Label)) : ""; parentTags = ev.Tags != null && ev.Tags.Count > 0 ? string.Join(", ", ev.Tags.Select(t => t.Label)) : "";
// F3: If parentTags is empty, try to fetch the event from Gamma API to load tags
if (string.IsNullOrEmpty(parentTags) && long.TryParse(ev.Id, out var eventId))
{
try
{
var fetchedEv = await _api.GetEventAsync(eventId, ct);
if (fetchedEv != null && fetchedEv.Tags != null && fetchedEv.Tags.Count > 0)
{
parentTags = string.Join(", ", fetchedEv.Tags.Select(t => t.Label));
ev.Tags = fetchedEv.Tags;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch event {EventId} for tags", eventId);
}
}
} }
var market = MapGammaMarket(raw, parentTags); var market = MapGammaMarket(raw, parentTags);
@@ -0,0 +1,61 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Predictalytics.Application.Interfaces;
namespace Predictalytics.Infrastructure.Services;
public static class EgressRequestOptions
{
public static readonly HttpRequestOptionsKey<string> ChannelIdKey = new("EgressChannelId");
}
public class EgressPoolHandler : DelegatingHandler
{
private readonly IEgressPoolService _egressPool;
private readonly ILogger<EgressPoolHandler> _logger;
public EgressPoolHandler(IEgressPoolService egressPool, ILogger<EgressPoolHandler> logger)
{
_egressPool = egressPool;
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string? channelId = null;
if (request.Options.TryGetValue(EgressRequestOptions.ChannelIdKey, out var cid))
{
channelId = cid;
}
if (string.IsNullOrEmpty(channelId))
{
_logger.LogDebug("🔌 Routing request to {Host} using default channel (fallback)", request.RequestUri?.Host);
return await base.SendAsync(request, cancellationToken);
}
var invoker = _egressPool.GetInvoker(channelId);
if (invoker == null)
{
_logger.LogWarning("🔌 Egress channel '{Id}' not found. Falling back to default channel.", channelId);
return await base.SendAsync(request, cancellationToken);
}
_logger.LogDebug("🔌 Routing request to {Host} using channel '{ChannelId}'", request.RequestUri?.Host, channelId);
try
{
var response = await invoker.SendAsync(request, cancellationToken);
_egressPool.ReportSuccess(channelId);
return response;
}
catch (Exception ex)
{
_egressPool.ReportFailure(channelId, ex);
throw;
}
}
}
@@ -0,0 +1,178 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Predictalytics.Application.Interfaces;
using Predictalytics.Infrastructure.Configuration;
namespace Predictalytics.Infrastructure.Services;
public class EgressPoolService : IEgressPoolService, IDisposable
{
private class ChannelState
{
public EgressChannelOptions Options { get; }
public HttpMessageInvoker Invoker { get; }
public SocketsHttpHandler Handler { get; }
public int ConsecutiveFailures { get; set; }
public DateTime? CooldownUntil { get; set; }
public ChannelState(EgressChannelOptions options, HttpMessageInvoker invoker, SocketsHttpHandler handler)
{
Options = options;
Invoker = invoker;
Handler = handler;
}
}
private readonly List<ChannelState> _channels = new();
private readonly ConcurrentDictionary<string, ChannelState> _channelMap = new();
private readonly ILogger<EgressPoolService> _logger;
private int _roundRobinIndex = -1;
public EgressPoolService(IOptions<EgressOptions> options, ILogger<EgressPoolService> logger)
{
_logger = logger;
InitializeChannels(options.Value);
}
private void InitializeChannels(EgressOptions options)
{
if (options?.Channels == null || options.Channels.Count == 0)
{
_logger.LogInformation("🔌 EgressPoolService: No egress channels configured. Using default direct connection.");
return;
}
foreach (var config in options.Channels)
{
try
{
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
KeepAlivePingDelay = TimeSpan.FromSeconds(15),
KeepAlivePingTimeout = TimeSpan.FromSeconds(5)
};
if (config.Type == EgressChannelType.Proxy)
{
handler.Proxy = new WebProxy(config.Value);
handler.UseProxy = true;
_logger.LogInformation("🔌 Registered Proxy Channel '{Id}' -> {Proxy}", config.Id, config.Value);
}
else if (config.Type == EgressChannelType.SourceIp)
{
var ip = IPAddress.Parse(config.Value);
handler.ConnectCallback = async (context, cancellationToken) =>
{
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
socket.Bind(new IPEndPoint(ip, 0));
try
{
await socket.ConnectAsync(context.DnsEndPoint, cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
};
_logger.LogInformation("🔌 Registered SourceIp Channel '{Id}' -> {Ip}", config.Id, config.Value);
}
else
{
_logger.LogWarning("🔌 EgressPoolService: Unknown channel type '{Type}' for channel '{Id}'. Skipping.", config.Type, config.Id);
continue;
}
var invoker = new HttpMessageInvoker(handler, disposeHandler: true);
var state = new ChannelState(config, invoker, handler);
_channels.Add(state);
_channelMap[config.Id] = state;
}
catch (Exception ex)
{
_logger.LogError(ex, "🔌 EgressPoolService: Failed to initialize channel '{Id}' ({Type}) with value '{Value}'", config.Id, config.Type, config.Value);
}
}
}
public string? GetNextChannelId()
{
if (_channels.Count == 0) return null;
lock (_channels)
{
var now = DateTime.UtcNow;
// Filter out channels currently in cooldown
var available = _channels
.Where(c => !c.CooldownUntil.HasValue || c.CooldownUntil.Value < now)
.ToList();
if (available.Count == 0)
{
_logger.LogWarning("🔌 All egress channels are currently in cooldown! Falling back to default direct connection.");
return null;
}
_roundRobinIndex = (_roundRobinIndex + 1) % available.Count;
return available[_roundRobinIndex].Options.Id;
}
}
public HttpMessageInvoker? GetInvoker(string channelId)
{
return _channelMap.TryGetValue(channelId, out var state) ? state.Invoker : null;
}
public void ReportFailure(string channelId, Exception exception)
{
if (!_channelMap.TryGetValue(channelId, out var state)) return;
lock (state)
{
state.ConsecutiveFailures++;
_logger.LogWarning("🔌 Channel '{Id}' reported failure ({Count}/3): {Message}", channelId, state.ConsecutiveFailures, exception.Message);
if (state.ConsecutiveFailures >= 3)
{
var cooldown = TimeSpan.FromSeconds(30);
state.CooldownUntil = DateTime.UtcNow.Add(cooldown);
_logger.LogError("🔌 Channel '{Id}' has failed 3 times consecutively. Entering cooldown until {Time}", channelId, state.CooldownUntil);
}
}
}
public void ReportSuccess(string channelId)
{
if (!_channelMap.TryGetValue(channelId, out var state)) return;
lock (state)
{
if (state.ConsecutiveFailures > 0 || state.CooldownUntil.HasValue)
{
state.ConsecutiveFailures = 0;
state.CooldownUntil = null;
_logger.LogInformation("🔌 Channel '{Id}' recovered successfully.", channelId);
}
}
}
public void Dispose()
{
foreach (var state in _channels)
{
state.Invoker.Dispose();
}
_channels.Clear();
_channelMap.Clear();
}
}
@@ -19,6 +19,14 @@ public class AppSettings
[DefaultValue(false)] [DefaultValue(false)]
public bool DbConnectionDebug { get; set; } = false; public bool DbConnectionDebug { get; set; } = false;
private string _egressChannelsText = "";
[Category("Egress (Proxy/IP)")]
[DisplayName("Egress Channels")]
[Description("Liste der Egress-Kanäle im Format: id|type|value (Zeilengetrennt). Beispiel: prox-1|Proxy|http://user:pass@proxy:8080\nip-1|SourceIp|192.168.1.100")]
[Editor("System.ComponentModel.Design.MultilineStringEditor, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(System.Drawing.Design.UITypeEditor))]
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
private string _dbServer = "localhost"; private string _dbServer = "localhost";
private string _dbName = ""; private string _dbName = "";
private string _dbUser = ""; private string _dbUser = "";
@@ -38,12 +38,14 @@ public partial class MainForm : Form
{ {
_webServer.ConnectionString = _settings.ConnectionString; _webServer.ConnectionString = _settings.ConnectionString;
_webServer.DbConnectionDebug = _settings.DbConnectionDebug; _webServer.DbConnectionDebug = _settings.DbConnectionDebug;
_webServer.EgressChannelsText = _settings.EgressChannelsText;
} }
}; };
_webServer = new EmbeddedWebServer(); _webServer = new EmbeddedWebServer();
_webServer.ConnectionString = _settings.ConnectionString; _webServer.ConnectionString = _settings.ConnectionString;
_webServer.DbConnectionDebug = _settings.DbConnectionDebug; _webServer.DbConnectionDebug = _settings.DbConnectionDebug;
_webServer.EgressChannelsText = _settings.EgressChannelsText;
// Build Version (Date of compilation/file creation) // Build Version (Date of compilation/file creation)
try { try {
@@ -83,6 +85,7 @@ public partial class MainForm : Form
{ {
_webServer!.ConnectionString = _settings.ConnectionString; _webServer!.ConnectionString = _settings.ConnectionString;
_webServer!.DbConnectionDebug = _settings.DbConnectionDebug; _webServer!.DbConnectionDebug = _settings.DbConnectionDebug;
_webServer!.EgressChannelsText = _settings.EgressChannelsText;
await _webServer!.StartWorkersAsync(_workerCts.Token); await _webServer!.StartWorkersAsync(_workerCts.Token);
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using Predictalytics.Api; using Predictalytics.Api;
using Predictalytics.Api.Endpoints; using Predictalytics.Api.Endpoints;
using Predictalytics.Worker; using Predictalytics.Worker;
@@ -22,6 +23,7 @@ public class EmbeddedWebServer
private readonly object _lock = new(); private readonly object _lock = new();
public string? ConnectionString { get; set; } public string? ConnectionString { get; set; }
public bool DbConnectionDebug { get; set; } public bool DbConnectionDebug { get; set; }
public string? EgressChannelsText { get; set; }
public async Task UpdateDatabaseAsync() public async Task UpdateDatabaseAsync()
{ {
@@ -58,6 +60,29 @@ public class EmbeddedWebServer
WHERE m.IsResolved = 1 AND (m.ResolutionOutcome IS NULL OR m.ResolutionOutcome = '');", ct); WHERE m.IsResolved = 1 AND (m.ResolutionOutcome IS NULL OR m.ResolutionOutcome = '');", ct);
Log.Information("Backfilled ResolutionOutcome for {Count} markets", backfilled); Log.Information("Backfilled ResolutionOutcome for {Count} markets", backfilled);
// 1b. Offline Category/Subcategory backfill:
// Query all markets that have events with tags in the DB, and re-classify them
var dbMarkets = await db.Markets.Include(m => m.Event).ToListAsync(ct);
int categoryBackfilledCount = 0;
foreach (var m in dbMarkets)
{
if (m.Event != null && !string.IsNullOrEmpty(m.Event.Tags))
{
var (newCategory, newSubcategory) = Predictalytics.Infrastructure.Helpers.MarketCategoryMapper.Map(string.Empty, m.Event.Tags, m.Question);
if (m.Category != newCategory || m.Subcategory != newSubcategory)
{
m.Category = newCategory;
m.Subcategory = newSubcategory;
categoryBackfilledCount++;
}
}
}
if (categoryBackfilledCount > 0)
{
await db.SaveChangesAsync(ct);
Log.Information("Backfilled Category/Subcategory for {Count} markets based on Event tags", categoryBackfilledCount);
}
// 2. Wipe derived data computed by earlier engine versions. // 2. Wipe derived data computed by earlier engine versions.
var snapshots = await db.Database.ExecuteSqlRawAsync("DELETE FROM TraderDailySnapshots;", ct); var snapshots = await db.Database.ExecuteSqlRawAsync("DELETE FROM TraderDailySnapshots;", ct);
var catPerf = await db.Database.ExecuteSqlRawAsync("DELETE FROM TraderCategoryPerformances;", ct); var catPerf = await db.Database.ExecuteSqlRawAsync("DELETE FROM TraderCategoryPerformances;", ct);
@@ -75,6 +100,7 @@ public class EmbeddedWebServer
var traders = await db.Database.ExecuteSqlRawAsync("UPDATE Traders SET LastAnalyzedAt = NULL;", ct); var traders = await db.Database.ExecuteSqlRawAsync("UPDATE Traders SET LastAnalyzedAt = NULL;", ct);
var summary = $"ResolutionOutcome backfilled: {backfilled} markets\n" + var summary = $"ResolutionOutcome backfilled: {backfilled} markets\n" +
$"Category/Subcategory backfilled: {categoryBackfilledCount} markets\n" +
$"Daily snapshots deleted: {snapshots}\n" + $"Daily snapshots deleted: {snapshots}\n" +
$"Category stats deleted: {catPerf}\n" + $"Category stats deleted: {catPerf}\n" +
$"Positions reset: {positions}\n" + $"Positions reset: {positions}\n" +
@@ -104,11 +130,23 @@ public class EmbeddedWebServer
Log.Information("Initializing Predictalytics infrastructure with connection: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(effectiveConnString ?? "NULL", "Password=[^;]+", "Password=****")); Log.Information("Initializing Predictalytics infrastructure with connection: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(effectiveConnString ?? "NULL", "Password=[^;]+", "Password=****"));
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(builder.Services, builder.Configuration, effectiveConnString, DbConnectionDebug); Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(builder.Services, builder.Configuration, effectiveConnString, DbConnectionDebug);
if (!string.IsNullOrEmpty(EgressChannelsText))
{
var egressOptions = ParseEgressOptions(EgressChannelsText);
builder.Services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(options =>
{
options.Channels = egressOptions.Channels;
});
}
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
new() { Title = "Predictalytics Analytics API", Version = "v1" })); new() { Title = "Predictalytics Analytics API", Version = "v1" }));
var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>()
?? new[] { "http://localhost:5000" };
builder.Services.AddCors(o => o.AddDefaultPolicy(p => builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
builder.Host.UseSerilog(); builder.Host.UseSerilog();
var app = builder.Build(); var app = builder.Build();
@@ -131,7 +169,8 @@ public class EmbeddedWebServer
// Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints. // Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints.
// Do NOT map endpoints individually here. // Do NOT map endpoints individually here.
app.MapPredictalyticsEndpoints(); app.MapPredictalyticsReadEndpoints();
app.MapPredictalyticsControlEndpoints();
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug); await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug);
@@ -174,6 +213,14 @@ public class EmbeddedWebServer
{ {
Serilog.Log.Warning("🔌 [StartWorkers] Using ConnectionString: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(ConnectionString ?? "NULL", "Password=[^;]+", "Password=****")); Serilog.Log.Warning("🔌 [StartWorkers] Using ConnectionString: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(ConnectionString ?? "NULL", "Password=[^;]+", "Password=****"));
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug); Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug);
if (!string.IsNullOrEmpty(EgressChannelsText))
{
var egressOptions = ParseEgressOptions(EgressChannelsText);
services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(options =>
{
options.Channels = egressOptions.Channels;
});
}
services.AddWorkerServices(); services.AddWorkerServices();
}) })
.Build(); .Build();
@@ -286,4 +333,33 @@ public class EmbeddedWebServer
Log.Warning("wwwroot directory not found! Searched: {Paths}", string.Join(", ", candidates)); Log.Warning("wwwroot directory not found! Searched: {Paths}", string.Join(", ", candidates));
return null; return null;
} }
private Predictalytics.Infrastructure.Configuration.EgressOptions ParseEgressOptions(string text)
{
var options = new Predictalytics.Infrastructure.Configuration.EgressOptions();
if (string.IsNullOrWhiteSpace(text)) return options;
var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var parts = line.Split('|');
if (parts.Length >= 3)
{
var id = parts[0].Trim();
var typeStr = parts[1].Trim();
var val = parts[2].Trim();
if (Enum.TryParse<Predictalytics.Infrastructure.Configuration.EgressChannelType>(typeStr, true, out var type))
{
options.Channels.Add(new Predictalytics.Infrastructure.Configuration.EgressChannelOptions
{
Id = id,
Type = type,
Value = val
});
}
}
}
return options;
}
} }
@@ -28,5 +28,14 @@
"Azuro": { "Azuro": {
"EnableCrawling": false "EnableCrawling": false
} }
},
"Egress": {
"Channels": []
},
"ApiSettings": {
"CanControl": true,
"AuthRequired": false,
"AllowedOrigins": [ "http://localhost:5000" ],
"ReadOnlyDatabase": false
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; }
* { box-sizing: border-box; }
a { color: #5b9dff; text-decoration: none; }
a:hover { color: #8ab8ff; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
pre { margin: 0; }
</style>
</helmet>
<div style="position:relative; min-height:100vh; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.22), transparent 60%), #0a0d14; color:#EDEFF5;">
<header style="position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:space-between; padding:16px 48px; backdrop-filter:blur(20px); background:rgba(10,13,20,0.6); border-bottom:1px solid rgba(255,255,255,0.07);">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em;">{{ brandName }}<span style="color:#5b9dff;">.</span></div>
</div>
<nav style="display:flex; align-items:center; gap:32px;">
<a href="./Landing.dc.html#features" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Features</a>
<a href="./Landing.dc.html#pricing" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Pricing</a>
<a href="./Landing.dc.html#faq" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">FAQ</a>
<div style="font-size:13.5px; font-weight:700; color:#5b9dff;">API Docs</div>
</nav>
<a href="./Landing.dc.html" style="padding:9px 20px; border-radius:10px; font-size:13.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff);">Get API key</a>
</header>
<div style="display:flex; max-width:1280px; margin:0 auto;">
<!-- SIDEBAR -->
<aside style="width:230px; flex:none; padding:36px 16px; position:sticky; top:68px; align-self:flex-start; height:calc(100vh - 68px); overflow-y:auto;">
<div style="font-size:11px; font-weight:700; color:#5B6377; letter-spacing:0.06em; padding:0 12px 8px;">GETTING STARTED</div>
<sc-for list="{{ sidebarTop }}" as="s" hint-placeholder-count="3">
<div onClick="{{ s.onClick }}" style="cursor:pointer; padding:9px 12px; border-radius:9px; font-size:13.5px; font-weight:600; color:{{ s.color }}; background:{{ s.bg }}; margin-bottom:2px;">{{ s.label }}</div>
</sc-for>
<div style="font-size:11px; font-weight:700; color:#5B6377; letter-spacing:0.06em; padding:16px 12px 8px;">ENDPOINTS</div>
<sc-for list="{{ sidebarEndpoints }}" as="s" hint-placeholder-count="6">
<div onClick="{{ s.onClick }}" style="cursor:pointer; display:flex; align-items:center; gap:8px; padding:9px 12px; border-radius:9px; font-size:13px; font-weight:600; color:{{ s.color }}; background:{{ s.bg }}; margin-bottom:2px;">
<span style="font-size:10px; font-weight:800; font-family:'Roboto Mono'; color:{{ s.methodColor }};">{{ s.method }}</span>
<span>{{ s.label }}</span>
</div>
</sc-for>
</aside>
<!-- CONTENT -->
<main style="flex:1; min-width:0; padding:36px 40px 100px;">
<h1 style="margin:0 0 10px; font-size:30px; font-weight:800; letter-spacing:-0.02em;">API Reference</h1>
<p style="margin:0 0 32px; color:#8B93A7; font-size:14.5px; max-width:640px; line-height:1.6;">Programmatic access to every trader and market metric in {{ brandName }}. All endpoints return JSON over HTTPS.</p>
<!-- Auth -->
<section style="margin-bottom:40px;">
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Authentication</h2>
<p style="color:#8B93A7; font-size:13.5px; line-height:1.7; margin:0 0 14px;">Pass your API key as a bearer token on every request. Keys are available from your account dashboard after signing up.</p>
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:18px 20px; font-family:'Roboto Mono'; font-size:13px; color:#C7CCDA; overflow-x:auto;">
<pre>curl https://api.predictalytics.io/v1/markets \
-H "Authorization: Bearer YOUR_API_KEY"</pre>
</div>
</section>
<!-- Rate limits -->
<section style="margin-bottom:40px;">
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Rate limits</h2>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:14px;">
<sc-for list="{{ rateLimits }}" as="r" hint-placeholder-count="3">
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px;">
<div style="font-size:12.5px; font-weight:700; color:#8B93A7; margin-bottom:6px;">{{ r.plan }}</div>
<div style="font-size:20px; font-weight:800; font-family:'Roboto Mono';">{{ r.limit }}</div>
</div>
</sc-for>
</div>
</section>
<!-- Endpoints -->
<sc-for list="{{ endpoints }}" as="ep" hint-placeholder-count="6">
<section id="{{ ep.id }}" style="margin-bottom:40px; scroll-margin-top:80px;">
<div style="display:flex; align-items:center; gap:12px; margin-bottom:10px;">
<span style="font-size:11px; font-weight:800; font-family:'Roboto Mono'; padding:4px 9px; border-radius:6px; background:{{ ep.methodBg }}; color:{{ ep.methodColor }};">{{ ep.method }}</span>
<span style="font-family:'Roboto Mono'; font-size:14.5px; font-weight:700; color:#EDEFF5;">{{ ep.path }}</span>
</div>
<p style="color:#8B93A7; font-size:13.5px; line-height:1.6; margin:0 0 14px;">{{ ep.desc }}</p>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:14px;">
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:16px 18px;">
<div style="font-size:11px; font-weight:700; color:#5B6377; text-transform:uppercase; margin-bottom:10px;">Parameters</div>
<sc-for list="{{ ep.params }}" as="p" hint-placeholder-count="2">
<div style="display:flex; align-items:baseline; gap:8px; margin-bottom:8px; font-size:12.5px;">
<span style="font-family:'Roboto Mono'; font-weight:700; color:#5b9dff;">{{ p.name }}</span>
<span style="color:#5B6377; font-size:11.5px;">{{ p.type }}</span>
<span style="color:#8B93A7;">{{ p.desc }}</span>
</div>
</sc-for>
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px; font-family:'Roboto Mono'; font-size:12px; color:#C7CCDA; overflow-x:auto;">
<pre>{{ ep.example }}</pre>
</div>
</div>
</section>
</sc-for>
</main>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;: {&quot;width&quot;: 1440}, &quot;brandName&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;Predictalytics&quot;, &quot;tsType&quot;: &quot;string&quot;}}">
class Component extends DCLogic {
scrollTo(id) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ block: "start" });
}
renderVals() {
const brandName = this.props.brandName ?? "Predictalytics";
const endpoints = [
{ id: "get-markets", method: "GET", path: "/v1/markets", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
desc: "List all markets with current price, volume, and liquidity.",
params: [{ name: "category", type: "string", desc: "Filter by category" }, { name: "status", type: "string", desc: "active | resolved" }],
example: `{\n "id": "fed-sep-cut",\n "question": "Fed cuts rates...",\n "yesPrice": 71.4,\n "volume24h": 2840000\n}` },
{ id: "get-market", method: "GET", path: "/v1/markets/{id}", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
desc: "Get detailed data for a single market including order book and holders.",
params: [{ name: "id", type: "string", desc: "Market identifier" }],
example: `{\n "orderBook": { "bids": [...], "asks": [...] },\n "holders": [ { "trader": "0xA1...", "size": 420000 } ]\n}` },
{ id: "get-traders", method: "GET", path: "/v1/traders", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
desc: "Search and list traders with P&L, win rate, and traits.",
params: [{ name: "trait", type: "string", desc: "Bot | Arbitrage Trader | Resolution Farming" }, { name: "sort", type: "string", desc: "pnl | volume" }],
example: `{\n "handle": "quant_owl",\n "traits": ["Arbitrage Trader"],\n "winRate": 68.4\n}` },
{ id: "get-trader", method: "GET", path: "/v1/traders/{id}", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
desc: "Get a trader's full position history and connected wallets.",
params: [{ name: "id", type: "string", desc: "Trader handle or wallet" }],
example: `{\n "positions": [...],\n "connectedWallets": ["0x88Ac...2f01"]\n}` },
{ id: "post-alerts", method: "POST", path: "/v1/alerts", methodBg: "rgba(22,82,240,0.16)", methodColor: "#5b9dff",
desc: "Create an alert on a trader or market.",
params: [{ name: "target", type: "string", desc: "Trader or market id" }, { name: "condition", type: "string", desc: "price_move | new_position" }],
example: `{\n "id": "alert_881",\n "target": "arb_meridian",\n "condition": "new_position"\n}` },
{ id: "get-agent", method: "GET", path: "/v1/mcp/context", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
desc: "Enterprise only. MCP-compatible context feed for your own AI agent.",
params: [{ name: "scope", type: "string", desc: "markets | traders | both" }],
example: `{\n "mcp_version": "1.0",\n "resources": ["markets", "traders"]\n}` },
];
const sidebarTop = [
{ label: "Authentication", color: "#C7CCDA", bg: "transparent", onClick: () => this.scrollTo("auth") },
{ label: "Rate limits", color: "#C7CCDA", bg: "transparent", onClick: () => this.scrollTo("rate-limits") },
];
const sidebarEndpoints = endpoints.map((ep) => ({
label: ep.path, method: ep.method, methodColor: ep.methodColor, color: "#C7CCDA", bg: "transparent",
onClick: () => this.scrollTo(ep.id),
}));
const rateLimits = [
{ plan: "Free", limit: "20 / day" },
{ plan: "Basic", limit: "200 / day" },
{ plan: "Enterprise", limit: "2,000 / day" },
];
return { brandName, endpoints, sidebarTop, sidebarEndpoints, rateLimits };
}
}
</script>
</body>
</html>
+306
View File
@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; }
* { box-sizing: border-box; }
a { color: #5b9dff; text-decoration: none; }
a:hover { color: #8ab8ff; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
</style>
</helmet>
<div style="position:relative; min-height:100vh; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.28), transparent 60%), radial-gradient(900px 600px at 110% 10%, rgba(18,212,138,0.10), transparent 55%), #0a0d14; color:#EDEFF5; overflow-x:hidden;">
<!-- NAV -->
<header style="position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:space-between; padding:16px 48px; backdrop-filter:blur(20px); background:rgba(10,13,20,0.6); border-bottom:1px solid rgba(255,255,255,0.07);">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em;">{{ brandName }}<span style="color:#5b9dff;">.</span></div>
</div>
<nav style="display:flex; align-items:center; gap:32px;">
<a href="#features" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Features</a>
<a href="#pricing" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Pricing</a>
<a href="#faq" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">FAQ</a>
<a href="./API Docs.dc.html" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">API Docs</a>
</nav>
<div style="display:flex; align-items:center; gap:10px;">
<div onClick="{{ openLogin }}" style="cursor:pointer; padding:9px 18px; border-radius:10px; font-size:13.5px; font-weight:700; color:#EDEFF5;">Log in</div>
<div onClick="{{ openRegister }}" style="cursor:pointer; padding:9px 20px; border-radius:10px; font-size:13.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 4px 20px rgba(22,82,240,0.4);">Sign up</div>
</div>
</header>
<!-- HERO -->
<section style="padding:100px 48px 80px; max-width:1080px; margin:0 auto; text-align:center; display:flex; flex-direction:column; align-items:center; gap:22px;">
<div style="padding:6px 14px; border-radius:20px; background:rgba(22,82,240,0.12); border:1px solid rgba(22,82,240,0.3); font-size:12.5px; font-weight:700; color:#5b9dff;">Live on-chain analytics · Polymarket &amp; beyond</div>
<h1 style="margin:0; font-size:52px; font-weight:800; letter-spacing:-0.03em; line-height:1.08; max-width:760px;">See every trader and market before the crowd does</h1>
<p style="margin:0; font-size:17px; color:#8B93A7; max-width:600px; line-height:1.6;">Deep trader profiling, wallet clustering, and real-time market analytics for professional prediction market traders.</p>
<div style="display:flex; gap:14px; margin-top:8px;">
<div onClick="{{ openRegister }}" style="cursor:pointer; padding:14px 26px; border-radius:12px; font-size:14.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 30px rgba(22,82,240,0.4);">Start free</div>
<a href="#features" style="padding:14px 26px; border-radius:12px; font-size:14.5px; font-weight:700; color:#EDEFF5; border:1px solid rgba(255,255,255,0.14); background:rgba(255,255,255,0.04); white-space:nowrap;">See features</a>
</div>
<!-- product preview mock -->
<div style="margin-top:40px; width:100%; border-radius:20px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px); box-shadow:0 20px 60px rgba(0,0,0,0.4); padding:20px; display:grid; grid-template-columns:1.4fr 1fr; gap:14px; text-align:left;">
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:18px;">
<div style="font-size:12px; font-weight:700; color:#8B93A7; margin-bottom:10px;">Yes Price History</div>
<svg viewBox="0 0 500 140" style="width:100%; height:140px;">
<defs>
<linearGradient id="hfill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#12D48A" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#12D48A" stop-opacity="0"/>
</linearGradient>
</defs>
<path d="{{ heroArea }}" fill="url(#hfill)" stroke="none"></path>
<path d="{{ heroLine }}" fill="none" stroke="#12D48A" stroke-width="2.5"></path>
</svg>
</div>
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:18px; display:flex; flex-direction:column; gap:10px;">
<div style="font-size:12px; font-weight:700; color:#8B93A7;">Top Traders</div>
<sc-for list="{{ heroTraders }}" as="t" hint-placeholder-count="3">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:24px; height:24px; border-radius:7px; background:{{ t.bg }};"></div>
<div style="font-size:12.5px; font-weight:700; flex:1;">{{ t.handle }}</div>
<div style="font-size:12px; font-family:'Roboto Mono'; font-weight:700; color:#12D48A;">{{ t.pnl }}</div>
</div>
</sc-for>
</div>
</div>
</section>
<!-- FEATURES -->
<section id="features" style="padding:80px 48px; max-width:1200px; margin:0 auto;">
<div style="text-align:center; margin-bottom:48px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">FEATURES</div>
<h2 style="margin:0 0 12px; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Everything you need to read the market</h2>
<p style="margin:0; color:#8B93A7; font-size:15px;">Built for quants who need signal, not noise.</p>
</div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:18px;">
<sc-for list="{{ features }}" as="f" hint-placeholder-count="6">
<div style="padding:26px; border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px);">
<div style="width:42px; height:42px; border-radius:12px; background:{{ f.iconBg }}; margin-bottom:16px; display:flex; align-items:center; justify-content:center;">
<div style="width:16px; height:16px; border-radius:5px; background:{{ f.dotColor }};"></div>
</div>
<div style="font-size:16px; font-weight:700; margin-bottom:8px;">{{ f.title }}</div>
<div style="font-size:13.5px; color:#8B93A7; line-height:1.6;">{{ f.desc }}</div>
</div>
</sc-for>
</div>
</section>
<!-- PRICING -->
<section id="pricing" style="padding:80px 48px; max-width:1160px; margin:0 auto;">
<div style="text-align:center; margin-bottom:48px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">PRICING</div>
<h2 style="margin:0 0 12px; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Simple, usage-based plans</h2>
<p style="margin:0; color:#8B93A7; font-size:15px;">Upgrade any time as your call volume grows.</p>
</div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:20px; align-items:stretch;">
<sc-for list="{{ plans }}" as="p" hint-placeholder-count="3">
<div style="{{ p.cardStyle }}">
<sc-if value="{{ p.featured }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; top:-13px; left:50%; transform:translateX(-50%); padding:5px 14px; border-radius:20px; background:linear-gradient(135deg,#1652F0,#4c8dff); font-size:11px; font-weight:800; letter-spacing:0.04em;">MOST POPULAR</div>
</sc-if>
<div style="font-size:15px; font-weight:700; margin-bottom:6px;">{{ p.name }}</div>
<div style="display:flex; align-items:baseline; gap:4px; margin-bottom:18px;">
<div style="font-size:36px; font-weight:800; font-family:'Roboto Mono'; letter-spacing:-0.02em;">{{ p.price }}</div>
<div style="font-size:13px; color:#8B93A7;">{{ p.period }}</div>
</div>
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:22px;">
<sc-for list="{{ p.items }}" as="item" hint-placeholder-count="4">
<div style="display:flex; align-items:center; gap:9px; font-size:13.5px; color:#C7CCDA;">
<div style="width:6px; height:6px; border-radius:50%; background:#12D48A; flex:none;"></div>
<span>{{ item }}</span>
</div>
</sc-for>
</div>
<div onClick="{{ openRegister }}" style="{{ p.btnStyle }}">{{ p.btnLabel }}</div>
</div>
</sc-for>
</div>
</section>
<!-- FAQ -->
<section id="faq" style="padding:80px 48px 100px; max-width:820px; margin:0 auto;">
<div style="text-align:center; margin-bottom:44px;">
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">FAQ</div>
<h2 style="margin:0; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Common questions</h2>
</div>
<div style="display:flex; flex-direction:column; gap:10px;">
<sc-for list="{{ faqs }}" as="q" hint-placeholder-count="6">
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); overflow:hidden;">
<div onClick="{{ q.onToggle }}" style="cursor:pointer; padding:18px 22px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div style="font-size:14.5px; font-weight:700;">{{ q.question }}</div>
<div style="flex:none; width:20px; height:20px; display:flex; align-items:center; justify-content:center; font-size:16px; color:#5b9dff; transform:{{ q.iconTransform }};">+</div>
</div>
<sc-if value="{{ q.open }}" hint-placeholder-val="{{ false }}">
<div style="padding:0 22px 18px; font-size:13.5px; color:#8B93A7; line-height:1.7;">{{ q.answer }}</div>
</sc-if>
</div>
</sc-for>
</div>
</section>
<!-- FOOTER -->
<footer style="padding:32px 48px; border-top:1px solid rgba(255,255,255,0.07); display:flex; align-items:center; justify-content:space-between; color:#5B6377; font-size:12.5px;">
<div>© 2026 {{ brandName }}. All rights reserved.</div>
<div style="display:flex; gap:20px;">
<a href="./API Docs.dc.html" style="color:#5B6377;">API Docs</a>
<a href="#faq" style="color:#5B6377;">FAQ</a>
<a href="#pricing" style="color:#5B6377;">Pricing</a>
</div>
</footer>
<!-- AUTH MODAL -->
<sc-if value="{{ authOpen }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ closeAuth }}" style="position:fixed; inset:0; z-index:100; background:rgba(6,8,13,0.7); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center;">
<div onClick="{{ stopProp }}" style="width:400px; max-width:92vw; border-radius:20px; background:rgba(16,20,29,0.9); border:1px solid rgba(255,255,255,0.1); backdrop-filter:blur(30px); box-shadow:0 30px 80px rgba(0,0,0,0.5); padding:32px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px;">
<div style="font-size:19px; font-weight:800;">{{ authTitle }}</div>
<div onClick="{{ closeAuth }}" style="cursor:pointer; width:28px; height:28px; border-radius:8px; background:rgba(255,255,255,0.06); display:flex; align-items:center; justify-content:center; font-size:14px; color:#8B93A7;"></div>
</div>
<div style="display:flex; flex-direction:column; gap:14px;">
<sc-if value="{{ isRegisterMode }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Full name</label>
<input placeholder="Jane Trader" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
</sc-if>
<div style="display:flex; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Email</label>
<input placeholder="you@domain.com" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Password</label>
<input type="password" placeholder="••••••••" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
</div>
<div style="margin-top:6px; padding:13px; border-radius:11px; text-align:center; font-size:14px; font-weight:700; cursor:pointer; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 24px rgba(22,82,240,0.4);">{{ authSubmitLabel }}</div>
<div style="text-align:center; font-size:12.5px; color:#8B93A7; margin-top:4px;">
{{ authSwitchPrompt }} <span onClick="{{ switchAuthMode }}" style="color:#5b9dff; font-weight:700; cursor:pointer;">{{ authSwitchAction }}</span>
</div>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;: {&quot;width&quot;: 1440}, &quot;brandName&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;Predictalytics&quot;, &quot;tsType&quot;: &quot;string&quot;}}">
class Component extends DCLogic {
state = {
authOpen: false,
authMode: "login",
openFaq: 0,
};
openLogin = () => this.setState({ authOpen: true, authMode: "login" });
openRegister = () => this.setState({ authOpen: true, authMode: "register" });
closeAuth = () => this.setState({ authOpen: false });
switchAuthMode = () => this.setState((s) => ({ authMode: s.authMode === "login" ? "register" : "login" }));
stopProp = (e) => e.stopPropagation();
toggleFaq = (i) => this.setState((s) => ({ openFaq: s.openFaq === i ? -1 : i }));
buildPath(series, w, h, pad) {
const min = Math.min(...series);
const max = Math.max(...series);
const range = max - min || 1;
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const pts = series.map((v, i) => {
const x = pad + (i / (series.length - 1)) * innerW;
const y = pad + innerH - ((v - min) / range) * innerH;
return [x, y];
});
let line = "M" + pts[0][0].toFixed(1) + "," + pts[0][1].toFixed(1);
for (let i = 1; i < pts.length; i++) line += " L" + pts[i][0].toFixed(1) + "," + pts[i][1].toFixed(1);
const area = line + ` L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z`;
return { line, area };
}
renderVals() {
const brandName = this.props.brandName ?? "Predictalytics";
const heroSeries = [40, 44, 41, 48, 52, 50, 58, 55, 62, 66, 63, 70, 68, 74, 71.4];
const { line, area } = this.buildPath(heroSeries, 500, 140, 8);
const features = [
{ title: "Trader Tracking", desc: "Follow any wallet's full position history, P&L, and behavior patterns across every market.", iconBg: "rgba(22,82,240,0.14)", dotColor: "#5b9dff" },
{ title: "Wallet Clustering", desc: "Detect connected wallets and coordinated accounts behind a single trading strategy.", iconBg: "rgba(124,92,255,0.14)", dotColor: "#7c5cff" },
{ title: "Market Analysis", desc: "Order book depth, liquidity, and holder concentration for every active market.", iconBg: "rgba(18,212,138,0.14)", dotColor: "#12D48A" },
{ title: "Custom Alerts", desc: "Get notified the moment a tracked trader opens a position or a market shifts sharply.", iconBg: "rgba(255,157,76,0.14)", dotColor: "#ff9d4c" },
{ title: "Trader Traits", desc: "Automatic tagging — arbitrage traders, resolution farmers, bots — at a glance.", iconBg: "rgba(246,70,93,0.14)", dotColor: "#F6465D" },
{ title: "API Access", desc: "Pull every metric programmatically into your own models and dashboards.", iconBg: "rgba(22,82,240,0.14)", dotColor: "#5b9dff" },
];
const baseCard = "position:relative; padding:30px 26px; border-radius:20px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); display:flex; flex-direction:column;";
const featuredCard = "position:relative; padding:30px 26px; border-radius:20px; background:rgba(22,82,240,0.08); border:1px solid rgba(22,82,240,0.35); backdrop-filter:blur(24px); display:flex; flex-direction:column; box-shadow:0 20px 50px rgba(22,82,240,0.2);";
const btnPrimary = "cursor:pointer; padding:12px; border-radius:11px; text-align:center; font-size:13.5px; font-weight:700; margin-top:auto; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 24px rgba(22,82,240,0.4);";
const btnSecondary = "cursor:pointer; padding:12px; border-radius:11px; text-align:center; font-size:13.5px; font-weight:700; margin-top:auto; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.12); color:#EDEFF5;";
const plans = [
{ name: "Free", price: "$0", period: "/ month", featured: false, cardStyle: baseCard, btnStyle: btnSecondary, btnLabel: "Get started",
items: ["20 API calls / day", "Restricted web access", "Market overview only", "Community support"] },
{ name: "Pro", price: "$99", period: "/ month", featured: true, cardStyle: featuredCard, btnStyle: btnPrimary, btnLabel: "Start Pro",
items: ["2,000 API calls / day", "Enhanced AI trader analytics", "MCP access for your AI agent", "Priority support"] },
{ name: "Enterprise", price: "Contact us", period: "", featured: false, cardStyle: baseCard, btnStyle: btnSecondary, btnLabel: "Contact sales",
items: ["10,000+ API calls / day", "Ready to use AI analytics with your individual prompts", "MCP access for your AI agent", "Priority support"] },
];
const faqDefs = [
{ q: "What data sources power your analytics?", a: "We index on-chain activity directly from Polymarket and related prediction market contracts in real time, plus supplementary off-chain resolution data." },
{ q: "How do you identify traders as bots or arbitrageurs?", a: "We apply behavioral heuristics — trade timing, position sizing, cross-market correlation — to automatically tag wallets with traits like Bot, Arbitrage Trader, or Resolution Farming." },
{ q: "Can I track a specific wallet or trader?", a: "Yes. Search any wallet address or handle to pull up its full position history, P&L, and connected wallets." },
{ q: "What's included in the API?", a: "Market and trader endpoints covering prices, order books, holders, and trader metrics. Call limits scale with your plan, from 20 to 2,000 requests per day." },
{ q: "Do you support alerts?", a: "Yes, set alerts on tracked traders or markets and get notified the moment a position opens or a price moves sharply." },
{ q: "Can I cancel or change my plan anytime?", a: "Yes, upgrade, downgrade, or cancel at any time from your account settings — changes apply at the next billing cycle." },
];
const faqs = faqDefs.map((f, i) => ({
question: f.q,
answer: f.a,
open: this.state.openFaq === i,
iconTransform: this.state.openFaq === i ? "rotate(45deg)" : "rotate(0deg)",
onToggle: () => this.toggleFaq(i),
}));
const heroTraders = [
{ handle: "quant_owl", pnl: "+$216K", bg: "linear-gradient(135deg,#1652F0,#4c8dff)" },
{ handle: "arb_meridian", pnl: "+$454K", bg: "linear-gradient(135deg,#12D48A,#0a8f5f)" },
{ handle: "resolvr", pnl: "+$105K", bg: "linear-gradient(135deg,#7c5cff,#4c8dff)" },
];
return {
brandName,
heroLine: line,
heroArea: area,
heroTraders,
features,
plans,
faqs,
authOpen: this.state.authOpen,
isRegisterMode: this.state.authMode === "register",
authTitle: this.state.authMode === "login" ? "Log in" : "Create your account",
authSubmitLabel: this.state.authMode === "login" ? "Log in" : "Create account",
authSwitchPrompt: this.state.authMode === "login" ? "Don't have an account?" : "Already have an account?",
authSwitchAction: this.state.authMode === "login" ? "Sign up" : "Log in",
openLogin: this.openLogin,
openRegister: this.openRegister,
closeAuth: this.closeAuth,
switchAuthMode: this.switchAuthMode,
stopProp: this.stopProp,
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,875 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; overflow-x: auto; }
* { box-sizing: border-box; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
a { color: #5b9dff; text-decoration: none; }
a:hover { color: #8ab8ff; }
table { border-collapse: collapse; }
.dual-range { -webkit-appearance: none; appearance: none; background: transparent; pointer-events: none; margin: 0; position: absolute; top: 0; left: 0; width: 100%; height: 20px; }
.dual-range::-webkit-slider-thumb { -webkit-appearance: none; pointer-events: auto; width: 15px; height: 15px; border-radius: 50%; background: #5b9dff; border: 2px solid #0a0d14; cursor: pointer; box-shadow: 0 0 6px rgba(91,157,255,0.7); margin-top: 0; }
.dual-range::-moz-range-thumb { pointer-events: auto; width: 15px; height: 15px; border-radius: 50%; background: #5b9dff; border: 2px solid #0a0d14; cursor: pointer; box-shadow: 0 0 6px rgba(91,157,255,0.7); }
.dual-range::-webkit-slider-runnable-track { background: transparent; }
.dual-range::-moz-range-track { background: transparent; }
</style>
</helmet>
<div style="position:relative; min-height:100vh; min-width:1440px; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.28), transparent 60%), radial-gradient(900px 600px at 110% 10%, rgba(18,212,138,0.10), transparent 55%), #0a0d14; color:#EDEFF5;">
<!-- decorative glow layer -->
<div style="position:fixed; inset:0; pointer-events:none; background: radial-gradient(600px 400px at 80% 85%, rgba(76,141,255,0.10), transparent 60%); z-index:0;"></div>
<div style="position:relative; z-index:1; display:flex; min-height:100vh;">
<!-- SIDEBAR -->
<aside style="width:236px; flex:none; padding:24px 16px; display:flex; flex-direction:column; gap:28px; border-right:1px solid rgba(255,255,255,0.07); background:rgba(255,255,255,0.015);">
<div style="display:flex; align-items:center; gap:10px; padding:4px 8px;">
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em;">Lucid<span style="color:#5b9dff;">.</span></div>
</div>
<nav style="display:flex; flex-direction:column; gap:2px;">
<div style="font:600 10px/1 'Manrope'; letter-spacing:0.08em; color:#5B6377; padding:0 12px 8px;">NAVIGATION</div>
<sc-for list="{{ navItems }}" as="item" hint-placeholder-count="4">
<div onClick="{{ item.onClick }}" style="{{ item.style }}">
<div style="{{ item.dotStyle }}"></div>
<span>{{ item.label }}</span>
</div>
</sc-for>
</nav>
<div style="margin-top:auto; display:flex; flex-direction:column; gap:10px;">
<div style="padding:14px; border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(20px);">
<div style="font-size:11px; color:#8B93A7; margin-bottom:6px;">Data feed</div>
<div style="display:flex; align-items:center; gap:7px;">
<div style="width:7px; height:7px; border-radius:50%; background:#12D48A; box-shadow:0 0 8px #12D48A;"></div>
<div style="font-size:12.5px; font-weight:600;">Live · on-chain</div>
</div>
</div>
<div style="display:flex; align-items:center; gap:10px; padding:8px 10px;">
<div style="width:30px; height:30px; border-radius:50%; background:linear-gradient(135deg,#2a2f3d,#1a1e28); border:1px solid rgba(255,255,255,0.1);"></div>
<div>
<div style="font-size:12.5px; font-weight:700;">Analyst</div>
<div style="font-size:11px; color:#5B6377;">Pro workspace</div>
</div>
</div>
</div>
</aside>
<!-- MAIN -->
<main style="flex:1; min-width:0; padding:22px 32px 60px;">
<!-- TOPBAR -->
<div style="display:flex; align-items:center; gap:16px; margin-bottom:22px;">
<div style="flex:1; position:relative; max-width:420px;">
<input placeholder="Search markets, traders, wallets…" style="width:100%; padding:11px 14px 11px 38px; border-radius:11px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.09); color:#EDEFF5; font-size:13px; font-family:'Manrope'; outline:none;" />
<div style="position:absolute; left:13px; top:50%; transform:translateY(-50%); width:14px; height:14px; border-radius:50%; border:2px solid #5B6377;"></div>
</div>
<div style="margin-left:auto; display:flex; align-items:center; gap:10px;">
<div style="padding:8px 14px; border-radius:10px; background:rgba(18,212,138,0.10); border:1px solid rgba(18,212,138,0.25); font-size:12px; font-weight:700; color:#12D48A; font-family:'Roboto Mono';">24H VOL {{ headerVolume }}</div>
<div style="width:38px; height:38px; border-radius:11px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.09); display:flex; align-items:center; justify-content:center;">
<div style="width:8px; height:8px; border-radius:50%; background:#F6465D;"></div>
</div>
</div>
</div>
<!-- breadcrumb -->
<div style="display:flex; align-items:center; gap:8px; font-size:12.5px; margin-bottom:18px; font-weight:600;">
<sc-for list="{{ breadcrumbItems }}" as="crumb" hint-placeholder-count="1">
<span onClick="{{ crumb.onClick }}" style="{{ crumb.style }}">{{ crumb.label }}</span>
<sc-if value="{{ crumb.sep }}" hint-placeholder-val="{{ false }}">
<span style="color:#3a4152;"></span>
</sc-if>
</sc-for>
</div>
<sc-if value="{{ isOverview }}" hint-placeholder-val="{{ true }}">
<!-- ================= OVERVIEW ================= -->
<div>
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:20px;">
<div>
<h1 style="margin:0 0 4px; font-size:26px; font-weight:800; letter-spacing:-0.02em;">Markets Overview</h1>
<div style="font-size:13px; color:#8B93A7;">Real-time analytics across all active prediction markets</div>
</div>
</div>
<!-- stat cards -->
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:20px;">
<sc-for list="{{ globalStats }}" as="stat" hint-placeholder-count="4">
<div style="padding:18px 20px; border-radius:16px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px); box-shadow:0 8px 30px rgba(0,0,0,0.25);">
<div style="font-size:12px; color:#8B93A7; font-weight:600; margin-bottom:10px;">{{ stat.label }}</div>
<div style="font-size:24px; font-weight:800; font-family:'Roboto Mono'; letter-spacing:-0.02em;">{{ stat.value }}</div>
<div style="font-size:12px; font-weight:700; margin-top:6px; font-family:'Roboto Mono'; color:{{ stat.deltaColor }};">{{ stat.delta }}</div>
</div>
</sc-for>
</div>
<div style="display:grid; grid-template-columns:1.7fr 1fr; gap:16px; align-items:start;">
<!-- markets table -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:18px 22px 14px; font-size:15px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">All Markets</div>
<table style="width:100%; font-size:13px;">
<thead>
<tr style="color:#5B6377; font-size:11px; text-transform:uppercase; letter-spacing:0.04em;">
<td style="padding:10px 22px;">Market</td>
<td style="padding:10px 10px; text-align:right;">Yes %</td>
<td style="padding:10px 10px; text-align:right;">24h</td>
<td style="padding:10px 10px; text-align:right;">Volume</td>
<td style="padding:10px 22px; text-align:right;">Traders</td>
</tr>
</thead>
<tbody>
<sc-for list="{{ marketRows }}" as="m" hint-placeholder-count="6">
<tr onClick="{{ m.onClick }}" style="cursor:pointer; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
<td style="padding:13px 22px;">
<div style="font-weight:700; font-size:13.5px; margin-bottom:3px;">{{ m.question }}</div>
<div style="font-size:11.5px; color:#5B6377;">{{ m.category }} · {{ m.statusLabel }}</div>
</td>
<td style="padding:13px 10px; text-align:right; font-family:'Roboto Mono'; font-weight:700;">{{ m.yesPriceLabel }}</td>
<td style="padding:13px 10px; text-align:right; font-family:'Roboto Mono'; font-weight:700; color:{{ m.changeColor }};">{{ m.changeLabel }}</td>
<td style="padding:13px 10px; text-align:right; font-family:'Roboto Mono'; color:#C7CCDA;">{{ m.volumeLabel }}</td>
<td style="padding:13px 22px; text-align:right; font-family:'Roboto Mono'; color:#8B93A7;">{{ m.tradersLabel }}</td>
</tr>
</sc-for>
</tbody>
</table>
</div>
<!-- leaderboard -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:18px 22px 14px; font-size:15px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Top Traders</div>
<div style="display:flex; flex-direction:column;">
<sc-for list="{{ leaderboard }}" as="t" hint-placeholder-count="6">
<div onClick="{{ t.onClick }}" style="cursor:pointer; display:flex; align-items:center; gap:12px; padding:12px 22px; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
<div style="width:30px; height:30px; border-radius:9px; flex:none; background:{{ t.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:800;">{{ t.initial }}</div>
<div style="min-width:0; flex:1;">
<div style="font-weight:700; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ t.handle }}</div>
<div style="font-size:11px; color:#5B6377;">{{ t.trait }}</div>
</div>
<div style="text-align:right; flex:none;">
<div style="font-family:'Roboto Mono'; font-weight:700; font-size:13px; color:#12D48A;">{{ t.pnlLabel }}</div>
<div style="font-size:10.5px; color:#5B6377;">P&amp;L</div>
</div>
</div>
</sc-for>
</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isMarket }}" hint-placeholder-val="{{ false }}">
<!-- ================= MARKET DETAIL ================= -->
<div>
<div style="display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:20px; gap:20px;">
<div>
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;">
<div style="font-size:11px; font-weight:700; padding:4px 10px; border-radius:7px; background:rgba(22,82,240,0.14); border:1px solid rgba(22,82,240,0.3); color:#5b9dff;">{{ marketDetail.category }}</div>
<div style="font-size:11px; font-weight:700; padding:4px 10px; border-radius:7px; background:{{ marketDetail.statusBg }}; color:{{ marketDetail.statusColor }};">{{ marketDetail.status }}</div>
</div>
<h1 style="margin:0 0 6px; font-size:24px; font-weight:800; letter-spacing:-0.02em; max-width:640px;">{{ marketDetail.question }}</h1>
<div style="font-size:12.5px; color:#8B93A7;">Resolves {{ marketDetail.resolves }}</div>
</div>
<div style="text-align:right; flex:none;">
<div style="font-size:34px; font-weight:800; font-family:'Roboto Mono'; color:#12D48A;">{{ marketDetail.yesPriceBig }}</div>
<div style="font-size:12px; color:#8B93A7; font-weight:600;">YES probability</div>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:18px;">
<sc-for list="{{ marketStats }}" as="stat" hint-placeholder-count="4">
<div style="padding:16px 18px; border-radius:14px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px);">
<div style="font-size:11.5px; color:#8B93A7; font-weight:600; margin-bottom:8px;">{{ stat.label }}</div>
<div style="font-size:19px; font-weight:800; font-family:'Roboto Mono';">{{ stat.value }}</div>
</div>
</sc-for>
</div>
<div style="display:grid; grid-template-columns:1.6fr 1fr; gap:16px; margin-bottom:16px; align-items:start;">
<!-- price chart -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:20px 22px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:14px;">
<div style="font-size:14px; font-weight:700;">Yes Price History</div>
<div style="display:flex; gap:6px;">
<div style="font-size:11px; padding:5px 10px; border-radius:7px; background:rgba(22,82,240,0.18); color:#5b9dff; font-weight:700;">30D</div>
</div>
</div>
<svg viewBox="0 0 600 220" style="width:100%; height:220px; overflow:visible;">
<defs>
<linearGradient id="fillGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#12D48A" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#12D48A" stop-opacity="0"/>
</linearGradient>
<filter id="glow"><feGaussianBlur stdDeviation="3.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
</defs>
<path d="{{ marketDetail.areaPath }}" fill="url(#fillGrad)" stroke="none"></path>
<path d="{{ marketDetail.linePath }}" fill="none" stroke="#12D48A" stroke-width="2.5" filter="url(#glow)"></path>
</svg>
</div>
<!-- order book -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:18px 20px;">
<div style="font-size:14px; font-weight:700; margin-bottom:12px;">Order Book Depth</div>
<div style="font-size:10.5px; color:#5B6377; font-weight:700; text-transform:uppercase; margin-bottom:6px;">Asks</div>
<sc-for list="{{ marketDetail.asks }}" as="row" hint-placeholder-count="5">
<div style="position:relative; margin-bottom:3px; height:20px;">
<div style="position:absolute; right:0; top:0; bottom:0; width:{{ row.barWidth }}; background:rgba(246,70,93,0.16); border-radius:4px;"></div>
<div style="position:relative; display:flex; justify-content:space-between; padding:0 8px; font-size:11.5px; font-family:'Roboto Mono'; line-height:20px;">
<span style="color:#F6465D; font-weight:700;">{{ row.priceLabel }}</span>
<span style="color:#8B93A7;">{{ row.sizeLabel }}</span>
</div>
</div>
</sc-for>
<div style="height:1px; background:rgba(255,255,255,0.08); margin:8px 0;"></div>
<div style="font-size:10.5px; color:#5B6377; font-weight:700; text-transform:uppercase; margin-bottom:6px;">Bids</div>
<sc-for list="{{ marketDetail.bids }}" as="row" hint-placeholder-count="5">
<div style="position:relative; margin-bottom:3px; height:20px;">
<div style="position:absolute; right:0; top:0; bottom:0; width:{{ row.barWidth }}; background:rgba(18,212,138,0.16); border-radius:4px;"></div>
<div style="position:relative; display:flex; justify-content:space-between; padding:0 8px; font-size:11.5px; font-family:'Roboto Mono'; line-height:20px;">
<span style="color:#12D48A; font-weight:700;">{{ row.priceLabel }}</span>
<span style="color:#8B93A7;">{{ row.sizeLabel }}</span>
</div>
</div>
</sc-for>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<!-- biggest holders -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:16px 20px 12px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Biggest Holders</div>
<table style="width:100%; font-size:12.5px;">
<tbody>
<sc-for list="{{ marketDetail.holders }}" as="h" hint-placeholder-count="5">
<tr onClick="{{ h.onClick }}" style="cursor:pointer; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
<td style="padding:11px 20px; font-family:'Roboto Mono'; font-weight:700;">{{ h.trader }}</td>
<td style="padding:11px 6px; text-align:center;"><span style="padding:3px 9px; border-radius:6px; font-size:11px; font-weight:700; background:{{ h.sideBg }}; color:{{ h.sideColor }};">{{ h.side }}</span></td>
<td style="padding:11px 20px; text-align:right; font-family:'Roboto Mono'; color:#C7CCDA;">{{ h.valueLabel }}</td>
</tr>
</sc-for>
</tbody>
</table>
</div>
<!-- top traders in market -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:16px 20px 12px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Top Traders Here</div>
<div style="display:flex; flex-direction:column;">
<sc-for list="{{ marketDetail.topTraders }}" as="t" hint-placeholder-count="4">
<div onClick="{{ t.onClick }}" style="cursor:pointer; display:flex; align-items:center; gap:12px; padding:12px 20px; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
<div style="width:28px; height:28px; border-radius:8px; flex:none; background:{{ t.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:800;">{{ t.initial }}</div>
<div style="font-weight:700; font-size:13px; flex:1;">{{ t.handle }}</div>
<div style="font-size:11px; color:#8B93A7;">{{ t.trait }}</div>
</div>
</sc-for>
</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isTraderSearch }}" hint-placeholder-val="{{ false }}">
<!-- ================= TRADER SEARCH ================= -->
<div>
<div style="margin-bottom:20px;">
<h1 style="margin:0 0 4px; font-size:26px; font-weight:800; letter-spacing:-0.02em;">Trader Search</h1>
<div style="font-size:13px; color:#8B93A7;">Filter by traits and metric ranges to find the traders you care about</div>
</div>
<div style="display:grid; grid-template-columns:300px 1fr; gap:16px; align-items:start;">
<!-- filter panel -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:20px; display:flex; flex-direction:column; gap:22px; position:sticky; top:22px;">
<div>
<input value="{{ searchText }}" onChange="{{ onSearchTextChange }}" placeholder="Handle or wallet…" style="width:100%; padding:10px 13px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13px; font-family:'Manrope'; outline:none;" />
</div>
<div>
<div style="font-size:12px; font-weight:700; color:#C7CCDA; margin-bottom:10px;">Traits</div>
<div style="display:flex; flex-wrap:wrap; gap:7px;">
<sc-for list="{{ traitFilterChips }}" as="chip" hint-placeholder-count="5">
<div onClick="{{ chip.onClick }}" style="{{ chip.style }}">{{ chip.label }}</div>
</sc-for>
</div>
</div>
<div>
<div style="display:flex; justify-content:space-between; margin-bottom:10px;">
<span style="font-size:12px; font-weight:700; color:#C7CCDA;">Win Rate</span>
<span style="font-size:11px; font-family:'Roboto Mono'; color:#8B93A7;">{{ winRateSlider.minLabel }} {{ winRateSlider.maxLabel }}</span>
</div>
<div style="position:relative; height:20px; margin-bottom:10px;">
<div style="position:absolute; top:8px; left:0; right:0; height:4px; border-radius:2px; background:rgba(255,255,255,0.1);"></div>
<div style="position:absolute; top:8px; height:4px; border-radius:2px; background:#5b9dff; left:{{ winRateSlider.fillLeft }}; right:{{ winRateSlider.fillRight }};"></div>
<input class="dual-range" type="range" min="{{ winRateSlider.lo }}" max="{{ winRateSlider.hi }}" value="{{ winRateSlider.minVal }}" onInput="{{ winRateSlider.onMinInput }}" />
<input class="dual-range" type="range" min="{{ winRateSlider.lo }}" max="{{ winRateSlider.hi }}" value="{{ winRateSlider.maxVal }}" onInput="{{ winRateSlider.onMaxInput }}" />
</div>
<div style="display:flex; align-items:center; gap:8px;">
<input value="{{ winRateSlider.minVal }}" onBlur="{{ winRateSlider.onMinTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
<span style="color:#5B6377; font-size:11px;"></span>
<input value="{{ winRateSlider.maxVal }}" onBlur="{{ winRateSlider.onMaxTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
</div>
</div>
<div>
<div style="display:flex; justify-content:space-between; margin-bottom:10px;">
<span style="font-size:12px; font-weight:700; color:#C7CCDA;">Volume</span>
<span style="font-size:11px; font-family:'Roboto Mono'; color:#8B93A7;">{{ volumeSlider.minLabel }} {{ volumeSlider.maxLabel }}</span>
</div>
<div style="position:relative; height:20px; margin-bottom:10px;">
<div style="position:absolute; top:8px; left:0; right:0; height:4px; border-radius:2px; background:rgba(255,255,255,0.1);"></div>
<div style="position:absolute; top:8px; height:4px; border-radius:2px; background:#5b9dff; left:{{ volumeSlider.fillLeft }}; right:{{ volumeSlider.fillRight }};"></div>
<input class="dual-range" type="range" min="{{ volumeSlider.lo }}" max="{{ volumeSlider.hi }}" step="500000" value="{{ volumeSlider.minVal }}" onInput="{{ volumeSlider.onMinInput }}" />
<input class="dual-range" type="range" min="{{ volumeSlider.lo }}" max="{{ volumeSlider.hi }}" step="500000" value="{{ volumeSlider.maxVal }}" onInput="{{ volumeSlider.onMaxInput }}" />
</div>
<div style="display:flex; align-items:center; gap:8px;">
<input value="{{ volumeSlider.minVal }}" onBlur="{{ volumeSlider.onMinTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
<span style="color:#5B6377; font-size:11px;"></span>
<input value="{{ volumeSlider.maxVal }}" onBlur="{{ volumeSlider.onMaxTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
</div>
</div>
<div>
<div style="display:flex; justify-content:space-between; margin-bottom:10px;">
<span style="font-size:12px; font-weight:700; color:#C7CCDA;">Total P&amp;L</span>
<span style="font-size:11px; font-family:'Roboto Mono'; color:#8B93A7;">{{ pnlSlider.minLabel }} {{ pnlSlider.maxLabel }}</span>
</div>
<div style="position:relative; height:20px; margin-bottom:10px;">
<div style="position:absolute; top:8px; left:0; right:0; height:4px; border-radius:2px; background:rgba(255,255,255,0.1);"></div>
<div style="position:absolute; top:8px; height:4px; border-radius:2px; background:#5b9dff; left:{{ pnlSlider.fillLeft }}; right:{{ pnlSlider.fillRight }};"></div>
<input class="dual-range" type="range" min="{{ pnlSlider.lo }}" max="{{ pnlSlider.hi }}" step="10000" value="{{ pnlSlider.minVal }}" onInput="{{ pnlSlider.onMinInput }}" />
<input class="dual-range" type="range" min="{{ pnlSlider.lo }}" max="{{ pnlSlider.hi }}" step="10000" value="{{ pnlSlider.maxVal }}" onInput="{{ pnlSlider.onMaxInput }}" />
</div>
<div style="display:flex; align-items:center; gap:8px;">
<input value="{{ pnlSlider.minVal }}" onBlur="{{ pnlSlider.onMinTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
<span style="color:#5B6377; font-size:11px;"></span>
<input value="{{ pnlSlider.maxVal }}" onBlur="{{ pnlSlider.onMaxTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
</div>
</div>
<div onClick="{{ onResetFilters }}" style="cursor:pointer; text-align:center; padding:10px; border-radius:10px; font-size:12.5px; font-weight:700; color:#8B93A7; border:1px solid rgba(255,255,255,0.1);">Reset filters</div>
</div>
<!-- results -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:16px 22px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06); display:flex; justify-content:space-between;">
<span>Results</span><span style="color:#5B6377; font-weight:600;">{{ searchResultsCount }} traders</span>
</div>
<table style="width:100%; font-size:13px;">
<thead>
<tr style="color:#5B6377; font-size:11px; text-transform:uppercase; letter-spacing:0.04em;">
<td style="padding:10px 22px;">Trader</td>
<td style="padding:10px 10px;">Traits</td>
<td style="padding:10px 10px; text-align:right;">Win Rate</td>
<td style="padding:10px 10px; text-align:right;">Volume</td>
<td style="padding:10px 22px; text-align:right;">P&amp;L</td>
</tr>
</thead>
<tbody>
<sc-for list="{{ searchResults }}" as="t" hint-placeholder-count="6">
<tr onClick="{{ t.onClick }}" style="cursor:pointer; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
<td style="padding:12px 22px;">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:26px; height:26px; border-radius:8px; flex:none; background:{{ t.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:800;">{{ t.initial }}</div>
<span style="font-weight:700;">{{ t.handle }}</span>
</div>
</td>
<td style="padding:12px 10px;">
<div style="display:flex; gap:5px; flex-wrap:wrap;">
<sc-for list="{{ t.traitChips }}" as="chip" hint-placeholder-count="2">
<div style="font-size:10.5px; font-weight:700; padding:3px 8px; border-radius:6px; background:{{ chip.bg }}; color:{{ chip.color }};">{{ chip.label }}</div>
</sc-for>
</div>
</td>
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; font-weight:700;">{{ t.winRateLabel }}</td>
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; color:#C7CCDA;">{{ t.volumeLabel }}</td>
<td style="padding:12px 22px; text-align:right; font-family:'Roboto Mono'; font-weight:700; color:{{ t.pnlColor }};">{{ t.pnlLabel }}</td>
</tr>
</sc-for>
</tbody>
</table>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isTrader }}" hint-placeholder-val="{{ false }}">
<!-- ================= TRADER DETAIL ================= -->
<div>
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; gap:20px;">
<div style="display:flex; align-items:center; gap:16px;">
<div style="width:56px; height:56px; border-radius:16px; background:{{ traderDetail.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:20px; font-weight:800; box-shadow:0 0 24px rgba(22,82,240,0.3);">{{ traderDetail.initial }}</div>
<div>
<div style="display:flex; align-items:center; gap:10px; margin-bottom:4px;">
<h1 style="margin:0; font-size:22px; font-weight:800; letter-spacing:-0.02em;">{{ traderDetail.handle }}</h1>
<div style="font-size:12px; font-family:'Roboto Mono'; color:#5B6377;">{{ traderDetail.wallet }}</div>
</div>
<div style="display:flex; gap:6px;">
<sc-for list="{{ traderDetail.traitChips }}" as="chip" hint-placeholder-count="2">
<div style="font-size:11px; font-weight:700; padding:4px 10px; border-radius:7px; background:{{ chip.bg }}; color:{{ chip.color }}; border:1px solid {{ chip.border }};">{{ chip.label }}</div>
</sc-for>
</div>
</div>
</div>
<div style="text-align:right;">
<div style="font-size:11.5px; color:#8B93A7; margin-bottom:2px;">Member since {{ traderDetail.joined }}</div>
<div style="font-size:11.5px; color:#8B93A7;">{{ traderDetail.activePositionsLabel }} active positions</div>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(5,1fr); gap:16px; margin-bottom:18px;">
<sc-for list="{{ traderStats }}" as="stat" hint-placeholder-count="5">
<div style="padding:16px 18px; border-radius:14px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px);">
<div style="font-size:11px; color:#8B93A7; font-weight:600; margin-bottom:8px;">{{ stat.label }}</div>
<div style="font-size:18px; font-weight:800; font-family:'Roboto Mono'; color:{{ stat.color }};">{{ stat.value }}</div>
</div>
</sc-for>
</div>
<div style="display:grid; grid-template-columns:1.6fr 1fr; gap:16px; margin-bottom:16px; align-items:start;">
<!-- pnl chart -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:20px 22px;">
<div style="font-size:14px; font-weight:700; margin-bottom:14px;">P&amp;L Over Time</div>
<svg viewBox="0 0 600 200" style="width:100%; height:200px; overflow:visible;">
<defs>
<linearGradient id="pnlFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#5b9dff" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#5b9dff" stop-opacity="0"/>
</linearGradient>
<filter id="glow2"><feGaussianBlur stdDeviation="3.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
</defs>
<path d="{{ traderDetail.areaPath }}" fill="url(#pnlFill)" stroke="none"></path>
<path d="{{ traderDetail.linePath }}" fill="none" stroke="#5b9dff" stroke-width="2.5" filter="url(#glow2)"></path>
</svg>
</div>
<!-- network -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:18px 20px;">
<div style="font-size:14px; font-weight:700; margin-bottom:10px;">Connected Wallets</div>
<sc-if value="{{ traderDetail.hasConnections }}" hint-placeholder-val="{{ true }}">
<sc-for list="{{ traderDetail.connections }}" as="w" hint-placeholder-count="2">
<div style="display:flex; align-items:center; gap:10px; padding:10px 0; border-top:1px solid rgba(255,255,255,0.05);">
<div style="width:8px; height:8px; border-radius:50%; background:#F6465D; box-shadow:0 0 6px #F6465D;"></div>
<div style="font-family:'Roboto Mono'; font-size:12.5px; color:#C7CCDA;">{{ w }}</div>
</div>
</sc-for>
</sc-if>
<sc-if value="{{ traderDetail.noConnections }}" hint-placeholder-val="{{ false }}">
<div style="font-size:12.5px; color:#5B6377; padding:10px 0;">No linked wallets detected.</div>
</sc-if>
</div>
</div>
<!-- positions table -->
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
<div style="padding:16px 22px 12px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Positions</div>
<table style="width:100%; font-size:12.5px;">
<thead>
<tr style="color:#5B6377; font-size:11px; text-transform:uppercase; letter-spacing:0.04em;">
<td style="padding:10px 22px;">Market</td>
<td style="padding:10px 10px; text-align:center;">Side</td>
<td style="padding:10px 10px; text-align:right;">Size</td>
<td style="padding:10px 10px; text-align:right;">Entry</td>
<td style="padding:10px 10px; text-align:right;">Current</td>
<td style="padding:10px 22px; text-align:right;">P&amp;L</td>
</tr>
</thead>
<tbody>
<sc-for list="{{ traderDetail.positions }}" as="p" hint-placeholder-count="4">
<tr style="border-top:1px solid rgba(255,255,255,0.05);">
<td style="padding:12px 22px; font-weight:600;">{{ p.market }}</td>
<td style="padding:12px 10px; text-align:center;"><span style="padding:3px 9px; border-radius:6px; font-size:11px; font-weight:700; background:{{ p.sideBg }}; color:{{ p.sideColor }};">{{ p.side }}</span></td>
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono';">{{ p.sizeLabel }}</td>
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; color:#8B93A7;">{{ p.entryLabel }}</td>
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; color:#8B93A7;">{{ p.currentLabel }}</td>
<td style="padding:12px 22px; text-align:right; font-family:'Roboto Mono'; font-weight:700; color:{{ p.pnlColor }};">{{ p.pnlLabel }}</td>
</tr>
</sc-for>
</tbody>
</table>
</div>
</div>
</sc-if>
</main>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
state = {
view: "overview",
selectedMarketId: null,
selectedTraderId: null,
markets: null,
traders: null,
searchText: "",
filterTraits: [],
winRateMin: 0,
winRateMax: 100,
volumeMin: 0,
volumeMax: 25000000,
pnlMin: -50000,
pnlMax: 500000,
};
componentDidMount() {
import("./market-data.js").then((m) => {
this.setState({ markets: m.MARKETS, traders: m.TRADERS });
});
}
goOverview = () => this.setState({ view: "overview" });
goMarket = (id) => this.setState({ view: "market", selectedMarketId: id });
goTrader = (id) => this.setState({ view: "trader", selectedTraderId: id });
goTraderSearch = () => this.setState({ view: "traderSearch" });
setSearchText = (e) => this.setState({ searchText: e.target.value });
toggleTrait = (trait) => this.setState((s) => ({
filterTraits: s.filterTraits.includes(trait) ? s.filterTraits.filter((t) => t !== trait) : [...s.filterTraits, trait],
}));
setRange = (key) => (e) => this.setState({ [key]: Number(e.target.value) });
resetFilters = () => this.setState({
searchText: "", filterTraits: [], winRateMin: 0, winRateMax: 100,
volumeMin: 0, volumeMax: 25000000, pnlMin: -50000, pnlMax: 500000,
});
parseClamp(raw, lo, hi) {
const n = Number(String(raw).replace(/[^0-9.\-]/g, ""));
if (Number.isNaN(n)) return null;
return Math.max(lo, Math.min(hi, n));
}
onMinTextBlur = (key, lo, hi) => (e) => {
const n = this.parseClamp(e.target.value, lo, hi);
this.setState({ [key]: n === null ? lo : n });
};
onMaxTextBlur = (key, lo, hi) => (e) => {
const n = this.parseClamp(e.target.value, lo, hi);
this.setState({ [key]: n === null ? hi : n });
};
fmtMoney(n) {
if (n >= 1000000) return "$" + (n / 1000000).toFixed(2) + "M";
if (n >= 1000) return "$" + (n / 1000).toFixed(1) + "K";
return "$" + Math.round(n);
}
fmtNum(n) {
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(Math.round(n));
}
fmtSigned(n, suffix) {
const s = n >= 0 ? "+" : "";
return s + n.toFixed(1) + (suffix || "");
}
traitStyle(trait) {
const map = {
"Arbitrage Bot": { bg: "rgba(22,82,240,0.14)", color: "#5b9dff", border: "rgba(22,82,240,0.3)" },
"Resolution Farming": { bg: "rgba(246,70,93,0.12)", color: "#F6465D", border: "rgba(246,70,93,0.28)" },
"Trading Bot": { bg: "rgba(255,255,255,0.08)", color: "#C7CCDA", border: "rgba(255,255,255,0.15)" },
"Human": { bg: "rgba(124,92,255,0.12)", color: "#a78bff", border: "rgba(124,92,255,0.28)" },
"High Volume": { bg: "rgba(18,212,138,0.12)", color: "#12D48A", border: "rgba(18,212,138,0.28)" },
};
return map[trait] || { bg: "rgba(255,255,255,0.08)", color: "#C7CCDA", border: "rgba(255,255,255,0.15)" };
}
ALL_TRAITS = ["Human", "Trading Bot", "Arbitrage Bot", "Resolution Farming", "High Volume"];
avatarBg(seed) {
const palettes = [
"linear-gradient(135deg,#1652F0,#4c8dff)",
"linear-gradient(135deg,#12D48A,#0a8f5f)",
"linear-gradient(135deg,#F6465D,#a8283a)",
"linear-gradient(135deg,#7c5cff,#4c8dff)",
"linear-gradient(135deg,#ff9d4c,#F6465D)",
];
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
return palettes[h % palettes.length];
}
buildDualSlider(minKey, maxKey, lo, hi, fmt) {
const minVal = this.state[minKey];
const maxVal = this.state[maxKey];
const pctMin = ((minVal - lo) / (hi - lo)) * 100;
const pctMax = ((maxVal - lo) / (hi - lo)) * 100;
return {
lo, hi, minVal, maxVal,
minLabel: fmt(minVal), maxLabel: fmt(maxVal),
fillLeft: pctMin.toFixed(1) + "%",
fillRight: (100 - pctMax).toFixed(1) + "%",
onMinInput: (e) => this.setState({ [minKey]: Math.min(Number(e.target.value), this.state[maxKey]) }),
onMaxInput: (e) => this.setState({ [maxKey]: Math.max(Number(e.target.value), this.state[minKey]) }),
onMinTextBlur: this.onMinTextBlur(minKey, lo, hi),
onMaxTextBlur: this.onMaxTextBlur(maxKey, lo, hi),
};
}
buildPath(series, w, h, pad) {
if (!series || series.length < 2) return { line: "", area: "" };
const min = Math.min(...series);
const max = Math.max(...series);
const range = max - min || 1;
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const pts = series.map((v, i) => {
const x = pad + (i / (series.length - 1)) * innerW;
const y = pad + innerH - ((v - min) / range) * innerH;
return [x, y];
});
let line = "M" + pts[0][0].toFixed(1) + "," + pts[0][1].toFixed(1);
for (let i = 1; i < pts.length; i++) line += " L" + pts[i][0].toFixed(1) + "," + pts[i][1].toFixed(1);
const area = line + ` L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z`;
return { line, area };
}
renderVals() {
const markets = this.state.markets || [];
const traders = this.state.traders || [];
const view = this.state.view;
const navDefs = [
{ key: "overview", label: "Markets" },
{ key: "traders", label: "Traders" },
{ key: "watchlist", label: "Watchlist" },
{ key: "settings", label: "Settings" },
];
const navItems = navDefs.map((n) => {
const active = (n.key === "overview" && view === "overview") ||
(n.key === "traders" && (view === "trader" || view === "traderSearch")) ||
(n.key === "watchlist" && false) ||
(n.key === "settings" && false);
return {
label: n.label,
onClick: n.key === "overview" ? this.goOverview : (n.key === "traders" ? this.goTraderSearch : this.goOverview),
style: `display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:10px; font-size:13.5px; font-weight:600; cursor:pointer; color:${active ? "#EDEFF5" : "#8B93A7"}; background:${active ? "rgba(22,82,240,0.14)" : "transparent"};`,
dotStyle: `width:6px; height:6px; border-radius:50%; background:${active ? "#5b9dff" : "#3a4152"};`,
};
});
// ---- global stats
const totalVol24 = markets.reduce((s, m) => s + m.volume24h, 0);
const openMarkets = markets.filter((m) => m.status === "Active").length;
const totalTraders = new Set(traders.map((t) => t.id)).size + markets.reduce((s, m) => s + m.traders, 0) * 0; // display sum below
const totalActiveTraders = markets.reduce((s, m) => s + m.traders, 0);
const avgLiquidity = markets.length ? markets.reduce((s, m) => s + m.liquidity, 0) / markets.length : 0;
const globalStats = [
{ label: "24H Volume", value: this.fmtMoney(totalVol24), delta: this.fmtSigned(3.4, "%"), deltaColor: "#12D48A" },
{ label: "Open Markets", value: String(openMarkets), delta: "+2 today", deltaColor: "#12D48A" },
{ label: "Active Traders", value: this.fmtNum(totalActiveTraders), delta: this.fmtSigned(1.8, "%"), deltaColor: "#12D48A" },
{ label: "Avg. Liquidity", value: this.fmtMoney(avgLiquidity), delta: this.fmtSigned(-0.6, "%"), deltaColor: "#F6465D" },
];
const marketRows = markets.map((m) => ({
question: m.question,
category: m.category,
statusLabel: m.status,
yesPriceLabel: m.yesPrice.toFixed(1) + "%",
changeLabel: this.fmtSigned(m.change24h, "%"),
changeColor: m.change24h >= 0 ? "#12D48A" : "#F6465D",
volumeLabel: this.fmtMoney(m.volume24h),
tradersLabel: this.fmtNum(m.traders),
onClick: () => this.goMarket(m.id),
}));
const leaderboard = [...traders].sort((a, b) => (b.pnlRealized + b.pnlUnrealized) - (a.pnlRealized + a.pnlUnrealized)).slice(0, 6).map((t) => ({
handle: t.handle,
trait: t.traits[0],
initial: t.handle[0].toUpperCase(),
avatarBg: this.avatarBg(t.id),
pnlLabel: this.fmtMoney(t.pnlRealized + t.pnlUnrealized),
onClick: () => this.goTrader(t.id),
}));
const headerVolume = this.fmtMoney(totalVol24);
const crumbDefs = view === "overview" ? [["Markets Overview", null]] :
view === "market" ? [["Markets Overview", this.goOverview], ["Market Detail", null]] :
view === "traderSearch" ? [["Trader Search", null]] :
[["Trader Search", this.goTraderSearch], ["Trader Detail", null]];
const breadcrumbItems = crumbDefs.map(([label, onClick], i) => ({
label, onClick,
style: onClick ? "cursor:pointer; color:#8B93A7;" : "color:#EDEFF5; cursor:default;",
sep: i < crumbDefs.length - 1,
}));
// ---- trader search
let searchResults = [];
let traitFilterChips = [];
if (view === "traderSearch") {
const q = this.state.searchText.trim().toLowerCase();
traitFilterChips = this.ALL_TRAITS.map((tr) => {
const active = this.state.filterTraits.includes(tr);
const s = this.traitStyle(tr);
return {
label: tr,
onClick: () => this.toggleTrait(tr),
style: `cursor:pointer; font-size:12px; font-weight:700; padding:7px 13px; border-radius:9px; background:${active ? s.bg : "rgba(255,255,255,0.04)"}; color:${active ? s.color : "#8B93A7"}; border:1px solid ${active ? s.border : "rgba(255,255,255,0.09)"};`,
};
});
searchResults = traders.filter((t) => {
if (q && !t.handle.toLowerCase().includes(q) && !t.wallet.toLowerCase().includes(q)) return false;
if (this.state.filterTraits.length && !this.state.filterTraits.every((tr) => t.traits.includes(tr))) return false;
if (t.winRate < this.state.winRateMin || t.winRate > this.state.winRateMax) return false;
if (t.volume < this.state.volumeMin || t.volume > this.state.volumeMax) return false;
const totalPnl = t.pnlRealized + t.pnlUnrealized;
if (totalPnl < this.state.pnlMin || totalPnl > this.state.pnlMax) return false;
return true;
}).map((t) => ({
id: t.id,
handle: t.handle,
initial: t.handle[0].toUpperCase(),
avatarBg: this.avatarBg(t.id),
traitChips: t.traits.map((tr) => {
const s = this.traitStyle(tr);
return { label: tr, bg: s.bg, color: s.color, border: s.border };
}),
winRateLabel: t.winRate.toFixed(1) + "%",
volumeLabel: this.fmtMoney(t.volume),
pnlLabel: ((t.pnlRealized + t.pnlUnrealized) >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(t.pnlRealized + t.pnlUnrealized)),
pnlColor: (t.pnlRealized + t.pnlUnrealized) >= 0 ? "#12D48A" : "#F6465D",
onClick: () => this.goTrader(t.id),
}));
}
// ---- market detail
let marketDetail = null;
let marketStats = [];
if (view === "market") {
const m = markets.find((x) => x.id === this.state.selectedMarketId) || markets[0];
if (m) {
const { line, area } = this.buildPath(m.yesSeries, 600, 220, 14);
const maxBookSize = Math.max(1, ...m.orderBook.bids.map((b) => b[1]), ...m.orderBook.asks.map((a) => a[1]));
marketDetail = {
question: m.question,
category: m.category,
status: m.status,
statusBg: m.status === "Active" ? "rgba(18,212,138,0.12)" : "rgba(255,255,255,0.08)",
statusColor: m.status === "Active" ? "#12D48A" : "#8B93A7",
resolves: m.resolves,
yesPriceBig: m.yesPrice.toFixed(1) + "%",
linePath: line,
areaPath: area,
asks: [...m.orderBook.asks].reverse().map(([p, s]) => ({ priceLabel: p.toFixed(1) + "%", sizeLabel: this.fmtMoney(s), barWidth: Math.round((s / maxBookSize) * 100) + "%" })),
bids: m.orderBook.bids.map(([p, s]) => ({ priceLabel: p.toFixed(1) + "%", sizeLabel: this.fmtMoney(s), barWidth: Math.round((s / maxBookSize) * 100) + "%" })),
holders: m.holders.map((h) => ({
trader: h.trader,
side: h.side,
sideBg: h.side === "Yes" ? "rgba(18,212,138,0.14)" : "rgba(246,70,93,0.14)",
sideColor: h.side === "Yes" ? "#12D48A" : "#F6465D",
valueLabel: this.fmtMoney(h.value),
onClick: () => {
const t = traders.find((tr) => tr.handle === h.trader || tr.wallet === h.trader);
if (t) this.goTrader(t.id);
},
})),
topTraders: m.topTraders.map((handle) => {
const t = traders.find((tr) => tr.handle === handle);
return t ? {
handle: t.handle,
trait: t.traits[0],
initial: t.handle[0].toUpperCase(),
avatarBg: this.avatarBg(t.id),
onClick: () => this.goTrader(t.id),
} : { handle, trait: "", initial: handle[0].toUpperCase(), avatarBg: "#333", onClick: () => {} };
}),
};
marketStats = [
{ label: "24H Volume", value: this.fmtMoney(m.volume24h) },
{ label: "Total Volume", value: this.fmtMoney(m.volumeTotal) },
{ label: "Liquidity", value: this.fmtMoney(m.liquidity) },
{ label: "Open Interest", value: this.fmtMoney(m.openInterest) },
];
}
}
// ---- trader detail
let traderDetail = null;
let traderStats = [];
if (view === "trader") {
const t = traders.find((x) => x.id === this.state.selectedTraderId) || traders[0];
if (t) {
const { line, area } = this.buildPath(t.pnlSeries, 600, 200, 14);
const totalPnl = t.pnlRealized + t.pnlUnrealized;
traderDetail = {
handle: t.handle,
wallet: t.wallet,
initial: t.handle[0].toUpperCase(),
avatarBg: this.avatarBg(t.id),
joined: t.joined,
activePositionsLabel: String(t.activePositions),
traitChips: t.traits.map((tr) => {
const s = this.traitStyle(tr);
return { label: tr, bg: s.bg, color: s.color, border: s.border };
}),
linePath: line,
areaPath: area,
hasConnections: t.connectedWallets.length > 0,
noConnections: t.connectedWallets.length === 0,
connections: t.connectedWallets,
positions: t.positions.map((p) => ({
market: p.market,
side: p.side,
sideBg: p.side === "Yes" ? "rgba(18,212,138,0.14)" : "rgba(246,70,93,0.14)",
sideColor: p.side === "Yes" ? "#12D48A" : "#F6465D",
sizeLabel: this.fmtMoney(p.size),
entryLabel: p.entry.toFixed(1) + "%",
currentLabel: p.current.toFixed(1) + "%",
pnlLabel: this.fmtSigned(0, "").slice(0,0) + (p.pnl >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(p.pnl)),
pnlColor: p.pnl >= 0 ? "#12D48A" : "#F6465D",
})),
};
traderStats = [
{ label: "Realized P&L", value: (t.pnlRealized >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(t.pnlRealized)), color: t.pnlRealized >= 0 ? "#12D48A" : "#F6465D" },
{ label: "Unrealized P&L", value: (t.pnlUnrealized >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(t.pnlUnrealized)), color: t.pnlUnrealized >= 0 ? "#12D48A" : "#F6465D" },
{ label: "Win Rate", value: t.winRate.toFixed(1) + "%", color: "#EDEFF5" },
{ label: "Total Volume", value: this.fmtMoney(t.volume), color: "#EDEFF5" },
{ label: "Avg Position", value: this.fmtMoney(t.avgPositionSize), color: "#EDEFF5" },
];
}
}
return {
navItems,
headerVolume,
breadcrumbItems,
isOverview: view === "overview",
isMarket: view === "market",
isTrader: view === "trader",
isTraderSearch: view === "traderSearch",
searchResultsCount: String(searchResults.length),
searchResults,
traitFilterChips,
searchText: this.state.searchText,
onSearchTextChange: this.setSearchText,
winRateSlider: this.buildDualSlider("winRateMin", "winRateMax", 0, 100, (v) => v.toFixed(0) + "%"),
volumeSlider: this.buildDualSlider("volumeMin", "volumeMax", 0, 25000000, (v) => this.fmtMoney(v)),
pnlSlider: this.buildDualSlider("pnlMin", "pnlMax", -50000, 500000, (v) => (v >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(v))),
onResetFilters: this.resetFilters,
globalStats,
marketRows,
leaderboard,
marketDetail,
marketStats,
traderDetail,
traderStats,
};
}
}
</script>
</body>
</html>
+290
View File
@@ -0,0 +1,290 @@
// Mock data for prediction market analytics prototype
function genSeries(n, start, vol, drift) {
const pts = [start];
for (let i = 1; i < n; i++) {
const next = Math.max(2, Math.min(98, pts[i - 1] + (Math.random() - 0.5 + drift) * vol));
pts.push(Math.round(next * 10) / 10);
}
return pts;
}
export const MARKETS = [
{
id: "fed-sep-cut",
question: "Fed cuts rates at September meeting?",
category: "Economics",
status: "Active",
resolves: "Sep 18, 2026",
yesPrice: 71.4,
change24h: 4.2,
volume24h: 2840000,
volumeTotal: 41200000,
liquidity: 6120000,
openInterest: 18400000,
traders: 8340,
yesSeries: genSeries(40, 55, 4, 0.06),
noSeries: null,
orderBook: {
bids: [[71.0, 182000], [70.5, 240000], [70.0, 310000], [69.5, 198000], [69.0, 145000]],
asks: [[71.5, 165000], [72.0, 221000], [72.5, 290000], [73.0, 176000], [73.5, 130000]]
},
holders: [
{ trader: "0xA13f…9c2", side: "Yes", size: 420000, value: 300000 },
{ trader: "quant_owl", side: "Yes", size: 318000, value: 227000 },
{ trader: "0x77Bd…41a", side: "No", size: 275000, value: 78600 },
{ trader: "resolvr", side: "Yes", size: 210000, value: 150000 },
{ trader: "0x9F02…c88", side: "No", size: 188000, value: 53700 }
],
topTraders: ["quant_owl", "resolvr", "arb_meridian", "0xA13f…9c2"]
},
{
id: "btc-150k",
question: "Bitcoin above $150,000 by year end?",
category: "Crypto",
status: "Active",
resolves: "Dec 31, 2026",
yesPrice: 38.2,
change24h: -2.8,
volume24h: 5120000,
volumeTotal: 88900000,
liquidity: 11400000,
openInterest: 34200000,
traders: 21300,
yesSeries: genSeries(40, 45, 5, -0.02),
noSeries: null,
orderBook: {
bids: [[38.0, 410000], [37.5, 380000], [37.0, 512000], [36.5, 290000], [36.0, 210000]],
asks: [[38.5, 390000], [39.0, 460000], [39.5, 330000], [40.0, 280000], [40.5, 190000]]
},
holders: [
{ trader: "satoshi_fan", side: "Yes", size: 890000, value: 340000 },
{ trader: "0x4Ac1…d02", side: "No", size: 720000, value: 445000 },
{ trader: "bot_delta_9", side: "No", size: 610000, value: 377000 },
{ trader: "arb_meridian", side: "Yes", size: 540000, value: 206000 },
{ trader: "0xE221…7b4", side: "Yes", size: 480000, value: 183000 }
],
topTraders: ["satoshi_fan", "bot_delta_9", "arb_meridian", "0x4Ac1…d02"]
},
{
id: "ucl-winner",
question: "Real Madrid win Champions League 2027?",
category: "Sports",
status: "Active",
resolves: "May 30, 2027",
yesPrice: 22.6,
change24h: 1.1,
volume24h: 980000,
volumeTotal: 14300000,
liquidity: 3210000,
openInterest: 7600000,
traders: 5120,
yesSeries: genSeries(40, 20, 3, 0.03),
noSeries: null,
orderBook: {
bids: [[22.0, 92000], [21.5, 110000], [21.0, 88000], [20.5, 64000], [20.0, 51000]],
asks: [[22.5, 87000], [23.0, 101000], [23.5, 76000], [24.0, 55000], [24.5, 40000]]
},
holders: [
{ trader: "resolvr", side: "Yes", size: 210000, value: 47000 },
{ trader: "0xB901…5e3", side: "No", size: 198000, value: 153000 },
{ trader: "quant_owl", side: "Yes", size: 155000, value: 35000 },
{ trader: "farmer_jo", side: "Yes", size: 121000, value: 27000 }
],
topTraders: ["resolvr", "quant_owl", "0xB901…5e3"]
},
{
id: "us-election-28",
question: "Democratic candidate wins 2028 election?",
category: "Politics",
status: "Active",
resolves: "Nov 7, 2028",
yesPrice: 54.8,
change24h: 0.6,
volume24h: 3620000,
volumeTotal: 61500000,
liquidity: 9870000,
openInterest: 28100000,
traders: 14700,
yesSeries: genSeries(40, 50, 3.5, 0.01),
noSeries: null,
orderBook: {
bids: [[54.5, 260000], [54.0, 310000], [53.5, 220000], [53.0, 190000], [52.5, 140000]],
asks: [[55.0, 245000], [55.5, 300000], [56.0, 210000], [56.5, 175000], [57.0, 120000]]
},
holders: [
{ trader: "0x2Ff8…a10", side: "Yes", size: 610000, value: 334000 },
{ trader: "arb_meridian", side: "No", size: 540000, value: 244000 },
{ trader: "night_owl_88", side: "Yes", size: 410000, value: 224000 },
{ trader: "0xC450…9d1", side: "Yes", size: 380000, value: 208000 }
],
topTraders: ["0x2Ff8…a10", "arb_meridian", "night_owl_88"]
},
{
id: "openai-ipo",
question: "OpenAI IPOs before end of 2027?",
category: "Business",
status: "Active",
resolves: "Dec 31, 2027",
yesPrice: 16.9,
change24h: -0.4,
volume24h: 740000,
volumeTotal: 9800000,
liquidity: 2140000,
openInterest: 5300000,
traders: 3980,
yesSeries: genSeries(40, 18, 2.5, -0.01),
noSeries: null,
orderBook: {
bids: [[16.5, 61000], [16.0, 72000], [15.5, 58000], [15.0, 40000], [14.5, 33000]],
asks: [[17.0, 55000], [17.5, 63000], [18.0, 47000], [18.5, 36000], [19.0, 28000]]
},
holders: [
{ trader: "bot_delta_9", side: "No", size: 310000, value: 258000 },
{ trader: "0x9F02…c88", side: "Yes", size: 190000, value: 32000 },
{ trader: "farmer_jo", side: "No", size: 140000, value: 116000 }
],
topTraders: ["bot_delta_9", "0x9F02…c88"]
},
{
id: "fifa-worldcup",
question: "Brazil wins 2026 FIFA World Cup?",
category: "Sports",
status: "Resolved · No",
resolves: "Jul 19, 2026",
yesPrice: 0,
change24h: 0,
volume24h: 0,
volumeTotal: 27600000,
liquidity: 0,
openInterest: 0,
traders: 19200,
yesSeries: genSeries(40, 30, 5, 0.1).map((v,i,a)=> i>34 ? Math.max(0, 30 - (i-34)*6) : v),
noSeries: null,
orderBook: { bids: [], asks: [] },
holders: [
{ trader: "night_owl_88", side: "No", size: 520000, value: 520000 },
{ trader: "0xC450…9d1", side: "Yes", size: 300000, value: 0 }
],
topTraders: ["night_owl_88", "0xC450…9d1"]
}
];
export const TRADERS = [
{
id: "quant_owl",
handle: "quant_owl",
wallet: "0x1a4F…88bC",
traits: ["Arbitrage Bot", "High Volume"],
joined: "Mar 2024",
pnlRealized: 184200,
pnlUnrealized: 32100,
winRate: 68.4,
volume: 4820000,
avgPositionSize: 41200,
activePositions: 14,
pnlSeries: genSeries(30, 40, 6, 0.08),
positions: [
{ market: "Fed cuts rates at September meeting?", side: "Yes", size: 318000, entry: 62.1, current: 71.4, pnl: 29580, status: "Open" },
{ market: "Real Madrid win Champions League 2027?", side: "Yes", size: 155000, entry: 18.4, current: 22.6, pnl: 6510, status: "Open" },
{ market: "US Presidential Election 2028", side: "No", size: 96000, entry: 48.2, current: 45.2, pnl: 2880, status: "Open" },
{ market: "Bitcoin above $100k Jan 2026", side: "Yes", size: 210000, entry: 55.0, current: 100, pnl: 94500, status: "Resolved" }
],
connectedWallets: ["0x88Ac…2f01", "0x5b0D…e410"]
},
{
id: "bot_delta_9",
handle: "bot_delta_9",
wallet: "0x5C90…14fA",
traits: ["Trading Bot", "Resolution Farming"],
joined: "Jan 2025",
pnlRealized: 61400,
pnlUnrealized: -8200,
winRate: 54.1,
volume: 12400000,
avgPositionSize: 18300,
activePositions: 62,
pnlSeries: genSeries(30, 30, 3, 0.02),
positions: [
{ market: "Bitcoin above $150,000 by year end?", side: "No", size: 610000, entry: 41.0, current: 38.2, pnl: 17080, status: "Open" },
{ market: "OpenAI IPOs before end of 2027?", side: "No", size: 310000, entry: 84.2, current: 83.1, pnl: 3410, status: "Open" },
{ market: "Brazil wins 2026 FIFA World Cup?", side: "No", size: 520000, entry: 71.0, current: 100, pnl: 150800, status: "Resolved" }
],
connectedWallets: ["0x14fA…C90D", "0x77b2…9e11", "0x0021…aabc"]
},
{
id: "arb_meridian",
handle: "arb_meridian",
wallet: "0x902D…7Ac1",
traits: ["Arbitrage Bot"],
joined: "Aug 2023",
pnlRealized: 402800,
pnlUnrealized: 51200,
winRate: 71.9,
volume: 22100000,
avgPositionSize: 68400,
activePositions: 9,
pnlSeries: genSeries(30, 60, 5, 0.05),
positions: [
{ market: "Bitcoin above $150,000 by year end?", side: "Yes", size: 540000, entry: 30.5, current: 38.2, pnl: 41580, status: "Open" },
{ market: "Democratic candidate wins 2028 election?", side: "No", size: 540000, entry: 47.0, current: 45.2, pnl: 9720, status: "Open" },
{ market: "Fed cuts rates at September meeting?", side: "Yes", size: 180000, entry: 58.0, current: 71.4, pnl: 24120, status: "Open" }
],
connectedWallets: []
},
{
id: "resolvr",
handle: "resolvr",
wallet: "0xF001…22Cd",
traits: ["Human", "Resolution Farming"],
joined: "Nov 2024",
pnlRealized: 92300,
pnlUnrealized: 12800,
winRate: 59.7,
volume: 6740000,
avgPositionSize: 27600,
activePositions: 22,
pnlSeries: genSeries(30, 35, 4, 0.03),
positions: [
{ market: "Fed cuts rates at September meeting?", side: "Yes", size: 210000, entry: 60.0, current: 71.4, pnl: 23940, status: "Open" },
{ market: "Real Madrid win Champions League 2027?", side: "Yes", size: 210000, entry: 15.2, current: 22.6, pnl: 15540, status: "Open" }
],
connectedWallets: ["0x22Cd…F001"]
},
{
id: "night_owl_88",
handle: "night_owl_88",
wallet: "0x3E77…B420",
traits: ["Human", "High Volume"],
pnlRealized: 128900,
pnlUnrealized: 4100,
winRate: 62.2,
volume: 9120000,
avgPositionSize: 35200,
activePositions: 11,
pnlSeries: genSeries(30, 42, 4, 0.04),
positions: [
{ market: "Democratic candidate wins 2028 election?", side: "Yes", size: 410000, entry: 51.0, current: 54.8, pnl: 15580, status: "Open" },
{ market: "Brazil wins 2026 FIFA World Cup?", side: "No", size: 520000, entry: 68.0, current: 100, pnl: 166400, status: "Resolved" }
],
connectedWallets: []
},
{
id: "farmer_jo",
handle: "farmer_jo",
wallet: "0xA210…6Bf0",
traits: ["Human", "Resolution Farming"],
pnlRealized: 21400,
pnlUnrealized: -3100,
winRate: 48.9,
volume: 1840000,
avgPositionSize: 9200,
activePositions: 31,
pnlSeries: genSeries(30, 20, 3, -0.01),
positions: [
{ market: "Real Madrid win Champions League 2027?", side: "Yes", size: 121000, entry: 24.0, current: 22.6, pnl: -1690, status: "Open" },
{ market: "OpenAI IPOs before end of 2027?", side: "No", size: 140000, entry: 80.0, current: 83.1, pnl: -4340, status: "Open" }
],
connectedWallets: ["0x6Bf0…A210", "0x9021…4Ccb"]
}
];
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB