WebUI Redesign and Component 1: category mapper fixes
This commit is contained in:
Binary file not shown.
@@ -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 0–2 begleiten das neue Design sofort. Phase 3 kann jederzeit vorgezogen werden (billig).
|
||||||
|
Phase 4 erst zum Release — aber durch 0–3 ist dann nichts mehr umzubauen, nur zu ergänzen.
|
||||||
@@ -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. **2a–2c** (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.
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
@@ -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 & 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 & 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&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&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&L</th>
|
||||||
|
<th class="num-col">Unrealisierte P&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 & 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & 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 |
@@ -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="{"$preview": {"width": 1440}, "brandName": {"editor": "text", "default": "Predictalytics", "tsType": "string"}}">
|
||||||
|
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>
|
||||||
@@ -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 & 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="{"$preview": {"width": 1440}, "brandName": {"editor": "text", "default": "Predictalytics", "tsType": "string"}}">
|
||||||
|
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&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&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&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&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&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>
|
||||||
@@ -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 |
Reference in New Issue
Block a user