Initial commit: Predictalytics solution
Clean Architecture .NET 8 solution (Domain/Application/Infrastructure/Api/Worker/WinFormsHost) for analyzing Polymarket traders for copytrading/strategy-replication candidates. Includes EF Core InitialBaseline migration and DB secrets removed from source/config in preparation for version control.
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# Build output
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
|
||||||
|
# Visual Studio
|
||||||
|
.vs/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
|
||||||
|
# Runtime-generated local settings (contains real DB credentials, never commit)
|
||||||
|
settings.json
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Rider / VS Code
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude Code local settings (may contain tokens/secrets used during this session)
|
||||||
|
.claude/settings.local.json
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/Predictalytics.Api/Predictalytics.Api.csproj" />
|
||||||
|
<Project Path="src/Predictalytics.Application/Predictalytics.Application.csproj" />
|
||||||
|
<Project Path="src/Predictalytics.Domain/Predictalytics.Domain.csproj" />
|
||||||
|
<Project Path="src/Predictalytics.Infrastructure/Predictalytics.Infrastructure.csproj" />
|
||||||
|
<Project Path="src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj" />
|
||||||
|
<Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# Predictalytics – Umsetzungsplan (Phase 1 & 2 + Storage-Optimierung)
|
||||||
|
|
||||||
|
> **Wie wir das nutzen:** Wir arbeiten diese Liste Stück für Stück ab. Erledigte Punkte werden von `[ ]` auf `[x]` gesetzt. Reihenfolge ist absichtlich so gewählt, dass spätere Schritte auf früheren aufbauen — nicht einfach querbeet abhaken, siehe Abschnitt "Empfohlene Reihenfolge" ganz unten für die Kurzfassung.
|
||||||
|
>
|
||||||
|
> Phase 3 (Monetarisierung: Auth, Multi-Tenant, Billing) ist bewusst **nicht** Teil dieses Plans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Vorab-Bugfund (betrifft Phase 1 direkt)
|
||||||
|
|
||||||
|
Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden Trade, der kein `Buy` ist, wie ein `Sell` behandelt** (`else pnl += t.Amount`). Das ist falsch für `Split`, `Merge`, `Redeem`, `AddLiquidity`, `RemoveLiquidity` — das sind keine gewöhnlichen Verkäufe. Dieser Fix gehört zwingend in die neue PnL-Engine (siehe A1/A6), nicht als Extra-Task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 – Kernanalytik korrigieren
|
||||||
|
|
||||||
|
### A1. Positionsbasierte PnL-Engine
|
||||||
|
- [ ] Entscheidung dokumentieren: **Average-Cost-Methode** statt FIFO (einfacher, und – wichtig – kompatibel mit späterer Trade-Kompaktierung in Abschnitt C, weil Average-Cost nur Gesamtstückzahl & Gesamtkosten braucht, keine Einzel-Trade-Reihenfolge)
|
||||||
|
- [ ] Neue Domain-Struktur `TraderPosition` (TraderId, MarketOutcomeId, SharesHeld, AvgCost, RealizedPnl, zuletzt aktualisiert) — inkrementell fortschreibbar statt bei jeder Berechnung die komplette Trade-Historie neu zu scannen
|
||||||
|
- [ ] Buchungslogik je `TradeSide` sauber definieren:
|
||||||
|
- [ ] `Buy`: Shares += Size, AvgCost neu gewichten
|
||||||
|
- [ ] `Sell`: RealizedPnl += Size × (Price − AvgCost), Shares −= Size
|
||||||
|
- [ ] `Redeem` (Marktauflösung): RealizedPnl += verbleibende Shares × (1 oder 0 je nach Gewinn-Outcome − AvgCost), Shares = 0
|
||||||
|
- [ ] `Split` / `Merge`: als neutrale Positionsumwandlung behandeln (kein PnL-Effekt), nicht wie `Sell`
|
||||||
|
- [ ] `AddLiquidity` / `RemoveLiquidity`: getrennt von Trading-PnL betrachten (eigene Kategorie, fließt nicht in "Trading-Skill"-Bewertung ein)
|
||||||
|
- [ ] Unrealisierten PnL für offene Positionen berechnen: `Shares × (MarketOutcome.CurrentPrice − AvgCost)`
|
||||||
|
- [ ] Gesamt-PnL = realisiert + unrealisiert (ersetzt `OverallPnL`, `PnL30d/7d/24h` Felder in `TraderAnalytics`)
|
||||||
|
- [ ] Prüfen, ob wir zusätzlich/alternativ Polymarkets eigenen `/positions`-Endpoint (`GetTraderPositionsAsync`, liefert `CurrentValue`/`PercentPnl`) als Plausibilitäts-Check oder sogar als primäre Quelle für **offene** Positionen nutzen (aktuell komplett ungenutzt)
|
||||||
|
- [ ] `CalculatePnL`-Bug aus Abschnitt 0 im Zuge dessen mit erledigen
|
||||||
|
|
||||||
|
### A2. Win-Rate korrekt berechnen
|
||||||
|
- [ ] Win/Loss ist **pro Markt**, nicht pro Trade, definiert: ein Markt zählt als "Win", wenn der realisierte PnL aus diesem Markt (nach Redeem/vollständigem Exit) positiv ist
|
||||||
|
- [ ] `WinRate = Anzahl gewonnener Märkte / Anzahl abgeschlossener Märkte` (offene Positionen zählen nicht mit)
|
||||||
|
- [ ] Placeholder `return 0;` in `TraderAnalyticsWorker.CalculateWinRate` ersetzen
|
||||||
|
|
||||||
|
### A3. Deep-Dive-Kennzahlen mit echten Werten füllen
|
||||||
|
- [ ] **Voraussetzung klären:** Für Entry/Exit-Qualität und Timing-Accuracy brauchen wir eine **Preis-Historie** pro `MarketOutcome`, nicht nur den aktuellen Preis (`CurrentPrice`). Aktuell existiert keine Historisierung.
|
||||||
|
- [ ] Prüfen, ob Polymarkets CLOB-API einen Preishistorie-Endpoint (`/prices-history`) hergibt, den wir zum Backfill nutzen können
|
||||||
|
- [ ] Falls ja: neue Tabelle `MarketOutcomePriceSnapshot` (MarketOutcomeId, Timestamp, Price) einführen, periodisch befüllt (z.B. durch bestehenden `MarketSyncWorker`/`MarketHistoryWorker` erweitern)
|
||||||
|
- [ ] `AvgHoldDurationHours` echt berechnen: gewichtete Haltedauer zwischen Einstieg (Buy-Zeitpunkte, gewichtet nach Größe) und Ausstieg (Sell/Redeem) pro Position
|
||||||
|
- [ ] `EntryQuality`: Einstiegspreis im Vergleich zur nachfolgenden Preisentwicklung (z.B. Perzentil des Einstiegspreises innerhalb eines Zeitfensters danach)
|
||||||
|
- [ ] `ExitQuality`: analog für Ausstiegspreis
|
||||||
|
- [ ] `TimingAccuracy`: z.B. Anteil der Trades, die kurz vor einer für den Trader günstigen Preisbewegung platziert wurden
|
||||||
|
- [ ] Hardcodierte `50`-Neutralwerte in `AnalyticsService.PerformDeepDive` entfernen
|
||||||
|
|
||||||
|
### A4. Strategie-Klassifikation verbessern
|
||||||
|
- [ ] Zusätzliche Signale einbeziehen statt nur "Ø-Größe" und "Hedging-Rate":
|
||||||
|
- [ ] Verteilung der Haltedauern (kurz/lang, Varianz)
|
||||||
|
- [ ] Diversität der Marktkategorien
|
||||||
|
- [ ] Anteil der Trades kurz vor Marktauflösung vs. früh im Marktleben
|
||||||
|
- [ ] Nutzung gegenläufiger Positionen (Arbitrage-Muster über mehrere Outcomes/Märkte)
|
||||||
|
- [ ] Trade-Größen-Varianz (regelmäßig gleich große Orders = evtl. automatisiert)
|
||||||
|
- [ ] Überlegen, ob `StrategyType` als reines Einzel-Enum ausreicht oder ob wir zusätzlich Mehrfach-Signale/Tags parallel speichern (z.B. "primär Hedger, aber auch Whale-Größenordnung") — Enum bleibt als Primär-Tag, Zusatzsignale als eigene Felder/Scores
|
||||||
|
- [ ] Sicherstellen, dass nicht der Großteil der Trader dauerhaft bei "Unknown" landet (aktuell strukturell der Fall)
|
||||||
|
|
||||||
|
### A5. Copytrading-Eignungs-Score (neu)
|
||||||
|
- [ ] Neue Metrik-Dimension definieren, unabhängig vom bestehenden `PriorityScore`:
|
||||||
|
- [ ] **Liquiditäts-Fit**: durchschnittliche Positionsgröße im Verhältnis zur Marktliquidität/zum Volumen zum Handelszeitpunkt (Slippage-Risiko für Nachahmer)
|
||||||
|
- [ ] **Reaktionsfenster**: wie viel Zeit bliebe einem Copytrader realistisch zum Nachziehen (Trader mit Sekunden/Millisekunden-Kadenz sind nicht kopierbar)
|
||||||
|
- [ ] **Frequenz/Konzentration**: sehr hochfrequente/bot-artige Trader senken den Score automatisch
|
||||||
|
- [ ] **Track-Record-Länge & Konsistenz**: mehr abgeschlossene Märkte mit konsistent positivem PnL = höheres Vertrauen
|
||||||
|
- [ ] Kombinierten `CopytradingScore` (0–100) berechnen und persistieren (neues Feld auf `TraderScore` oder eigene Entity `TraderCopytradingProfile`)
|
||||||
|
- [ ] Im Dashboard/API sichtbar machen (getrennt von der bisherigen "Priorität", da unterschiedliche Fragestellung: *gut* vs. *kopierbar*)
|
||||||
|
|
||||||
|
### A6. (siehe Abschnitt 0) TradeSide-Bugfix in PnL-Berechnung
|
||||||
|
- [ ] Erledigt sich durch A1, hier nur als Häkchen zur Nachverfolgung
|
||||||
|
- [ ] Kurzer Änderungsvermerk/Commit-Hinweis, damit klar ist, dass dieser Bug bewusst behoben wurde
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 – Robustheit & Wartbarkeit
|
||||||
|
|
||||||
|
### B1. EF Core Migrationen einführen
|
||||||
|
- [x] Aktuellen Ist-Stand des Schemas exakt erfassen (inkl. aller manuellen `ALTER TABLE`-Patches aus `DependencyInjection.cs`) — abgeglichen, Entities/`OnModelCreating` deckten den Patch-Stand bereits vollständig ab
|
||||||
|
- [x] Erste **Baseline-Migration** erzeugt (`20260701102311_InitialBaseline`, in `src/Predictalytics.Infrastructure/Migrations/`), entspricht exakt dem aktuellen Schema
|
||||||
|
- [x] `EnsureCreatedAsync` + handgeschriebene `ExecuteIfColumnMissing`/`ALTER`-Helfer aus `DependencyInjection.cs` entfernt, durch `db.Database.MigrateAsync()` ersetzt (Platform-Seed bleibt als `INSERT IGNORE`)
|
||||||
|
- [ ] **Offen (manueller Schritt durch Nutzer):** Live-DB per SQL als "bereits migriert" markieren (`__EFMigrationsHistory`-Tabelle + Insert für `20260701102311_InitialBaseline`), da die Tabellen dort schon existieren und nicht per `CreateTable` neu angelegt werden dürfen. SQL wurde im Chat bereitgestellt, Ausführung liegt beim Nutzer (bewusst nicht automatisch gegen die produktive Remote-DB ausgeführt)
|
||||||
|
- [ ] Alle künftigen Schemaänderungen aus Phase 1 (z.B. `TraderPosition`, `MarketOutcomePriceSnapshot`, `TraderCopytradingProfile`) als reguläre Migrationen anlegen — **dieser Task sollte vor A1 abgeschlossen sein**, damit wir dort nicht wieder manuell patchen
|
||||||
|
|
||||||
|
### B2. Secrets-Management (Release-Vorbereitung, kein akuter Risikofall)
|
||||||
|
> Software läuft aktuell nur lokal, kein Fremdzugriff — Passwort bleibt vorerst wie es ist, keine Rotation nötig. Dieser Punkt ist reine **Vorbereitung**, damit das Projekt bei Bedarf später auch ohne die aktuellen Secrets veröffentlicht/geteilt werden könnte, ohne den Code nochmal anfassen zu müssen. Dadurch niedrigere Priorität als vorher angenommen — kann später in der Reihenfolge stehen.
|
||||||
|
- [ ] Produktiven MySQL-Connection-String aus `appsettings.json` in Umgebungsvariablen/User Secrets verschieben
|
||||||
|
- [ ] `appsettings.json` im Repo künftig nur Platzhalter/Dev-Default enthalten, damit ein Klon des Repos ohne die echten Zugangsdaten lauffähig bleibt (mit eigener lokaler DB)
|
||||||
|
- [ ] Keine Passwort-Rotation nötig, solange rein lokale Nutzung
|
||||||
|
|
||||||
|
### B3. Scoring-Pipeline entkoppeln
|
||||||
|
- [ ] `PollingWorker` soll **nicht** bei jedem 60-Sekunden-Zyklus alle Trader neu bewerten
|
||||||
|
- [ ] Nur Trader neu bewerten, die seit letzter Berechnung neue Trades bekommen haben (Dirty-Flag oder Vergleich `LastTradesUpdatedAt` vs. `TraderScore.CalculatedAt`)
|
||||||
|
- [ ] N+1-Datenbankzugriffe in `ScoringService.RecalculateAllScoresAsync` durch Batch-Queries ersetzen
|
||||||
|
- [ ] Eigenes Intervall für volle Neuberechnung/Ranking (z.B. alle 15 Min) getrennt vom reinen Trade-Polling (60s)
|
||||||
|
|
||||||
|
### B4. Testabdeckung für die kritische Logik
|
||||||
|
- [ ] Neues Testprojekt (z.B. `Predictalytics.Application.Tests`) anlegen — aktuell existiert **kein einziges** Testprojekt
|
||||||
|
- [ ] Unit-Tests für die neue PnL-Engine (A1) — insbesondere Grenzfälle: nur offene Position, nur geschlossene Position, Split/Merge, Redeem-Verlust vs. -Gewinn
|
||||||
|
- [ ] Unit-Tests für Win-Rate (A2)
|
||||||
|
- [ ] Unit-Tests für Deep-Dive-Kennzahlen (A3) und Copytrading-Score (A5)
|
||||||
|
- [ ] Diese Tests idealerweise **parallel zu A1–A5** schreiben, nicht erst am Ende nachziehen
|
||||||
|
|
||||||
|
### B5. Kleine verwandte Aufräumarbeit
|
||||||
|
- [ ] Log-Rotation/Retention der Serilog-Datei-Sinks (`WinFormsHost/logs`) prüfen — wächst potenziell unbegrenzt, ähnliches Prinzip wie die DB-Speicherplatzfrage unten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank-Speicherplatz-Optimierung
|
||||||
|
|
||||||
|
### C0. Kritische Bewertung deiner beiden Ideen (überarbeitet nach Diskussion 2026-07-01)
|
||||||
|
|
||||||
|
**Neue Rahmenbedingungen, die die Bewertung ändern:**
|
||||||
|
- Alle Rohdaten sind jederzeit erneut abrufbar — über Polymarkets `/activity`-Endpoint ([Doku](https://docs.polymarket.com/api-reference/core/get-user-activity), keine sichtbare Limitierung), im Zweifel über die Blockchain selbst. Wir müssen also **nichts "für immer" sichern** — Löschen ist kein unwiderruflicher Datenverlust.
|
||||||
|
- Analyseziel ist laut Auftrag explizit die **aktuelle** Strategie und aktueller Erfolg/Misserfolg, nicht die Historie von vor Jahren. Wir brauchen also gar keine unbegrenzte Detailtiefe — nur genug, um "aktuelles Verhalten" zu charakterisieren.
|
||||||
|
- Kern-Einsicht aus A1: Sobald ein Trade in `TraderPosition` (SharesHeld/AvgCost/RealizedPnl) eingerechnet ist, wird die **Rohzeile für die PnL-Fortführung nie wieder gebraucht** — die Position ist bereits die komprimierte Zusammenfassung. Rohdaten braucht es nur noch für die Strategie-/Deep-Dive-Analyse (Haltedauer, Timing, Hedging-Muster), und die soll ohnehin nur das aktuelle Fenster betrachten.
|
||||||
|
|
||||||
|
→ Das ersetzt die alte Idee 1 (Archivierung nur inaktiver Trader + Reaktivierungs-Baseline) durch ein einfacheres, einheitliches Prinzip: siehe **C1 – Rollierendes Zeitfenster**.
|
||||||
|
|
||||||
|
**Idee 2 (Hochfrequenz-Trader alle 10 Min. aggregieren)** bleibt weiterhin sinnvoll — allerdings in überarbeiteter Form, da die ursprüngliche Beschreibung zu grob war:
|
||||||
|
- "Min/Max/Ø Buy-In" ohne Trennung nach Buy/Sell und nach Markt/Outcome zerstört genau die Information, die die PnL-Engine (A1) braucht.
|
||||||
|
- Eine feste 10-Minuten-Uhrzeit-Bucket-Grenze für *alle* Trades eines als "Bot" eingestuften Traders würde auch die wenigen möglicherweise bedeutsamen Trades eines Mischtyps mit-aggregieren.
|
||||||
|
- Relevant bleibt sie, weil auch *innerhalb* des neuen rollierenden Zeitfensters (siehe C1) ein aktiver Bot enorme Mengen an Trades erzeugen kann — das ist jetzt eine Optimierung fürs "heiße" Fenster, nicht mehr fürs Langzeitarchiv.
|
||||||
|
|
||||||
|
→ Vorschlag: **Burst-Erkennung statt starrer Zeit-Buckets** (nur tatsächlich dichte Trade-Sequenzen kompaktieren) und **Aggregation getrennt nach Trader+Markt/Outcome+Seite** mit **VWAP** (mengengewichteter Durchschnittspreis) statt einfachem Durchschnitt — bleibt unter der Average-Cost-Methode aus A1 nahezu verlustfrei für die PnL-Berechnung.
|
||||||
|
|
||||||
|
### C1. Rollierendes Zeitfenster statt Archivierung (ersetzt die alte "Idee 1")
|
||||||
|
- [ ] Konfigurierbares Retention-Fenster einführen (Default-Vorschlag: 3–6 Monate) — gilt **für alle Trader gleichermaßen**, nicht nur für inaktive. Länge sollte sich daran orientieren, wie weit die Deep-Dive-/Strategie-Analyse (A3/A4) tatsächlich zurückschaut, um "aktuelle Strategie" zu charakterisieren
|
||||||
|
- [ ] Reihenfolge pro Trade zwingend einhalten: **erst** in `TraderPosition`/Monats-Aggregat (s.u.) einrechnen und das sicher persistieren, **dann erst** die Rohzeile löschen — rein zeitbasiert, unabhängig davon ob die betroffene Position noch offen oder schon geschlossen ist
|
||||||
|
- [ ] Kein Export/Cold-Storage nötig, da jederzeit über die Polymarket-API bzw. im Zweifel über die Blockchain nachladbar — vereinfacht C1 gegenüber der ursprünglichen Idee erheblich (keine Baseline-Felder, keine Reaktivierungs-Sonderfälle)
|
||||||
|
- [ ] Optionales, leichtgewichtiges Langzeit-Signal (nice-to-have, niedrige Priorität): ein grobes Monats-Aggregat pro Trader (Monat, realisierter PnL, Trade-Anzahl, Volumen) für einen "war er über Monate hinweg konsistent profitabel"-Trend im Copytrading-Score (A5) — **ohne** Trade-Detailtiefe, nur wenige Kennzahlen pro Monat
|
||||||
|
- [ ] Neuer periodischer Cleanup-Job (`TradeRetentionWorker`), der Trades außerhalb des Fensters findet und löscht, nachdem die Voraussetzung (Position/Aggregat aktuell) erfüllt ist
|
||||||
|
- [ ] Kurzer Stichprobentest, wie weit `/activity` pro Wallet tatsächlich zurückreicht (bestätigt/verifiziert nur die schon vorliegende Doku-Aussage, geringe Priorität da schon durch Nutzer-Recherche plausibilisiert)
|
||||||
|
|
||||||
|
### C2. Kompaktierung von Hochfrequenz-Tradern innerhalb des Zeitfensters (überarbeitete "Idee 2")
|
||||||
|
- [ ] Burst-Erkennung statt globalem Zeitraster: Sequenz von Trades mit Abstand kleiner als Schwellwert (z.B. 60s, konfigurierbar) zwischen aufeinanderfolgenden Trades **desselben Traders, Outcomes und derselben Seite (Buy/Sell)** gilt als "Burst"
|
||||||
|
- [ ] Mindestlänge für Kompaktierung festlegen (z.B. erst ab 20+ Trades im Burst lohnt sich das)
|
||||||
|
- [ ] Bestehende Bot-Heuristik (`intervals.Average() < 10` in `AnalyticsService`) als Ausgangspunkt wiederverwenden/verallgemeinern statt eine zweite, unabhängige Definition einzuführen
|
||||||
|
- [ ] Aggregat-Datensatz pro Burst: Anzahl Trades, Summe Size, Summe Amount, Min-Preis, Max-Preis, **VWAP** (nicht einfacher Durchschnitt)
|
||||||
|
- [ ] Design-Entscheidung: Aggregat als zusätzliche nullable Spalten auf der bestehenden `Trade`-Tabelle (`AggregateCount`, `AggregateMinPrice`, `AggregateMaxPrice`) statt separater Tabelle — bestehender Code (PnL, Deep-Dive) muss dadurch kaum angepasst werden, ein Aggregat-Datensatz ist einfach ein "Trade" mit `Size = Summe`, `Price = VWAP`
|
||||||
|
- [ ] **Reihenfolge beachten:** Diese Kompaktierung erst implementieren, nachdem A1 (neue PnL-Engine) steht und validiert ist — sonst kompaktieren wir Daten weg, bevor wir wissen, was die neue Engine wirklich braucht
|
||||||
|
|
||||||
|
### C3. Weitere eigene Vorschläge
|
||||||
|
- [ ] **Spaltentypen verkleinern**: `TransactionHash`, `MarketId`, `AssetId` sind Hex-/Dezimal-Strings fester Länge (z.B. `0x` + 64 Hex-Zeichen) — als `BINARY(32)` statt `VARCHAR(66/80)` speichern spart ca. 30–50% Platz auf diesen stark indizierten Spalten und ist schneller
|
||||||
|
- [ ] **Tabellen-Partitionierung** von `Trades` nach Monat (`ExecutedAt`) prüfen — erlaubt später das Archivieren/Droppen ganzer Partitionen statt teurer zeilenweiser `DELETE`s
|
||||||
|
- [ ] **Materialitätsschwelle**: Mikro-Trades unterhalb eines Betrags (z.B. < $0.50) unabhängig von Bot-Klassifizierung direkt aggregiert erfassen, da sie für Copytrading/Strategieanalyse ohnehin kaum Aussagekraft haben
|
||||||
|
- [ ] ~~Kaltarchiv außerhalb der DB~~ — nicht mehr nötig: da Rohdaten jederzeit über die API/Blockchain nachladbar sind (siehe C1), erübrigt sich ein separates Cold-Storage-Export. Nur falls sich die API-Nachladbarkeit später als doch eingeschränkt herausstellt, hier nochmal aufgreifen
|
||||||
|
- [ ] **InnoDB-Kompression** (`ROW_FORMAT=COMPRESSED`) für die `Trades`-Tabelle als schneller Zwischenschritt prüfen — kein Datenverlust, kombinierbar mit allem anderen
|
||||||
|
- [ ] `Split`/`Merge`/`AddLiquidity`/`RemoveLiquidity`-Ereignisse (siehe A1) ggf. separat und kompakter ablegen, da sie für die Trader-Bewertung meist weniger relevant sind als `Buy`/`Sell`
|
||||||
|
|
||||||
|
### C4. Abhängigkeit zu Phase 1
|
||||||
|
- [ ] Merksatz: **Erst A1 (neue PnL-Engine) fertigstellen, dann C1/C2 umsetzen.** Sonst laufen wir Gefahr, Rohdaten wegzuoptimieren, die die neue Engine noch gebraucht hätte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entscheidung: Frischer Datenbank-Neustart nach Abschluss?
|
||||||
|
|
||||||
|
**Neu bewertet (2026-07-01):** Da wir laut C0/C1 ohnehin nichts an Alt-Historie "für immer" brauchen und alles bei Bedarf über die API/Blockchain nachladbar ist, ist ein sauberer Reset am Ende von Phase 1+2 **risikoarm und klar empfehlenswert** — nicht mehr nur eine Option mit Vorbehalt.
|
||||||
|
|
||||||
|
### D1. Kurzer Bestätigungs-Check (geringe Priorität, kein Blocker mehr)
|
||||||
|
- [ ] Stichprobenartig verifizieren, dass `/activity` für ein bekanntes, lange aktives Wallet tatsächlich vollständig zurückreicht — bestätigt nur die bereits über die [Doku](https://docs.polymarket.com/api-reference/core/get-user-activity) plausibilisierte Annahme, ist kein Show-Stopper mehr für die Entscheidung
|
||||||
|
|
||||||
|
### D2. Ablauf des Neustarts
|
||||||
|
- [ ] Vollständiges Backup/Dump der aktuellen Datenbank sichern (reine Vorsichtsmaßnahme, wird voraussichtlich nicht gebraucht) und einige Wochen aufbewahren
|
||||||
|
- [ ] EF-Migrationen-Baseline + alle Phase-1/2-Schemaänderungen fertigstellen (B1)
|
||||||
|
- [ ] Neue leere Datenbank anlegen, Migrationen anwenden
|
||||||
|
- [ ] Liste bereits bekannter Trader-Wallets aus dem alten Bestand als Startpunkt für die Re-Discovery übernehmen
|
||||||
|
- [ ] Re-Import über die Worker-Pipeline (mit korrigierter PnL-Logik, direkt im Rahmen des neuen Retention-Fensters aus C1) laufen lassen
|
||||||
|
- [ ] **Wichtig:** Dieser Schritt kommt ganz am Ende von Phase 1 + 2 — nicht vorher, damit wir nicht zweimal migrieren/importieren müssen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlene Reihenfolge (Kurzfassung, Stand 2026-07-01)
|
||||||
|
|
||||||
|
1. **B1** – EF-Migrationen-Baseline einführen (Fundament für alle weiteren Schemaänderungen)
|
||||||
|
2. **A1 + A6** – Positionsbasierte PnL-Engine inkl. TradeSide-Bugfix
|
||||||
|
3. **A2** – Win-Rate
|
||||||
|
4. **A3** – Deep-Dive-Kennzahlen (inkl. Preis-Historie-Voraussetzung klären)
|
||||||
|
5. **A4** – Strategie-Klassifikation verbessern
|
||||||
|
6. **A5** – Copytrading-Eignungs-Score
|
||||||
|
7. **B4** – Tests (idealerweise begleitend zu 2.–6., hier als Nachhol-Punkt falls übersprungen)
|
||||||
|
8. **B3** – Scoring-Pipeline entkoppeln/optimieren
|
||||||
|
9. **C1 + C2 + C3** – Speicherplatz-Optimierung: rollierendes Zeitfenster + Burst-Kompaktierung (bewusst erst jetzt, siehe C4)
|
||||||
|
10. **D** – Datenbank-Neustart durchführen (ganz am Ende, jetzt als klar empfohlener Schritt statt offener Entscheidung)
|
||||||
|
|
||||||
|
(B2 – Secrets-Vorbereitung und B5 – Log-Rotation sind risikoarme Nebenpunkte ohne akute Dringlichkeit, da rein lokale Nutzung; können jederzeit zwischendurch erledigt werden, wenn Zeit ist.)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||||
|
</packageSources>
|
||||||
|
<packageSourceMapping>
|
||||||
|
<packageSource key="nuget.org">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
|
</packageSourceMapping>
|
||||||
|
</configuration>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Predictalytics.Api.Endpoints;
|
||||||
|
using Predictalytics.Infrastructure;
|
||||||
|
|
||||||
|
namespace Predictalytics.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the API WebApplication. Used by the embedded Kestrel server.
|
||||||
|
/// </summary>
|
||||||
|
public static class ApiConfiguration
|
||||||
|
{
|
||||||
|
public static WebApplication ConfigureApi(WebApplicationBuilder builder, string? connectionStringOverride = null)
|
||||||
|
{
|
||||||
|
builder.Services.AddPredictalytics(builder.Configuration, connectionStringOverride);
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
|
||||||
|
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
|
||||||
|
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
|
||||||
|
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseCors();
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
// Map endpoints
|
||||||
|
app.MapDashboardEndpoints();
|
||||||
|
app.MapTraderEndpoints();
|
||||||
|
app.MapAlertEndpoints();
|
||||||
|
app.MapMarketEndpoints();
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace Predictalytics.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AlertEndpoints
|
||||||
|
{
|
||||||
|
public static void MapAlertEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/alerts").WithTags("Alerts");
|
||||||
|
|
||||||
|
group.MapGet("/", async (IAlertService svc, int? count, bool? unreadOnly, CancellationToken ct) =>
|
||||||
|
Results.Ok(await svc.GetRecentAlertsAsync(count ?? 50, unreadOnly ?? false, ct)));
|
||||||
|
|
||||||
|
group.MapPut("/{id:int}/read", async (int id, IAlertService svc, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
await svc.MarkAsReadAsync(id, ct);
|
||||||
|
return Results.Ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace Predictalytics.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class DashboardEndpoints
|
||||||
|
{
|
||||||
|
public static void MapDashboardEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/dashboard", async (IAnalyticsService svc, CancellationToken ct) =>
|
||||||
|
Results.Ok(await svc.GetDashboardAsync(ct)))
|
||||||
|
.WithTags("Dashboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace Predictalytics.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class MarketEndpoints
|
||||||
|
{
|
||||||
|
public static void MapMarketEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/markets").WithTags("Markets");
|
||||||
|
|
||||||
|
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await svc.GetMarketsAsync(skip ?? 0, take ?? 50, platform, ct);
|
||||||
|
return Results.Ok(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/{id:int}", async (int id, IAnalyticsService svc, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var detail = await svc.GetMarketDetailAsync(id, ct);
|
||||||
|
return detail is not null ? Results.Ok(detail) : Results.NotFound();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
namespace Predictalytics.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class SearchEndpoints
|
||||||
|
{
|
||||||
|
public static void MapSearchEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/search", async (string q, IAnalyticsService svc, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(q)) return Results.BadRequest("Query cannot be empty");
|
||||||
|
var results = await svc.SearchAsync(q, ct);
|
||||||
|
return Results.Ok(results);
|
||||||
|
}).WithTags("Search");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace Predictalytics.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class TraderEndpoints
|
||||||
|
{
|
||||||
|
public static void MapTraderEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/traders").WithTags("Traders");
|
||||||
|
|
||||||
|
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, CancellationToken ct) =>
|
||||||
|
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, ct)));
|
||||||
|
|
||||||
|
group.MapGet("/{id:int}", async (int id, IAnalyticsService svc, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var detail = await svc.GetTraderDetailAsync(id, ct);
|
||||||
|
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.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}/refresh", async (int id, IAnalyticsService svc, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
await svc.TriggerTradeSyncAsync(id, ct);
|
||||||
|
return Results.Ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async (string platform, string wallet, IAnalyticsService svc, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var id = await svc.AddTraderAsync(platform, wallet, ct);
|
||||||
|
return Results.Ok(new { id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>Predictalytics.Api</RootNamespace>
|
||||||
|
<!-- This project is used as a library by WinFormsHost; it does not run standalone -->
|
||||||
|
<OutputType>Library</OutputType>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Predictalytics.Application\Predictalytics.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="wwwroot\**">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"Predictalytics.Api": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"applicationUrl": "https://localhost:62273;http://localhost:62274"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Server=localhost;Database=Predictalytics_dev;User=root;Password="
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
Predictalytics — Premium SaaS Dashboard Design System
|
||||||
|
Modern clean dashboard inspired by Dribbble reference design.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* ─── CSS Custom Properties (Design Tokens) ─── */
|
||||||
|
:root {
|
||||||
|
/* Light Theme */
|
||||||
|
--bg: #F4F5F7;
|
||||||
|
--bg-sidebar: #FFFFFF;
|
||||||
|
--bg-card: #FFFFFF;
|
||||||
|
--bg-input: #F0F1F3;
|
||||||
|
--text-primary: #111111;
|
||||||
|
--text-secondary: #666666;
|
||||||
|
--text-muted: #999999;
|
||||||
|
--border: #E8E9EC;
|
||||||
|
--accent: #FF2D55;
|
||||||
|
--accent-glow: rgba(255, 45, 85, 0.15);
|
||||||
|
--success: #34C759;
|
||||||
|
--danger: #FF3B30;
|
||||||
|
--warning: #FF9500;
|
||||||
|
--info: #5AC8FA;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
|
||||||
|
--shadow-lg: 0 8px 32px rgba(0,0,0,0.08);
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--transition: 0.2s ease;
|
||||||
|
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--sidebar-w: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #0B0C0F;
|
||||||
|
--bg-sidebar: #111215;
|
||||||
|
--bg-card: #16181D;
|
||||||
|
--bg-input: #1E2028;
|
||||||
|
--text-primary: #EEEEEE;
|
||||||
|
--text-secondary: #AAAAAA;
|
||||||
|
--text-muted: #666666;
|
||||||
|
--border: #2A2C33;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
--shadow-lg: 0 8px 32px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Reset & Base ─── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html { font-size: 14px; -webkit-font-smoothing: antialiased; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
transition: background var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Sidebar ─── */
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transition: background var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #FF6B8A);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 18px;
|
||||||
|
box-shadow: 0 2px 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text { font-weight: 700; font-size: 16px; letter-spacing: -0.3px; }
|
||||||
|
|
||||||
|
.sidebar-nav { flex: 1; padding: 12px 10px; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13.5px;
|
||||||
|
transition: all var(--transition);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover { background: var(--bg-input); color: var(--text-primary); }
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--accent-glow);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -10px;
|
||||||
|
top: 50%; transform: translateY(-50%);
|
||||||
|
width: 3px; height: 20px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer { padding: 12px 10px; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* ─── Main Content ─── */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: var(--sidebar-w);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Topbar ─── */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 32px;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
transition: background var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-left { flex: 1; max-width: 600px; display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
.platform-select {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-select:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 14px;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box:focus-within { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.search-box svg { color: var(--text-muted); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.topbar-right { display: flex; align-items: center; gap: 16px; }
|
||||||
|
|
||||||
|
.timeframe-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn.active {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn:hover:not(.active) { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* Theme Toggle */
|
||||||
|
.theme-toggle {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all var(--transition);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
[data-theme="dark"] .icon-sun { display: block; }
|
||||||
|
[data-theme="dark"] .icon-moon { display: none; }
|
||||||
|
[data-theme="light"] .icon-sun { display: none; }
|
||||||
|
[data-theme="light"] .icon-moon { display: block; }
|
||||||
|
.icon-sun { display: none; }
|
||||||
|
|
||||||
|
/* ─── Page Content ─── */
|
||||||
|
.page-content { padding: 28px 32px; }
|
||||||
|
|
||||||
|
.page { display: none; }
|
||||||
|
.page.active { display: block; animation: fadeIn 0.3s ease; }
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Metric Cards ─── */
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px 24px;
|
||||||
|
transition: all var(--transition);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
|
||||||
|
|
||||||
|
.metric-card.accent {
|
||||||
|
background: linear-gradient(135deg, var(--accent), #FF6B8A);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card.accent .metric-label { color: rgba(255,255,255,0.8); }
|
||||||
|
.metric-card.accent .metric-delta { color: rgba(255,255,255,0.9); }
|
||||||
|
|
||||||
|
.metric-label { font-size: 12px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
||||||
|
.metric-value { font-size: 28px; font-weight: 800; letter-spacing: -1px; margin-bottom: 4px; }
|
||||||
|
.metric-delta { font-size: 12px; font-weight: 600; }
|
||||||
|
.metric-delta.positive { color: var(--success); }
|
||||||
|
.metric-delta.negative { color: var(--danger); }
|
||||||
|
|
||||||
|
/* ─── Cards ─── */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all var(--transition);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 18px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h2 { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
|
||||||
|
|
||||||
|
/* ─── Charts ─── */
|
||||||
|
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
||||||
|
.chart-card { margin-bottom: 0; }
|
||||||
|
.chart-container { padding: 20px 24px; height: 260px; }
|
||||||
|
|
||||||
|
/* ─── Data Tables ─── */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; }
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr { transition: background var(--transition); }
|
||||||
|
.data-table tbody tr:hover { background: var(--bg-input); }
|
||||||
|
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
/* Badges in tables */
|
||||||
|
.tier-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.tier-diamond { background: rgba(99,102,241,0.15); color: #818CF8; }
|
||||||
|
.tier-platinum { background: rgba(168,85,247,0.15); color: #C084FC; }
|
||||||
|
.tier-gold { background: rgba(245,158,11,0.15); color: #F59E0B; }
|
||||||
|
.tier-silver { background: rgba(156,163,175,0.15); color: #9CA3AF; }
|
||||||
|
.tier-bronze { background: rgba(180,83,9,0.15); color: #D97706; }
|
||||||
|
.tier-unknown { background: var(--bg-input); color: var(--text-muted); }
|
||||||
|
|
||||||
|
.side-buy { color: var(--success); font-weight: 700; }
|
||||||
|
.side-sell { color: var(--danger); font-weight: 700; }
|
||||||
|
|
||||||
|
.pnl-positive { color: var(--success); font-weight: 600; }
|
||||||
|
.pnl-negative { color: var(--danger); font-weight: 600; }
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.btn-sm:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* ─── Alerts ─── */
|
||||||
|
.alert-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.alert-item:hover { background: var(--bg-input); }
|
||||||
|
.alert-item:last-child { border-bottom: none; }
|
||||||
|
.alert-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 16px; }
|
||||||
|
.alert-severity-4 { background: rgba(255,59,48,0.15); }
|
||||||
|
.alert-severity-3 { background: rgba(255,149,0,0.15); }
|
||||||
|
.alert-severity-2 { background: rgba(90,200,250,0.15); }
|
||||||
|
.alert-severity-1 { background: var(--bg-input); }
|
||||||
|
.alert-content { flex: 1; }
|
||||||
|
.alert-title { font-weight: 600; font-size: 13px; margin-bottom: 3px; }
|
||||||
|
.alert-message { font-size: 12px; color: var(--text-secondary); line-height: 1.5; }
|
||||||
|
.alert-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; margin-top: 2px; }
|
||||||
|
.alert-unread .alert-title::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-right: 8px; }
|
||||||
|
|
||||||
|
/* ─── Empty State ─── */
|
||||||
|
.empty-state {
|
||||||
|
padding: 60px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.empty-state p { font-size: 14px; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ─── Responsive ─── */
|
||||||
|
@media (max-width: 1200px) { .metrics-grid { grid-template-columns: repeat(2, 1fr); } .charts-row { grid-template-columns: 1fr; } }
|
||||||
|
@media (max-width: 768px) { .sidebar { display: none; } .main-content { margin-left: 0; } .metrics-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* ─── Detail Views ─── */
|
||||||
|
.detail-header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
|
||||||
|
.btn-back { background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 14px; font-weight: 600; padding: 4px 8px; border-radius: 4px; transition: var(--transition); }
|
||||||
|
.btn-back:hover { background: var(--bg-input); color: var(--text-primary); }
|
||||||
|
|
||||||
|
.detail-grid { display: grid; grid-template-columns: 300px 1fr; gap: 24px; align-items: start; }
|
||||||
|
.detail-sidebar { padding: 24px; }
|
||||||
|
.stat-group { margin-bottom: 16px; }
|
||||||
|
.stat-label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||||
|
.stat-value { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.stat-value.small { font-size: 12px; font-family: 'Cascadia Code', monospace; word-break: break-all; opacity: 0.8; }
|
||||||
|
|
||||||
|
.outcome-row { margin-bottom: 12px; padding: 12px; background: var(--bg-input); border-radius: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.outcome-row span { font-size: 13px; font-weight: 600; }
|
||||||
|
.outcome-row strong { font-size: 15px; color: var(--accent); }
|
||||||
|
|
||||||
|
.progress-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; margin-top: 4px; }
|
||||||
|
.progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.5s ease; }
|
||||||
|
|
||||||
|
.market-img-container img { width: 100%; height: auto; border-radius: 8px; margin-bottom: 16px; box-shadow: var(--shadow-sm); }
|
||||||
|
.outcomes-list { padding: 16px 24px; }
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Predictalytics — Multi-Platform Prediction Market Smart-Money Tracker & Analytics">
|
||||||
|
<title>Predictalytics</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<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">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<div class="logo-icon">P</div>
|
||||||
|
<span class="logo-text">Predictalytics</span>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<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>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
|
<span>Traders</span>
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
|
<span>Markets</span>
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
|
<span>Alerts</span>
|
||||||
|
<span class="badge" id="alertBadge" style="display:none">0</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<a href="/swagger" target="_blank" class="nav-item">
|
||||||
|
<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>
|
||||||
|
<span>API Docs</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<select id="platformSelect" class="platform-select">
|
||||||
|
<option value="All">All Platforms</option>
|
||||||
|
<option value="Polymarket">Polymarket</option>
|
||||||
|
<option value="Limitless">Limitless</option>
|
||||||
|
<option value="Azuro">Azuro</option>
|
||||||
|
</select>
|
||||||
|
<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>
|
||||||
|
<input type="text" id="searchInput" placeholder="Search traders, markets..." autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<select id="sortSelect" class="platform-select">
|
||||||
|
<option value="default">Sort by: Default</option>
|
||||||
|
<option value="score">Sort by: Score/Vol</option>
|
||||||
|
<option value="name">Sort by: Name/Date</option>
|
||||||
|
<option value="pnl">Sort by: PnL/Liq</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<div class="timeframe-toggle">
|
||||||
|
<button class="tf-btn active" data-tf="24h">24H</button>
|
||||||
|
<button class="tf-btn" data-tf="7d">7D</button>
|
||||||
|
<button class="tf-btn" data-tf="30d">30D</button>
|
||||||
|
</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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="page-content" id="pageContent">
|
||||||
|
<!-- Dashboard Page -->
|
||||||
|
<section class="page active" id="page-dashboard">
|
||||||
|
<h1 class="page-title">Dashboard</h1>
|
||||||
|
<!-- Metric Cards -->
|
||||||
|
<div class="metrics-grid" id="metricsGrid">
|
||||||
|
<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="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>
|
||||||
|
<!-- Charts Row -->
|
||||||
|
<div class="charts-row">
|
||||||
|
<div class="card chart-card">
|
||||||
|
<div class="card-header"><h2>Platform Breakdown</h2></div>
|
||||||
|
<div class="chart-container"><canvas id="platformChart"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="card chart-card">
|
||||||
|
<div class="card-header"><h2>Trader Tiers</h2></div>
|
||||||
|
<div class="chart-container"><canvas id="tierChart"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Top Traders Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h2>Top Traders</h2></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<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>Tier</th><th>Trades</th></tr></thead>
|
||||||
|
<tbody id="topTradersBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Recent Trades -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h2>Recent Trades</h2></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<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>
|
||||||
|
<tbody id="recentTradesBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Traders Page -->
|
||||||
|
<section class="page" id="page-traders">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;">
|
||||||
|
<h1 class="page-title" style="margin-bottom:0">Traders</h1>
|
||||||
|
<div class="card" style="margin-bottom:0; padding:12px 20px; display:flex; gap:12px; align-items:center;">
|
||||||
|
<span style="font-size:13px; font-weight:600">Add Trader:</span>
|
||||||
|
<select id="addPlatform" class="platform-select">
|
||||||
|
<option value="Polymarket">Polymarket</option>
|
||||||
|
<option value="Limitless">Limitless</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="addWallet" placeholder="Wallet Address" class="platform-select" style="width:300px">
|
||||||
|
<button class="btn-sm" onclick="manualAddTrader()" style="padding:6px 16px; background:var(--accent); color:white; border:none">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table"><thead><tr><th>#</th><th>Name</th><th>Platform</th><th>Score</th><th>Win Rate</th><th>PnL</th><th>Tier</th><th>Strategy</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody id="allTradersBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Markets Page -->
|
||||||
|
<section class="page" id="page-markets"><h1 class="page-title">Markets</h1><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></section>
|
||||||
|
|
||||||
|
<!-- Alerts Page -->
|
||||||
|
<section class="page" id="page-alerts"><h1 class="page-title">Alerts</h1><div class="card" id="alertsList"></div></section>
|
||||||
|
|
||||||
|
<!-- Search Results Page -->
|
||||||
|
<section class="page" id="page-search">
|
||||||
|
<h1 class="page-title" id="search-title">Search Results</h1>
|
||||||
|
<div id="searchResults">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h2>Traders</h2></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<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>
|
||||||
|
<tbody id="searchTradersBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h2>Markets</h2></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Platform</th><th>Question</th><th>Volume</th><th>Status</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody id="searchMarketsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Trader Detail Page -->
|
||||||
|
<section class="page" id="page-trader-detail">
|
||||||
|
<div class="detail-header" style="justify-content:space-between">
|
||||||
|
<div style="display:flex; align-items:center; gap:16px">
|
||||||
|
<button class="btn-back" onclick="navigateBack()">← Back</button>
|
||||||
|
<h1 class="page-title" id="td-name" style="margin-bottom:0">Trader Name</h1>
|
||||||
|
</div>
|
||||||
|
<button class="btn-sm" id="btn-refresh-trader" style="padding:8px 16px; background:var(--bg-input)">Sync History</button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="card detail-sidebar">
|
||||||
|
<div class="stat-group">
|
||||||
|
<div class="stat-label">Platform</div>
|
||||||
|
<div class="stat-value" id="td-platform">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-group">
|
||||||
|
<div class="stat-label">Platform ID</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>
|
||||||
|
<div class="stat-group">
|
||||||
|
<div class="stat-label">Strategy</div>
|
||||||
|
<div class="stat-value" id="td-strategy">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-main">
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<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">Total PnL</div><div class="metric-value" id="td-pnl">—</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 accent"><div class="metric-label">Score</div><div class="metric-value" id="td-score">—</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h2>Recent Trades</h2></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<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>
|
||||||
|
<tbody id="td-tradesBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Market Detail Page -->
|
||||||
|
<section class="page" id="page-market-detail">
|
||||||
|
<div class="detail-header">
|
||||||
|
<button class="btn-back" onclick="navigateBack()">← Back</button>
|
||||||
|
<h1 class="page-title" id="md-question">Market Question</h1>
|
||||||
|
</div>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="card detail-sidebar">
|
||||||
|
<div id="md-image" class="market-img-container"></div>
|
||||||
|
<div class="stat-group">
|
||||||
|
<div class="stat-label">Platform</div>
|
||||||
|
<div class="stat-value" id="md-platform">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-group">
|
||||||
|
<div class="stat-label">Category</div>
|
||||||
|
<div class="stat-value" id="md-category">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-group">
|
||||||
|
<div class="stat-label">Ends</div>
|
||||||
|
<div class="stat-value" id="md-ends">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-main">
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<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">Liquidity</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>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h2>Outcomes</h2></div>
|
||||||
|
<div class="outcomes-list" id="md-outcomes"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h2>Recent Trades</h2></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<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>
|
||||||
|
<tbody id="md-tradesBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
// Predictalytics Analytics — Dashboard Application
|
||||||
|
const API_BASE = '';
|
||||||
|
|
||||||
|
// ─── Theme Toggle ───
|
||||||
|
const themeToggle = document.getElementById('themeToggle');
|
||||||
|
const html = document.documentElement;
|
||||||
|
const savedTheme = localStorage.getItem('ba-theme') || 'dark';
|
||||||
|
html.setAttribute('data-theme', savedTheme);
|
||||||
|
themeToggle.addEventListener('click', () => {
|
||||||
|
const current = html.getAttribute('data-theme');
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
html.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('ba-theme', next);
|
||||||
|
updateChartColors();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Navigation ───
|
||||||
|
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
||||||
|
item.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const page = item.dataset.page;
|
||||||
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
|
document.getElementById(`page-${page}`).classList.add('active');
|
||||||
|
if (page === 'traders') loadTraders();
|
||||||
|
if (page === 'alerts') loadAlerts();
|
||||||
|
if (page === 'markets') loadMarkets();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let pageHistory = ['dashboard'];
|
||||||
|
function navigateTo(pageId) {
|
||||||
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
|
const page = document.getElementById(`page-${pageId}`);
|
||||||
|
if (page) {
|
||||||
|
page.classList.add('active');
|
||||||
|
pageHistory.push(pageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateBack() {
|
||||||
|
if (pageHistory.length > 1) {
|
||||||
|
pageHistory.pop();
|
||||||
|
const prev = pageHistory[pageHistory.length - 1];
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
|
document.getElementById(`page-${prev}`).classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Search ───
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
searchInput?.addEventListener('keypress', e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
if (query) performSearch(query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function performSearch(query) {
|
||||||
|
navigateTo('search');
|
||||||
|
document.getElementById('search-title').textContent = `Search Results for "${query}"`;
|
||||||
|
const data = await api(`/api/search?q=${encodeURIComponent(query)}`);
|
||||||
|
const tBody = document.getElementById('searchTradersBody');
|
||||||
|
const mBody = document.getElementById('searchMarketsBody');
|
||||||
|
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
tBody.innerHTML = data.traders.map((t, i) => `
|
||||||
|
<tr>
|
||||||
|
<td>${i + 1}</td>
|
||||||
|
<td><strong>${t.displayName}</strong></td>
|
||||||
|
<td>${t.platform}</td>
|
||||||
|
<td>${Number(t.combinedScore).toFixed(1)}</td>
|
||||||
|
<td>${fmt.tier(t.tier)}</td>
|
||||||
|
<td><button class="btn-sm" onclick="viewTrader(${t.id})">View</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('') || '<tr><td colspan="6">No traders found</td></tr>';
|
||||||
|
|
||||||
|
mBody.innerHTML = data.markets.map(m => `
|
||||||
|
<tr>
|
||||||
|
<td>${m.platform}</td>
|
||||||
|
<td title="${m.question}">${m.question.substring(0, 50)}...</td>
|
||||||
|
<td>${fmt.usd(m.volume)}</td>
|
||||||
|
<td>${m.isResolved ? 'Resolved' : 'Active'}</td>
|
||||||
|
<td><button class="btn-sm" onclick="viewMarket(${m.id})">View</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('') || '<tr><td colspan="5">No markets found</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manualAddTrader() {
|
||||||
|
const platform = document.getElementById('addPlatform').value;
|
||||||
|
const wallet = document.getElementById('addWallet').value.trim();
|
||||||
|
if (!wallet) return;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/traders?platform=${platform}&wallet=${wallet}`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
alert('Trader added successfully! Sync will start shortly.');
|
||||||
|
document.getElementById('addWallet').value = '';
|
||||||
|
viewTrader(data.id);
|
||||||
|
} else {
|
||||||
|
alert('Failed to add trader.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manualUpdateTrader(id) {
|
||||||
|
const res = await fetch(`/api/traders/${id}/refresh`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
alert('Sync triggered manually. Data will update in a few minutes.');
|
||||||
|
} else {
|
||||||
|
alert('Failed to trigger sync.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPlatform = 'All';
|
||||||
|
let currentSort = 'default';
|
||||||
|
|
||||||
|
document.getElementById('platformSelect')?.addEventListener('change', (e) => {
|
||||||
|
currentPlatform = e.target.value;
|
||||||
|
refreshActivePage();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sortSelect')?.addEventListener('change', (e) => {
|
||||||
|
currentSort = e.target.value;
|
||||||
|
refreshActivePage();
|
||||||
|
});
|
||||||
|
|
||||||
|
function refreshActivePage() {
|
||||||
|
const activePage = document.querySelector('.page.active')?.id;
|
||||||
|
if (activePage === 'page-dashboard') loadDashboard();
|
||||||
|
else if (activePage === 'page-traders') loadTraders();
|
||||||
|
else if (activePage === 'page-markets') loadMarkets();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API Helpers ───
|
||||||
|
async function api(endpoint) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}${endpoint}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`API Error [${endpoint}]:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Format Helpers ───
|
||||||
|
const fmt = {
|
||||||
|
usd: v => { if (v === null || v === undefined) return '$0'; const n = Number(v); return n >= 1000000 ? `$${(n/1000000).toFixed(1)}M` : n >= 1000 ? `$${(n/1000).toFixed(1)}K` : `$${n.toFixed(0)}`; },
|
||||||
|
pct: v => { if (v === null || v === undefined) return '0%'; return `${Number(v).toFixed(1)}%`; },
|
||||||
|
num: v => { if (v === null || v === undefined) return '0'; return Number(v).toLocaleString(); },
|
||||||
|
time: v => { if (!v) return '—'; const d = new Date(v); const now = new Date(); const diff = (now - d) / 1000;
|
||||||
|
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff/60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff/3600)}h ago`;
|
||||||
|
return d.toLocaleDateString(); },
|
||||||
|
tier: t => { if (!t) return '—'; const cls = `tier-${t.toString().toLowerCase()}`; return `<span class="tier-badge ${cls}">${t}</span>`; },
|
||||||
|
side: s => { if (!s) return '—'; return `<span class="side-${s.toString().toLowerCase()}">${s}</span>`; },
|
||||||
|
pnl: v => { if (v === null || v === undefined) return '$0'; const n = Number(v); return `<span class="${n >= 0 ? 'pnl-positive' : 'pnl-negative'}">${fmt.usd(Math.abs(n))}${n >= 0 ? ' ▲' : ' ▼'}</span>`; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Chart instances ───
|
||||||
|
let platformChart, tierChart;
|
||||||
|
|
||||||
|
function getChartColors() {
|
||||||
|
const isDark = html.getAttribute('data-theme') === 'dark';
|
||||||
|
return {
|
||||||
|
text: isDark ? '#AAAAAA' : '#666666',
|
||||||
|
grid: isDark ? '#2A2C33' : '#E8E9EC',
|
||||||
|
bg: isDark ? '#16181D' : '#FFFFFF'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChartColors() {
|
||||||
|
const c = getChartColors();
|
||||||
|
[platformChart, tierChart].forEach(chart => {
|
||||||
|
if (!chart) return;
|
||||||
|
if (chart.options.plugins?.legend) chart.options.plugins.legend.labels.color = c.text;
|
||||||
|
chart.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dashboard Load ───
|
||||||
|
async function loadDashboard() {
|
||||||
|
const data = await api('/api/dashboard');
|
||||||
|
if (!data) {
|
||||||
|
document.getElementById('metricTraders').textContent = '0';
|
||||||
|
document.getElementById('metricActive').textContent = '0';
|
||||||
|
document.getElementById('metricTrades').textContent = '0';
|
||||||
|
document.getElementById('metricVolume').textContent = '$0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
document.getElementById('metricTraders').textContent = fmt.num(data.totalTraders);
|
||||||
|
document.getElementById('metricActive').textContent = fmt.num(data.activeTraders24h);
|
||||||
|
document.getElementById('metricTrades').textContent = fmt.num(data.totalTrades);
|
||||||
|
document.getElementById('metricVolume').textContent = fmt.usd(data.totalVolume24h);
|
||||||
|
|
||||||
|
// Alert badge
|
||||||
|
const badge = document.getElementById('alertBadge');
|
||||||
|
if (data.unreadAlerts > 0) { badge.textContent = data.unreadAlerts; badge.style.display = 'inline'; }
|
||||||
|
else { badge.style.display = 'none'; }
|
||||||
|
|
||||||
|
// Top Traders table
|
||||||
|
const tbody = document.getElementById('topTradersBody');
|
||||||
|
tbody.innerHTML = data.topTraders.map((t, i) => `
|
||||||
|
<tr onclick="viewTrader(${t.id})" style="cursor:pointer">
|
||||||
|
<td>${i + 1}</td>
|
||||||
|
<td><strong>${t.displayName}</strong></td>
|
||||||
|
<td>${t.platform}</td>
|
||||||
|
<td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
|
||||||
|
<td>${fmt.pct(t.winRate)}</td>
|
||||||
|
<td>${fmt.pnl(t.totalPnl)}</td>
|
||||||
|
<td>${fmt.tier(t.tier)}</td>
|
||||||
|
<td>${fmt.num(t.totalTrades)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Recent Trades table
|
||||||
|
const rBody = document.getElementById('recentTradesBody');
|
||||||
|
rBody.innerHTML = data.recentTrades.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td>${fmt.time(t.executedAt)}</td>
|
||||||
|
<td onclick="viewTrader(${t.traderId})" style="cursor:pointer; color:var(--primary)">${t.traderName}</td>
|
||||||
|
<td onclick="${t.dbMarketId ? `viewMarket(${t.dbMarketId})` : `''`}" style="cursor:${t.dbMarketId ? 'pointer' : 'default'}" title="Market ID: ${t.marketId}">
|
||||||
|
${t.dbMarketId ? `<span style="color:var(--primary)">Market #${t.dbMarketId}</span>` : (t.marketId ? t.marketId.substring(0, 12) + '...' : '—')}
|
||||||
|
</td>
|
||||||
|
<td>${fmt.side(t.side)}</td>
|
||||||
|
<td>${Number(t.price).toFixed(2)}</td>
|
||||||
|
<td>${fmt.num(t.size)}</td>
|
||||||
|
<td>${fmt.usd(t.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Platform Chart
|
||||||
|
const cc = getChartColors();
|
||||||
|
const pLabels = Object.keys(data.platformBreakdown.traderCounts);
|
||||||
|
const pData = Object.values(data.platformBreakdown.traderCounts);
|
||||||
|
|
||||||
|
if (platformChart) platformChart.destroy();
|
||||||
|
platformChart = new Chart(document.getElementById('platformChart'), {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: { labels: pLabels.length ? pLabels : ['No Data'], datasets: [{ data: pData.length ? pData : [1],
|
||||||
|
backgroundColor: ['#FF2D55', '#5AC8FA', '#FF9500', '#34C759', '#AF52DE', '#FF6B8A', '#30D158'],
|
||||||
|
borderWidth: 0 }] },
|
||||||
|
options: { responsive: true, maintainAspectRatio: false, cutout: '70%',
|
||||||
|
plugins: { legend: { position: 'bottom', labels: { color: cc.text, padding: 16, font: { family: "'Inter'", size: 12 } } } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier chart
|
||||||
|
const tierData = data.topTraders.reduce((acc, t) => { acc[t.tier] = (acc[t.tier] || 0) + 1; return acc; }, {});
|
||||||
|
if (tierChart) tierChart.destroy();
|
||||||
|
const tLabels = Object.keys(tierData).length ? Object.keys(tierData) : ['No Data'];
|
||||||
|
const tData = Object.values(tierData).length ? Object.values(tierData) : [1];
|
||||||
|
tierChart = new Chart(document.getElementById('tierChart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: { labels: tLabels, datasets: [{ label: 'Traders', data: tData,
|
||||||
|
backgroundColor: '#FF2D55', borderRadius: 6, barThickness: 32 }] },
|
||||||
|
options: { responsive: true, maintainAspectRatio: false,
|
||||||
|
scales: { x: { grid: { display: false }, ticks: { color: cc.text, font: { family: "'Inter'" } } },
|
||||||
|
y: { grid: { color: cc.grid }, ticks: { color: cc.text, font: { family: "'Inter'" } } } },
|
||||||
|
plugins: { legend: { display: false } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Traders Page ───
|
||||||
|
async function loadTraders() {
|
||||||
|
let url = '/api/traders?skip=0&take=100';
|
||||||
|
if (currentPlatform !== 'All') url += `&platform=${currentPlatform}`;
|
||||||
|
let data = await api(url);
|
||||||
|
const tbody = document.getElementById('allTradersBody');
|
||||||
|
if (!data || !data.length) { tbody.innerHTML = '<tr><td colspan="9"><div class="empty-state"><p>No traders tracked yet.</p></div></td></tr>'; return; }
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
if (currentSort === 'score') data.sort((a, b) => b.combinedScore - a.combinedScore);
|
||||||
|
else if (currentSort === 'name') data.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||||
|
else if (currentSort === 'pnl') data.sort((a, b) => b.totalPnl - a.totalPnl);
|
||||||
|
|
||||||
|
tbody.innerHTML = data.map((t, i) => `
|
||||||
|
<tr>
|
||||||
|
<td>${i + 1}</td>
|
||||||
|
<td><strong>${t.displayName}</strong></td>
|
||||||
|
<td>${t.platform}</td>
|
||||||
|
<td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
|
||||||
|
<td>${fmt.pct(t.winRate)}</td>
|
||||||
|
<td>${fmt.pnl(t.totalPnl)}</td>
|
||||||
|
<td>${fmt.tier(t.tier)}</td>
|
||||||
|
<td>${t.strategy}</td>
|
||||||
|
<td><button class="btn-sm" onclick="viewTrader(${t.id})">Details</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Alerts Page ───
|
||||||
|
async function loadAlerts() {
|
||||||
|
const data = await api('/api/alerts?count=50');
|
||||||
|
const el = document.getElementById('alertsList');
|
||||||
|
if (!data || !data.length) { el.innerHTML = '<div class="empty-state"><p>No alerts yet.</p></div>'; return; }
|
||||||
|
el.innerHTML = data.map(a => `
|
||||||
|
<div class="alert-item ${a.isRead ? '' : 'alert-unread'}">
|
||||||
|
<div class="alert-icon alert-severity-${a.severity}">🔔</div>
|
||||||
|
<div class="alert-content">
|
||||||
|
<div class="alert-title">${a.title}</div>
|
||||||
|
<div class="alert-message">${a.message}</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert-time">${fmt.time(a.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Markets Page ───
|
||||||
|
async function loadMarkets() {
|
||||||
|
let url = '/api/markets?skip=0&take=100';
|
||||||
|
if (currentPlatform !== 'All') url += `&platform=${currentPlatform}`;
|
||||||
|
let data = await api(url);
|
||||||
|
const tbody = document.getElementById('allMarketsBody');
|
||||||
|
if (!data || !data.length) { tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><p>No markets found.</p></div></td></tr>'; return; }
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
if (currentSort === 'score') data.sort((a, b) => b.volume - a.volume);
|
||||||
|
else if (currentSort === 'name') data.sort((a, b) => (a.endDate || '').localeCompare(b.endDate || ''));
|
||||||
|
else if (currentSort === 'pnl') data.sort((a, b) => b.liquidity - a.liquidity);
|
||||||
|
|
||||||
|
tbody.innerHTML = data.map(m => `
|
||||||
|
<tr onclick="viewMarket(${m.id})" style="cursor:pointer">
|
||||||
|
<td>${m.platform}</td>
|
||||||
|
<td title="${m.question}"><strong>${m.question.length > 60 ? m.question.substring(0, 60) + '...' : m.question}</strong></td>
|
||||||
|
<td>${fmt.usd(m.volume)}</td>
|
||||||
|
<td>${fmt.usd(m.liquidity)}</td>
|
||||||
|
<td>${m.endDate ? new Date(m.endDate).toLocaleDateString() : '—'}</td>
|
||||||
|
<td>${m.isResolved ? '<span class="side-sell">Resolved</span>' : '<span class="side-buy">Active</span>'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewTrader(id) {
|
||||||
|
navigateTo('trader-detail');
|
||||||
|
const t = await api(`/api/traders/${id}`);
|
||||||
|
if (!t) return;
|
||||||
|
|
||||||
|
document.getElementById('td-name').textContent = t.displayName;
|
||||||
|
document.getElementById('td-platform').textContent = t.platform;
|
||||||
|
document.getElementById('td-platformId').textContent = t.platformUserId;
|
||||||
|
document.getElementById('td-tier').innerHTML = fmt.tier(t.tier);
|
||||||
|
document.getElementById('td-strategy').textContent = t.strategy;
|
||||||
|
document.getElementById('td-winrate').textContent = fmt.pct(t.winRate);
|
||||||
|
document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl);
|
||||||
|
document.getElementById('td-trades').textContent = fmt.num(t.totalTrades);
|
||||||
|
document.getElementById('td-score').textContent = Number(t.combinedScore).toFixed(1);
|
||||||
|
|
||||||
|
const refreshBtn = document.getElementById('btn-refresh-trader');
|
||||||
|
refreshBtn.onclick = () => manualUpdateTrader(id);
|
||||||
|
|
||||||
|
const tbody = document.getElementById('td-tradesBody');
|
||||||
|
tbody.innerHTML = t.recentTrades.map(tr => `
|
||||||
|
<tr>
|
||||||
|
<td>${fmt.time(tr.executedAt)}</td>
|
||||||
|
<td onclick="${tr.dbMarketId ? `viewMarket(${tr.dbMarketId})` : `''`}" style="cursor:${tr.dbMarketId ? 'pointer' : 'default'}; color:${tr.dbMarketId ? 'var(--primary)' : 'inherit'}" title="Market ID: ${tr.marketId}">
|
||||||
|
${tr.dbMarketId ? `Market #${tr.dbMarketId}` : (tr.marketId ? tr.marketId.substring(0, 16) + '...' : '—')}
|
||||||
|
</td>
|
||||||
|
<td>${fmt.side(tr.side)}</td>
|
||||||
|
<td>${Number(tr.price).toFixed(2)}</td>
|
||||||
|
<td>${fmt.num(tr.size)}</td>
|
||||||
|
<td>${fmt.usd(tr.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewMarket(id) {
|
||||||
|
navigateTo('market-detail');
|
||||||
|
const m = await api(`/api/markets/${id}`);
|
||||||
|
if (!m) return;
|
||||||
|
|
||||||
|
document.getElementById('md-question').textContent = m.question;
|
||||||
|
document.getElementById('md-platform').textContent = m.platform;
|
||||||
|
document.getElementById('md-category').textContent = m.category;
|
||||||
|
document.getElementById('md-ends').textContent = m.endDate ? new Date(m.endDate).toLocaleDateString() : 'Never';
|
||||||
|
document.getElementById('md-volume').textContent = fmt.usd(m.volume);
|
||||||
|
document.getElementById('md-liquidity').textContent = fmt.usd(m.liquidity);
|
||||||
|
document.getElementById('md-status').textContent = m.isResolved ? 'Resolved' : 'Active';
|
||||||
|
|
||||||
|
const imgContainer = document.getElementById('md-image');
|
||||||
|
if (m.imageUrl) imgContainer.innerHTML = `<img src="${m.imageUrl}" alt="Market" style="width:100%; border-radius:8px; margin-bottom:16px;">`;
|
||||||
|
else imgContainer.innerHTML = '';
|
||||||
|
|
||||||
|
const oList = document.getElementById('md-outcomes');
|
||||||
|
oList.innerHTML = m.outcomes.map(o => `
|
||||||
|
<div class="outcome-row">
|
||||||
|
<span>${o.name}</span>
|
||||||
|
<strong>${(o.price * 100).toFixed(1)}¢</strong>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:${o.price * 100}%"></div></div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const tbody = document.getElementById('md-tradesBody');
|
||||||
|
tbody.innerHTML = m.recentTrades.map(tr => `
|
||||||
|
<tr>
|
||||||
|
<td>${fmt.time(tr.executedAt)}</td>
|
||||||
|
<td onclick="viewTrader(${tr.traderId})" style="cursor:pointer; color:var(--primary)">${tr.traderName}</td>
|
||||||
|
<td>${fmt.side(tr.side)}</td>
|
||||||
|
<td>${Number(tr.price).toFixed(2)}</td>
|
||||||
|
<td>${fmt.num(tr.size)}</td>
|
||||||
|
<td>${fmt.usd(tr.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewMarketByQuestion(question) {
|
||||||
|
// Legacy helper: search markets by question text
|
||||||
|
const data = await api(`/api/search?q=${encodeURIComponent(question)}`);
|
||||||
|
if (data && data.markets.length > 0) {
|
||||||
|
viewMarket(data.markets[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewMarketById(dbMarketId) {
|
||||||
|
if (dbMarketId) viewMarket(dbMarketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Initial Load ───
|
||||||
|
loadDashboard();
|
||||||
|
|
||||||
|
// Auto-refresh every 30 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
const activePage = document.querySelector('.page.active');
|
||||||
|
if (activePage?.id === 'page-dashboard') loadDashboard();
|
||||||
|
}, 30000);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Predictalytics.Application.DTOs;
|
||||||
|
|
||||||
|
public record DashboardDto(
|
||||||
|
int TotalTraders,
|
||||||
|
int ActiveTraders24h,
|
||||||
|
int TotalTrades,
|
||||||
|
decimal TotalVolume24h,
|
||||||
|
int UnreadAlerts,
|
||||||
|
int WatchlistCount,
|
||||||
|
IReadOnlyList<TraderDto> TopTraders,
|
||||||
|
IReadOnlyList<TradeDto> RecentTrades,
|
||||||
|
IReadOnlyList<AlertDto> RecentAlerts,
|
||||||
|
PlatformBreakdownDto PlatformBreakdown
|
||||||
|
);
|
||||||
|
|
||||||
|
public record PlatformBreakdownDto(
|
||||||
|
Dictionary<string, int> TraderCounts,
|
||||||
|
Dictionary<string, decimal> VolumeCounts
|
||||||
|
);
|
||||||
|
|
||||||
|
public record AlertDto(
|
||||||
|
int Id,
|
||||||
|
string Type,
|
||||||
|
string Platform,
|
||||||
|
string Title,
|
||||||
|
string Message,
|
||||||
|
int Severity,
|
||||||
|
bool IsRead,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
string? TraderName
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Predictalytics.Application.DTOs;
|
||||||
|
|
||||||
|
public class MarketDetailDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Platform { get; set; } = "";
|
||||||
|
public string PlatformMarketId { get; set; } = "";
|
||||||
|
public string Question { get; set; } = "";
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public double Volume { get; set; }
|
||||||
|
public double Liquidity { get; set; }
|
||||||
|
public DateTime? EndDate { get; set; }
|
||||||
|
public bool IsResolved { get; set; }
|
||||||
|
public string? ResolutionOutcome { get; set; }
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
public IReadOnlyList<MarketOutcomeDto> Outcomes { get; set; } = new List<MarketOutcomeDto>();
|
||||||
|
public IReadOnlyList<TradeDto> RecentTrades { get; set; } = new List<TradeDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MarketOutcomeDto
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public double Price { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Predictalytics.Application.DTOs;
|
||||||
|
|
||||||
|
public class MarketDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Platform { get; set; } = "";
|
||||||
|
public string Question { get; set; } = "";
|
||||||
|
public double Volume { get; set; }
|
||||||
|
public double Liquidity { get; set; }
|
||||||
|
public DateTime? EndDate { get; set; }
|
||||||
|
public bool IsResolved { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Predictalytics.Application.DTOs;
|
||||||
|
|
||||||
|
public class SearchResultsDto
|
||||||
|
{
|
||||||
|
public IReadOnlyList<TraderDto> Traders { get; set; } = new List<TraderDto>();
|
||||||
|
public IReadOnlyList<MarketDto> Markets { get; set; } = new List<MarketDto>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.DTOs;
|
||||||
|
|
||||||
|
public record TradeDto(
|
||||||
|
long Id,
|
||||||
|
int TraderId,
|
||||||
|
string TraderName,
|
||||||
|
string Platform,
|
||||||
|
int? DbMarketId,
|
||||||
|
string MarketId,
|
||||||
|
string Outcome,
|
||||||
|
string Side,
|
||||||
|
decimal Price,
|
||||||
|
decimal Size,
|
||||||
|
decimal Amount,
|
||||||
|
DateTime ExecutedAt
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.DTOs;
|
||||||
|
|
||||||
|
public record TraderDeepDiveDto(
|
||||||
|
int TraderId,
|
||||||
|
string DisplayName,
|
||||||
|
PlatformType Platform,
|
||||||
|
StrategyType ClassifiedStrategy,
|
||||||
|
bool IsSuspectedBot,
|
||||||
|
decimal AvgHoldDurationHours,
|
||||||
|
decimal AvgPositionSizeUsd,
|
||||||
|
int MarketsTraded,
|
||||||
|
decimal HedgingFrequency,
|
||||||
|
decimal TimingAccuracy,
|
||||||
|
decimal EntryQuality,
|
||||||
|
decimal ExitQuality,
|
||||||
|
string[] BotIndicators,
|
||||||
|
string Summary,
|
||||||
|
IReadOnlyList<TradeDto> TradeHistory
|
||||||
|
);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.DTOs;
|
||||||
|
|
||||||
|
public record TraderDto(
|
||||||
|
int Id,
|
||||||
|
string Platform,
|
||||||
|
string PlatformUserId,
|
||||||
|
string DisplayName,
|
||||||
|
string Tier,
|
||||||
|
string Strategy,
|
||||||
|
decimal CombinedScore,
|
||||||
|
decimal WinRate,
|
||||||
|
decimal TotalPnl,
|
||||||
|
int TotalTrades,
|
||||||
|
bool IsOnWatchlist,
|
||||||
|
bool IsSuspectedBot,
|
||||||
|
DateTime? LastPolledAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public record TraderDetailDto(
|
||||||
|
int Id,
|
||||||
|
string Platform,
|
||||||
|
string PlatformUserId,
|
||||||
|
string DisplayName,
|
||||||
|
string? Notes,
|
||||||
|
string Tier,
|
||||||
|
string Strategy,
|
||||||
|
bool IsSuspectedBot,
|
||||||
|
int? ManualPriorityOverride,
|
||||||
|
decimal WinRate,
|
||||||
|
decimal TotalPnl,
|
||||||
|
int TotalTrades,
|
||||||
|
decimal ActivityScore,
|
||||||
|
decimal QualityScore,
|
||||||
|
decimal VolumeScore,
|
||||||
|
decimal TimingScore,
|
||||||
|
decimal CombinedScore,
|
||||||
|
int Rank,
|
||||||
|
bool IsOnWatchlist,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime? LastPolledAt,
|
||||||
|
IReadOnlyList<TradeDto> RecentTrades
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Predictalytics.Application.DTOs;
|
||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IAlertService
|
||||||
|
{
|
||||||
|
/// <summary>Evaluate alert rules against recent activity and create alerts as needed.</summary>
|
||||||
|
Task EvaluateAlertsAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Create a custom alert.</summary>
|
||||||
|
Task CreateAlertAsync(Alert alert, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Get recent alerts.</summary>
|
||||||
|
Task<IReadOnlyList<AlertDto>> GetRecentAlertsAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Mark an alert as read.</summary>
|
||||||
|
Task MarkAsReadAsync(int alertId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Predictalytics.Application.DTOs;
|
||||||
|
using Predictalytics.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IAnalyticsService
|
||||||
|
{
|
||||||
|
/// <summary>Get the main dashboard summary.</summary>
|
||||||
|
Task<DashboardDto> GetDashboardAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Perform deep-dive analysis on a specific trader.</summary>
|
||||||
|
Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Get trader list with scores.</summary>
|
||||||
|
Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Get a trader's details.</summary>
|
||||||
|
Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Get list of markets.</summary>
|
||||||
|
Task<IReadOnlyList<MarketDto>> GetMarketsAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Get market details.</summary>
|
||||||
|
Task<MarketDetailDto?> GetMarketDetailAsync(int marketId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Search for traders and markets.</summary>
|
||||||
|
Task<SearchResultsDto> SearchAsync(string query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Manually trigger a trade history sync for a specific trader.</summary>
|
||||||
|
Task TriggerTradeSyncAsync(int traderId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Manually add a trader by platform and wallet address.</summary>
|
||||||
|
Task<int> AddTraderAsync(string platform, string walletAddress, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IDiscoveryService
|
||||||
|
{
|
||||||
|
/// <summary>Run discovery to find new/notable traders on a platform.</summary>
|
||||||
|
Task<IReadOnlyList<DiscoveredTrader>> RunDiscoveryAsync(PlatformType platform, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Import a discovered trader into the tracking system.</summary>
|
||||||
|
Task<int> ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IPlatformStatisticsService
|
||||||
|
{
|
||||||
|
void TrackMarketSync(PlatformType platform, int count = 1);
|
||||||
|
void TrackTraderDiscovery(PlatformType platform, int count = 1);
|
||||||
|
void TrackTradeActivity(PlatformType platform, int count = 1);
|
||||||
|
|
||||||
|
Dictionary<PlatformType, PlatformStats> GetAndResetStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlatformStats
|
||||||
|
{
|
||||||
|
public int MarketsSynced { get; set; }
|
||||||
|
public int TradersDiscovered { get; set; }
|
||||||
|
public int TradesProcessed { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Token-bucket rate limiter for platform API calls.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRateLimiter
|
||||||
|
{
|
||||||
|
/// <summary>Wait until a request can be made to the given platform.</summary>
|
||||||
|
Task WaitAsync(PlatformType platform, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Check if a request can be made immediately.</summary>
|
||||||
|
bool CanMakeRequest(PlatformType platform);
|
||||||
|
|
||||||
|
/// <summary>Report that a 429 Too Many Requests was received.</summary>
|
||||||
|
void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IScoringService
|
||||||
|
{
|
||||||
|
/// <summary>Calculate and update priority score for a single trader.</summary>
|
||||||
|
Task<PriorityScore> CalculateScoreAsync(int traderId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Recalculate scores for all tracked traders.</summary>
|
||||||
|
Task RecalculateAllScoresAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Set a manual priority override for a trader.</summary>
|
||||||
|
Task SetManualOverrideAsync(int traderId, int? score, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>Predictalytics.Application</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Predictalytics.Domain\Predictalytics.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using Predictalytics.Application.DTOs;
|
||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alert evaluation and management service.
|
||||||
|
/// </summary>
|
||||||
|
public class AlertService : IAlertService
|
||||||
|
{
|
||||||
|
private readonly IAlertRepository _alertRepo;
|
||||||
|
private readonly ITradeRepository _tradeRepo;
|
||||||
|
private readonly ITraderRepository _traderRepo;
|
||||||
|
private readonly ILogger<AlertService> _logger;
|
||||||
|
|
||||||
|
// Alert thresholds (configurable in future)
|
||||||
|
private const decimal LargePositionThresholdUsd = 5000m;
|
||||||
|
|
||||||
|
public AlertService(
|
||||||
|
IAlertRepository alertRepo,
|
||||||
|
ITradeRepository tradeRepo,
|
||||||
|
ITraderRepository traderRepo,
|
||||||
|
ILogger<AlertService> logger)
|
||||||
|
{
|
||||||
|
_alertRepo = alertRepo;
|
||||||
|
_tradeRepo = tradeRepo;
|
||||||
|
_traderRepo = traderRepo;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EvaluateAlertsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Evaluating alert rules...");
|
||||||
|
|
||||||
|
// Check for large recent trades
|
||||||
|
var recentTrades = await _tradeRepo.GetRecentAsync(100, ct: ct);
|
||||||
|
var thirtyMinAgo = DateTime.UtcNow.AddMinutes(-30);
|
||||||
|
|
||||||
|
foreach (var trade in recentTrades.Where(t => t.ExecutedAt > thirtyMinAgo))
|
||||||
|
{
|
||||||
|
if (trade.Amount >= LargePositionThresholdUsd)
|
||||||
|
{
|
||||||
|
var trader = await _traderRepo.GetByIdAsync(trade.TraderId, ct);
|
||||||
|
var marketRef = trade.DbMarketId.HasValue
|
||||||
|
? $"Market #{trade.DbMarketId}"
|
||||||
|
: (!string.IsNullOrEmpty(trade.MarketId) ? $"Market {trade.MarketId[..Math.Min(12, trade.MarketId.Length)]}..." : "Unknown Market");
|
||||||
|
await CreateAlertAsync(new Alert
|
||||||
|
{
|
||||||
|
Type = AlertType.LargePosition,
|
||||||
|
Platform = trade.Platform,
|
||||||
|
TraderId = trade.TraderId,
|
||||||
|
Title = $"Large {trade.Side} detected",
|
||||||
|
Message = $"{trader?.DisplayName ?? "Unknown"} {trade.Side} ${trade.Amount:N0} on {marketRef} ({trade.Outcome} @ {trade.Price:P0})",
|
||||||
|
Severity = trade.Amount >= 25000 ? 4 : trade.Amount >= 10000 ? 3 : 2
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CreateAlertAsync(Alert alert, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _alertRepo.AddAsync(alert, ct);
|
||||||
|
_logger.LogInformation("🔔 Alert [{Type}]: {Title}", alert.Type, alert.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AlertDto>> GetRecentAlertsAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var alerts = await _alertRepo.GetRecentAsync(count, unreadOnly, ct);
|
||||||
|
var result = new List<AlertDto>();
|
||||||
|
|
||||||
|
foreach (var a in alerts)
|
||||||
|
{
|
||||||
|
string? traderName = null;
|
||||||
|
if (a.TraderId.HasValue)
|
||||||
|
{
|
||||||
|
var trader = await _traderRepo.GetByIdAsync(a.TraderId.Value, ct);
|
||||||
|
traderName = trader?.DisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new AlertDto(
|
||||||
|
a.Id, a.Type.ToString(), a.Platform.ToString(),
|
||||||
|
a.Title, a.Message, a.Severity, a.IsRead, a.CreatedAt, traderName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkAsReadAsync(int alertId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _alertRepo.MarkAsReadAsync(alertId, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
using Predictalytics.Application.DTOs;
|
||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Predictalytics.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Services;
|
||||||
|
|
||||||
|
public class AnalyticsService : IAnalyticsService
|
||||||
|
{
|
||||||
|
private readonly ITraderRepository _traderRepo;
|
||||||
|
private readonly ITradeRepository _tradeRepo;
|
||||||
|
private readonly IAlertRepository _alertRepo;
|
||||||
|
private readonly IWatchlistRepository _watchlistRepo;
|
||||||
|
private readonly IMarketRepository _marketRepo;
|
||||||
|
private readonly IDiscoveryService _discovery;
|
||||||
|
private readonly ILogger<AnalyticsService> _logger;
|
||||||
|
|
||||||
|
public AnalyticsService(ITraderRepository traderRepo, ITradeRepository tradeRepo,
|
||||||
|
IAlertRepository alertRepo, IWatchlistRepository watchlistRepo, IMarketRepository marketRepo,
|
||||||
|
IDiscoveryService discovery, ILogger<AnalyticsService> logger)
|
||||||
|
{
|
||||||
|
_traderRepo = traderRepo; _tradeRepo = tradeRepo;
|
||||||
|
_alertRepo = alertRepo; _watchlistRepo = watchlistRepo; _marketRepo = marketRepo;
|
||||||
|
_discovery = discovery; _logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardDto> GetDashboardAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var totalTraders = await _traderRepo.GetCountAsync(ct: ct);
|
||||||
|
var totalTrades = await _tradeRepo.GetCountAsync(ct: ct);
|
||||||
|
var volume24h = await _tradeRepo.GetTotalVolumeAsync(DateTime.UtcNow.AddHours(-24), ct);
|
||||||
|
var unreadAlerts = await _alertRepo.GetUnreadCountAsync(ct);
|
||||||
|
var watchlist = await _watchlistRepo.GetAllAsync(ct);
|
||||||
|
|
||||||
|
// Requirements:
|
||||||
|
// "Top Traders": successful 5 traders by PnL in the last 7 days.
|
||||||
|
var topTraders = await _traderRepo.GetTopByPnLAsync(5, DateTime.UtcNow.AddDays(-7), ct);
|
||||||
|
|
||||||
|
// "Recent Trades": 5 largest trades in the last 24h.
|
||||||
|
var largestTrades = await _tradeRepo.GetLargestAsync(5, DateTime.UtcNow.AddHours(-24), ct);
|
||||||
|
|
||||||
|
var recentTradesForActivity = await _tradeRepo.GetRecentAsync(500, ct: ct);
|
||||||
|
var activeTraders24h = recentTradesForActivity.Where(t => t.ExecutedAt > DateTime.UtcNow.AddHours(-24))
|
||||||
|
.Select(t => t.TraderId).Distinct().Count();
|
||||||
|
|
||||||
|
var watchlistIds = watchlist.Select(w => w.TraderId).ToHashSet();
|
||||||
|
var topTraderDtos = topTraders.Select(t => MapTraderDto(t, watchlistIds)).ToList();
|
||||||
|
var largestTradeDtos = largestTrades.Select(MapTradeDto).ToList();
|
||||||
|
|
||||||
|
var alerts = await _alertRepo.GetRecentAsync(10, ct: ct);
|
||||||
|
var alertDtos = alerts.Select(a => new AlertDto(a.Id, a.Type.ToString(), a.Platform.ToString(),
|
||||||
|
a.Title, a.Message, a.Severity, a.IsRead, a.CreatedAt, null)).ToList();
|
||||||
|
|
||||||
|
var allTraders = await _traderRepo.GetAllAsync(take: 1000, ct: ct); // Reduced from 10000 for perf
|
||||||
|
var traderCounts = allTraders.GroupBy(t => t.Platform.ToString()).ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
var volumeCounts = recentTradesForActivity.Where(t => t.ExecutedAt > DateTime.UtcNow.AddHours(-24))
|
||||||
|
.GroupBy(t => t.Platform.ToString()).ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
|
||||||
|
|
||||||
|
return new DashboardDto(totalTraders, activeTraders24h, totalTrades, volume24h,
|
||||||
|
unreadAlerts, watchlist.Count, topTraderDtos, largestTradeDtos, alertDtos,
|
||||||
|
new PlatformBreakdownDto(traderCounts, volumeCounts));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
|
||||||
|
if (trader == null) return null;
|
||||||
|
var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 500, ct);
|
||||||
|
var analysis = PerformDeepDive(trader, trades);
|
||||||
|
var tradeDtos = trades.Take(100).Select(MapTradeDto).ToList();
|
||||||
|
return new TraderDeepDiveDto(traderId, trader.DisplayName, trader.Platform,
|
||||||
|
analysis.ClassifiedStrategy, analysis.IsSuspectedBot, analysis.AvgHoldDurationHours,
|
||||||
|
analysis.AvgPositionSizeUsd, analysis.MarketsTraded, analysis.HedgingFrequency,
|
||||||
|
analysis.TimingAccuracy, analysis.EntryQuality, analysis.ExitQuality,
|
||||||
|
analysis.BotIndicators, analysis.Summary, tradeDtos);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
PlatformType? pType = null;
|
||||||
|
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt))
|
||||||
|
pType = pt;
|
||||||
|
|
||||||
|
var traders = await _traderRepo.GetAllAsync(platform: pType, skip: skip, take: take, ct: ct);
|
||||||
|
var watchlist = await _watchlistRepo.GetAllAsync(ct);
|
||||||
|
var wIds = watchlist.Select(w => w.TraderId).ToHashSet();
|
||||||
|
return traders.Select(t => MapTraderDto(t, wIds)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<MarketDto>> GetMarketsAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// NOTE: Currently IMarketRepository.GetActiveAsync doesn't support pagination/filtering.
|
||||||
|
// We will fetch all and filter in memory for now, or you can update repository.
|
||||||
|
// Let's use GetActiveAsync and map it.
|
||||||
|
var markets = await _marketRepo.GetActiveAsync(1000, ct);
|
||||||
|
|
||||||
|
PlatformType? pType = null;
|
||||||
|
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt))
|
||||||
|
pType = pt;
|
||||||
|
|
||||||
|
var query = markets.AsEnumerable();
|
||||||
|
if (pType.HasValue)
|
||||||
|
query = query.Where(m => m.Platform == pType.Value);
|
||||||
|
|
||||||
|
var result = query.Skip(skip).Take(take).Select(m => new MarketDto
|
||||||
|
{
|
||||||
|
Id = m.Id,
|
||||||
|
Platform = m.Platform.ToString(),
|
||||||
|
Question = m.Question,
|
||||||
|
Volume = (double)m.Volume,
|
||||||
|
Liquidity = (double)m.Liquidity,
|
||||||
|
EndDate = m.EndDate,
|
||||||
|
IsResolved = m.IsResolved
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
|
||||||
|
if (trader == null) return null;
|
||||||
|
var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 50, ct);
|
||||||
|
var wl = await _watchlistRepo.GetByTraderIdAsync(traderId, ct);
|
||||||
|
var s = trader.CurrentScore;
|
||||||
|
return new TraderDetailDto(trader.Id, trader.Platform.ToString(), trader.PlatformUserId, trader.DisplayName,
|
||||||
|
trader.Notes, trader.Tier.ToString(), trader.Strategy.ToString(), trader.IsSuspectedBot, trader.ManualPriorityOverride,
|
||||||
|
trader.WinRate, trader.TotalPnl, trader.TotalTrades,
|
||||||
|
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0,
|
||||||
|
s?.CombinedScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
|
||||||
|
trades.Select(MapTradeDto).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MarketDetailDto?> GetMarketDetailAsync(int marketId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var market = await _marketRepo.GetByIdAsync(marketId, ct);
|
||||||
|
if (market == null) return null;
|
||||||
|
|
||||||
|
// Prefer fast INT FK lookup; fall back to string scan for legacy unlinked trades
|
||||||
|
var recentTrades = await _tradeRepo.GetByDbMarketIdAsync(marketId, 0, 50, ct);
|
||||||
|
if (recentTrades.Count == 0)
|
||||||
|
{
|
||||||
|
// Fallback: trades ingested before DbMarketId backfill
|
||||||
|
recentTrades = await _tradeRepo.GetByMarketIdAsync(market.PlatformMarketId, 0, 50, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MarketDetailDto
|
||||||
|
{
|
||||||
|
Id = market.Id,
|
||||||
|
Platform = market.Platform.ToString(),
|
||||||
|
PlatformMarketId = market.PlatformMarketId,
|
||||||
|
Question = market.Question,
|
||||||
|
Description = market.Description,
|
||||||
|
Category = market.Category,
|
||||||
|
Volume = (double)market.Volume,
|
||||||
|
Liquidity = (double)market.Liquidity,
|
||||||
|
EndDate = market.EndDate,
|
||||||
|
IsResolved = market.IsResolved,
|
||||||
|
ResolutionOutcome = market.ResolutionOutcome,
|
||||||
|
ImageUrl = market.ImageUrl,
|
||||||
|
Outcomes = market.Outcomes.Select(o => new MarketOutcomeDto { Name = o.Label, Price = (double)o.CurrentPrice }).ToList(),
|
||||||
|
RecentTrades = recentTrades.Select(MapTradeDto).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SearchResultsDto> SearchAsync(string query, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var traders = await _traderRepo.SearchAsync(query, 20, ct);
|
||||||
|
var markets = await _marketRepo.SearchAsync(query, 20, ct);
|
||||||
|
|
||||||
|
var watchlist = await _watchlistRepo.GetAllAsync(ct);
|
||||||
|
var wIds = watchlist.Select(w => w.TraderId).ToHashSet();
|
||||||
|
|
||||||
|
return new SearchResultsDto
|
||||||
|
{
|
||||||
|
Traders = traders.Select(t => MapTraderDto(t, wIds)).ToList(),
|
||||||
|
Markets = markets.Select(m => new MarketDto
|
||||||
|
{
|
||||||
|
Id = m.Id,
|
||||||
|
Platform = m.Platform.ToString(),
|
||||||
|
Question = m.Question,
|
||||||
|
Volume = (double)m.Volume,
|
||||||
|
Liquidity = (double)m.Liquidity,
|
||||||
|
EndDate = m.EndDate,
|
||||||
|
IsResolved = m.IsResolved
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private TraderAnalysis PerformDeepDive(Trader trader, IReadOnlyList<Trade> trades)
|
||||||
|
{
|
||||||
|
if (trades.Count == 0)
|
||||||
|
return new TraderAnalysis(trader.Id, StrategyType.Unknown, false, 0, 0, 0, 0, 50, 50, 50,
|
||||||
|
Array.Empty<string>(), "Insufficient data.");
|
||||||
|
|
||||||
|
// Use DbMarketId when available, fall back to MarketId string for older trades
|
||||||
|
var marketKeys = trades
|
||||||
|
.Select(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId)
|
||||||
|
.Where(k => !string.IsNullOrEmpty(k))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var marketsTraded = marketKeys.Distinct().Count();
|
||||||
|
var avgSize = trades.Average(t => t.Amount);
|
||||||
|
var botIndicators = new List<string>();
|
||||||
|
var times = trades.Select(t => t.ExecutedAt).OrderBy(t => t).ToList();
|
||||||
|
if (times.Count > 10)
|
||||||
|
{
|
||||||
|
var intervals = times.Zip(times.Skip(1), (a, b) => (b - a).TotalSeconds).ToList();
|
||||||
|
if (intervals.Average() < 10) botIndicators.Add("Sub-10s trade frequency");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by unified market key to find hedged markets (both Yes and No held)
|
||||||
|
var hedgeGroups = trades
|
||||||
|
.GroupBy(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId)
|
||||||
|
.Where(g => !string.IsNullOrEmpty(g.Key) && g.Select(t => t.Outcome).Distinct().Count() > 1);
|
||||||
|
|
||||||
|
var hedgingRate = marketsTraded > 0 ? (decimal)hedgeGroups.Count() / marketsTraded * 100 : 0;
|
||||||
|
var strategy = avgSize > 10000 ? StrategyType.Whale : hedgingRate > 30 ? StrategyType.Hedger :
|
||||||
|
botIndicators.Count > 0 ? StrategyType.Bot : StrategyType.Unknown;
|
||||||
|
return new TraderAnalysis(trader.Id, strategy, botIndicators.Count > 1, 0, avgSize, marketsTraded,
|
||||||
|
hedgingRate, 50, 50, 50, botIndicators.ToArray(),
|
||||||
|
$"{trader.DisplayName}: {strategy}, {marketsTraded} markets, avg ${avgSize:N0}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TriggerTradeSyncAsync(int traderId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
|
||||||
|
if (trader != null)
|
||||||
|
{
|
||||||
|
trader.LastTradesUpdatedAt = null; // Force worker to pick it up
|
||||||
|
await _traderRepo.UpdateAsync(trader, ct);
|
||||||
|
_logger.LogInformation("Manually triggered trade sync for trader {TraderId} ({Name})", traderId, trader.DisplayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> AddTraderAsync(string platform, string walletAddress, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<PlatformType>(platform, true, out var pType))
|
||||||
|
throw new ArgumentException($"Invalid platform: {platform}");
|
||||||
|
|
||||||
|
_logger.LogInformation("Manually adding trader {Wallet} for platform {Platform}", walletAddress, platform);
|
||||||
|
return await _discovery.ImportTraderAsync(pType, walletAddress, walletAddress[..Math.Min(10, walletAddress.Length)] + "...", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TraderDto MapTraderDto(Trader t, HashSet<int> wIds) => new(
|
||||||
|
t.Id, t.Platform.ToString(), t.PlatformUserId, t.DisplayName, t.Tier.ToString(), t.Strategy.ToString(),
|
||||||
|
t.CurrentScore?.CombinedScore ?? 0, t.WinRate, t.TotalPnl, t.TotalTrades,
|
||||||
|
wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt);
|
||||||
|
|
||||||
|
private static TradeDto MapTradeDto(Trade t) => new(
|
||||||
|
t.Id, t.TraderId, t.Trader?.DisplayName ?? "—", t.Platform.ToString(),
|
||||||
|
t.DbMarketId, t.MarketId, t.Outcome, t.Side.ToString(), t.Price, t.Size, t.Amount, t.ExecutedAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers new notable traders on prediction market platforms.
|
||||||
|
/// </summary>
|
||||||
|
public class DiscoveryService : IDiscoveryService
|
||||||
|
{
|
||||||
|
private readonly IEnumerable<IPlatformProvider> _providers;
|
||||||
|
private readonly ITraderRepository _traderRepo;
|
||||||
|
private readonly IRateLimiter _rateLimiter;
|
||||||
|
private readonly ILogger<DiscoveryService> _logger;
|
||||||
|
|
||||||
|
public DiscoveryService(
|
||||||
|
IEnumerable<IPlatformProvider> providers,
|
||||||
|
ITraderRepository traderRepo,
|
||||||
|
IRateLimiter rateLimiter,
|
||||||
|
ILogger<DiscoveryService> logger)
|
||||||
|
{
|
||||||
|
_providers = providers;
|
||||||
|
_traderRepo = traderRepo;
|
||||||
|
_rateLimiter = rateLimiter;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DiscoveredTrader>> RunDiscoveryAsync(PlatformType platform, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var provider = _providers.FirstOrDefault(p => p.Platform == platform);
|
||||||
|
if (provider == null || !provider.IsImplemented)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No implemented provider for platform {Platform}", platform);
|
||||||
|
return Array.Empty<DiscoveredTrader>();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _rateLimiter.WaitAsync(platform, ct);
|
||||||
|
|
||||||
|
_logger.LogInformation("[{Platform}] Running trader discovery scan...", platform);
|
||||||
|
var discovered = await provider.DiscoverTradersAsync(50, ct);
|
||||||
|
|
||||||
|
int newCount = 0;
|
||||||
|
foreach (var d in discovered)
|
||||||
|
{
|
||||||
|
var existing = await _traderRepo.GetByPlatformIdAsync(platform, d.PlatformUserId, ct);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
await ImportTraderAsync(platform, d.PlatformUserId, d.DisplayName, ct);
|
||||||
|
newCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[{Platform}] Discovery complete: {Total} found, {New} new traders imported",
|
||||||
|
platform, discovered.Count, newCount);
|
||||||
|
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var existing = await _traderRepo.GetByPlatformIdAsync(platform, platformUserId, ct);
|
||||||
|
if (existing != null) return existing.Id;
|
||||||
|
|
||||||
|
var trader = new Trader
|
||||||
|
{
|
||||||
|
Platform = platform,
|
||||||
|
PlatformUserId = platformUserId,
|
||||||
|
DisplayName = string.IsNullOrEmpty(displayName) ? platformUserId[..8] + "..." : displayName,
|
||||||
|
IsAutoDiscovered = true,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await _traderRepo.AddAsync(trader, ct);
|
||||||
|
_logger.LogInformation("[{Platform}] Imported new trader: {Name} ({Id})",
|
||||||
|
platform, trader.DisplayName, trader.PlatformUserId);
|
||||||
|
|
||||||
|
return trader.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Services;
|
||||||
|
|
||||||
|
public class PlatformStatisticsService : IPlatformStatisticsService
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<PlatformType, PlatformStats> _stats = new();
|
||||||
|
|
||||||
|
public void TrackMarketSync(PlatformType platform, int count = 1)
|
||||||
|
{
|
||||||
|
var stats = _stats.GetOrAdd(platform, _ => new PlatformStats());
|
||||||
|
lock (stats) stats.MarketsSynced += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackTraderDiscovery(PlatformType platform, int count = 1)
|
||||||
|
{
|
||||||
|
var stats = _stats.GetOrAdd(platform, _ => new PlatformStats());
|
||||||
|
lock (stats) stats.TradersDiscovered += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackTradeActivity(PlatformType platform, int count = 1)
|
||||||
|
{
|
||||||
|
var stats = _stats.GetOrAdd(platform, _ => new PlatformStats());
|
||||||
|
lock (stats) stats.TradesProcessed += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<PlatformType, PlatformStats> GetAndResetStats()
|
||||||
|
{
|
||||||
|
var result = new Dictionary<PlatformType, PlatformStats>();
|
||||||
|
var platforms = Enum.GetValues<PlatformType>();
|
||||||
|
|
||||||
|
foreach (var p in platforms)
|
||||||
|
{
|
||||||
|
if (_stats.TryRemove(p, out var stats))
|
||||||
|
{
|
||||||
|
result[p] = stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Token-bucket rate limiter with per-platform configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class RateLimiterService : IRateLimiter
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<PlatformType, SemaphoreSlim> _semaphores = new();
|
||||||
|
private readonly ConcurrentDictionary<PlatformType, DateTime> _lastRequest = new();
|
||||||
|
private readonly ConcurrentDictionary<PlatformType, DateTime> _blockedUntil = new();
|
||||||
|
|
||||||
|
// Minimum delay between requests per platform (milliseconds)
|
||||||
|
private static readonly Dictionary<PlatformType, int> PlatformDelays = new()
|
||||||
|
{
|
||||||
|
{ PlatformType.Polymarket, 200 },
|
||||||
|
{ PlatformType.Limitless, 500 },
|
||||||
|
{ PlatformType.Azuro, 1000 },
|
||||||
|
{ PlatformType.Myriad, 1000 },
|
||||||
|
{ PlatformType.PredictFun, 1000 },
|
||||||
|
{ PlatformType.Kalshi, 500 },
|
||||||
|
{ PlatformType.Stake, 1000 }
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task WaitAsync(PlatformType platform, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sem = _semaphores.GetOrAdd(platform, _ => new SemaphoreSlim(1, 1));
|
||||||
|
await sem.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. Check if we are currently blocked due to a 429
|
||||||
|
if (_blockedUntil.TryGetValue(platform, out var blockedUntil))
|
||||||
|
{
|
||||||
|
var waitTime = blockedUntil - DateTime.UtcNow;
|
||||||
|
if (waitTime > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
await Task.Delay(waitTime, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Normal token bucket delay
|
||||||
|
if (_lastRequest.TryGetValue(platform, out var last))
|
||||||
|
{
|
||||||
|
var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000);
|
||||||
|
var elapsed = (DateTime.UtcNow - last).TotalMilliseconds;
|
||||||
|
if (elapsed < delayMs)
|
||||||
|
await Task.Delay((int)(delayMs - elapsed), ct);
|
||||||
|
}
|
||||||
|
_lastRequest[platform] = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
finally { sem.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanMakeRequest(PlatformType platform)
|
||||||
|
{
|
||||||
|
if (_blockedUntil.TryGetValue(platform, out var blockedUntil) && blockedUntil > DateTime.UtcNow)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!_lastRequest.TryGetValue(platform, out var last)) return true;
|
||||||
|
var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000);
|
||||||
|
return (DateTime.UtcNow - last).TotalMilliseconds >= delayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null)
|
||||||
|
{
|
||||||
|
var penalty = retryAfter ?? TimeSpan.FromSeconds(30);
|
||||||
|
_blockedUntil[platform] = DateTime.UtcNow.Add(penalty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Predictalytics.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates priority and quality scores for traders based on their activity,
|
||||||
|
/// performance, and trading patterns.
|
||||||
|
/// </summary>
|
||||||
|
public class ScoringService : IScoringService
|
||||||
|
{
|
||||||
|
private readonly ITraderRepository _traderRepo;
|
||||||
|
private readonly ITradeRepository _tradeRepo;
|
||||||
|
private readonly ILogger<ScoringService> _logger;
|
||||||
|
|
||||||
|
// Scoring weights (configurable in future)
|
||||||
|
private const decimal ActivityWeight = 0.25m;
|
||||||
|
private const decimal QualityWeight = 0.35m;
|
||||||
|
private const decimal VolumeWeight = 0.20m;
|
||||||
|
private const decimal TimingWeight = 0.20m;
|
||||||
|
|
||||||
|
public ScoringService(ITraderRepository traderRepo, ITradeRepository tradeRepo, ILogger<ScoringService> logger)
|
||||||
|
{
|
||||||
|
_traderRepo = traderRepo;
|
||||||
|
_tradeRepo = tradeRepo;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PriorityScore> CalculateScoreAsync(int traderId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
|
||||||
|
if (trader == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Cannot score trader {TraderId}: not found", traderId);
|
||||||
|
return new PriorityScore(0, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 200, ct);
|
||||||
|
|
||||||
|
// Activity Score: based on trade frequency and recency
|
||||||
|
var activityScore = CalculateActivityScore(trades);
|
||||||
|
|
||||||
|
// Quality Score: based on win rate and PnL
|
||||||
|
var qualityScore = CalculateQualityScore(trader);
|
||||||
|
|
||||||
|
// Volume Score: based on average trade size
|
||||||
|
var volumeScore = CalculateVolumeScore(trades);
|
||||||
|
|
||||||
|
// Timing Score: based on entry/exit timing quality
|
||||||
|
var timingScore = CalculateTimingScore(trades);
|
||||||
|
|
||||||
|
// Combined weighted score
|
||||||
|
var combined = Math.Round(
|
||||||
|
activityScore * ActivityWeight +
|
||||||
|
qualityScore * QualityWeight +
|
||||||
|
volumeScore * VolumeWeight +
|
||||||
|
timingScore * TimingWeight, 2);
|
||||||
|
|
||||||
|
var score = new PriorityScore(activityScore, qualityScore, volumeScore, timingScore, combined, trader.ManualPriorityOverride);
|
||||||
|
|
||||||
|
// Persist score
|
||||||
|
var traderScore = trader.CurrentScore ?? new TraderScore { TraderId = traderId };
|
||||||
|
traderScore.ActivityScore = activityScore;
|
||||||
|
traderScore.QualityScore = qualityScore;
|
||||||
|
traderScore.VolumeScore = volumeScore;
|
||||||
|
traderScore.TimingScore = timingScore;
|
||||||
|
traderScore.CombinedScore = combined;
|
||||||
|
traderScore.CalculatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
trader.CurrentScore = traderScore;
|
||||||
|
trader.Tier = score.DetermineTier();
|
||||||
|
await _traderRepo.UpdateAsync(trader, ct);
|
||||||
|
|
||||||
|
_logger.LogInformation("Scored trader {TraderName} ({TraderId}): Combined={Score}, Tier={Tier}",
|
||||||
|
trader.DisplayName, traderId, combined, trader.Tier);
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RecalculateAllScoresAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var traders = await _traderRepo.GetAllAsync(take: 1000, ct: ct);
|
||||||
|
_logger.LogInformation("Recalculating scores for {Count} traders", traders.Count);
|
||||||
|
|
||||||
|
int rank = 1;
|
||||||
|
var scored = new List<(int TraderId, decimal Score)>();
|
||||||
|
|
||||||
|
foreach (var trader in traders)
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
|
var score = await CalculateScoreAsync(trader.Id, ct);
|
||||||
|
scored.Add((trader.Id, score.EffectiveScore));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ranks
|
||||||
|
foreach (var (id, _) in scored.OrderByDescending(s => s.Score))
|
||||||
|
{
|
||||||
|
var trader = await _traderRepo.GetByIdAsync(id, ct);
|
||||||
|
if (trader?.CurrentScore != null)
|
||||||
|
{
|
||||||
|
trader.CurrentScore.Rank = rank++;
|
||||||
|
await _traderRepo.UpdateAsync(trader, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Score recalculation complete. Ranked {Count} traders.", scored.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetManualOverrideAsync(int traderId, int? score, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
|
||||||
|
if (trader == null) return;
|
||||||
|
|
||||||
|
trader.ManualPriorityOverride = score;
|
||||||
|
await _traderRepo.UpdateAsync(trader, ct);
|
||||||
|
_logger.LogInformation("Set manual priority override for {TraderName}: {Score}", trader.DisplayName, score?.ToString() ?? "cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculateActivityScore(IReadOnlyList<Trade> trades)
|
||||||
|
{
|
||||||
|
if (trades.Count == 0) return 0;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var recentTrades = trades.Where(t => t.ExecutedAt > now.AddDays(-7)).ToList();
|
||||||
|
var frequencyScore = Math.Min(recentTrades.Count / 10.0m, 1.0m) * 50;
|
||||||
|
|
||||||
|
// Recency bonus
|
||||||
|
var lastTrade = trades.MaxBy(t => t.ExecutedAt);
|
||||||
|
var hoursSinceLast = (decimal)(now - (lastTrade?.ExecutedAt ?? now.AddDays(-30))).TotalHours;
|
||||||
|
var recencyScore = Math.Max(0, 50 - hoursSinceLast / 2);
|
||||||
|
|
||||||
|
return Math.Min(Math.Round(frequencyScore + recencyScore, 2), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculateQualityScore(Trader trader)
|
||||||
|
{
|
||||||
|
// Win rate contribution (0-60 points)
|
||||||
|
var winRateScore = trader.WinRate * 0.6m;
|
||||||
|
|
||||||
|
// PnL contribution (0-40 points) - logarithmic scale
|
||||||
|
var pnlScore = trader.TotalPnl > 0
|
||||||
|
? Math.Min((decimal)Math.Log10((double)trader.TotalPnl + 1) * 10, 40)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return Math.Min(Math.Round(winRateScore + pnlScore, 2), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculateVolumeScore(IReadOnlyList<Trade> trades)
|
||||||
|
{
|
||||||
|
if (trades.Count == 0) return 0;
|
||||||
|
|
||||||
|
var avgAmount = trades.Average(t => t.Amount);
|
||||||
|
// Score based on average trade size (logarithmic)
|
||||||
|
var score = (decimal)Math.Log10((double)avgAmount + 1) * 25;
|
||||||
|
return Math.Min(Math.Round(score, 2), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculateTimingScore(IReadOnlyList<Trade> trades)
|
||||||
|
{
|
||||||
|
if (trades.Count < 2) return 50; // neutral if insufficient data
|
||||||
|
|
||||||
|
// Simple heuristic: variety in execution times suggests deliberate timing
|
||||||
|
var hours = trades.Select(t => t.ExecutedAt.Hour).Distinct().Count();
|
||||||
|
var timeSpread = Math.Min(hours / 12.0m, 1.0m) * 50;
|
||||||
|
|
||||||
|
// Consistency: regular intervals suggest discipline
|
||||||
|
var intervals = trades.OrderBy(t => t.ExecutedAt)
|
||||||
|
.Zip(trades.OrderBy(t => t.ExecutedAt).Skip(1), (a, b) => (b.ExecutedAt - a.ExecutedAt).TotalHours)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
decimal consistencyScore = 50;
|
||||||
|
if (intervals.Count > 0)
|
||||||
|
{
|
||||||
|
var avgInterval = intervals.Average();
|
||||||
|
var stdDev = Math.Sqrt(intervals.Average(i => Math.Pow(i - avgInterval, 2)));
|
||||||
|
consistencyScore = (decimal)Math.Max(0, 50 - stdDev);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Min(Math.Round(timeSpread + consistencyScore, 2), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Services;
|
||||||
|
|
||||||
|
public class WatchlistService
|
||||||
|
{
|
||||||
|
private readonly IWatchlistRepository _repo;
|
||||||
|
private readonly ITraderRepository _traderRepo;
|
||||||
|
private readonly ILogger<WatchlistService> _logger;
|
||||||
|
|
||||||
|
public WatchlistService(IWatchlistRepository repo, ITraderRepository traderRepo, ILogger<WatchlistService> logger)
|
||||||
|
{ _repo = repo; _traderRepo = traderRepo; _logger = logger; }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default) => _repo.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public async Task AddAsync(int traderId, string label, string? notes = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var existing = await _repo.GetByTraderIdAsync(traderId, ct);
|
||||||
|
if (existing != null) return;
|
||||||
|
await _repo.AddAsync(new WatchlistEntry { TraderId = traderId, Label = label, Notes = notes }, ct);
|
||||||
|
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
|
||||||
|
_logger.LogInformation("Added {Trader} to watchlist", trader?.DisplayName ?? traderId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _repo.RemoveAsync(id, ct);
|
||||||
|
_logger.LogInformation("Removed watchlist entry {Id}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An alert triggered by the system based on configurable rules.
|
||||||
|
/// </summary>
|
||||||
|
public class Alert
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Type of alert.</summary>
|
||||||
|
public AlertType Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Platform where the triggering event occurred.</summary>
|
||||||
|
public PlatformType Platform { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional reference to the trader who triggered this alert.</summary>
|
||||||
|
public int? TraderId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Alert title / headline.</summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Detailed message describing the alert.</summary>
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Severity: 1=Low, 2=Medium, 3=High, 4=Critical.</summary>
|
||||||
|
public int Severity { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>Whether the alert has been read/acknowledged.</summary>
|
||||||
|
public bool IsRead { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the alert was created.</summary>
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public Trader? Trader { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
public class TraderAnalytics
|
||||||
|
{
|
||||||
|
[Key, ForeignKey("Trader")]
|
||||||
|
public int TraderId { get; set; }
|
||||||
|
|
||||||
|
public DateTime LastCalculatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public decimal OverallPnL { get; set; }
|
||||||
|
public decimal OverallWinRate { get; set; }
|
||||||
|
|
||||||
|
public decimal PnL30d { get; set; }
|
||||||
|
public decimal WinRate30d { get; set; }
|
||||||
|
|
||||||
|
public decimal PnL7d { get; set; }
|
||||||
|
public decimal WinRate7d { get; set; }
|
||||||
|
|
||||||
|
public decimal PnL24h { get; set; }
|
||||||
|
public decimal WinRate24h { get; set; }
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public virtual Trader Trader { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MarketAnalytics
|
||||||
|
{
|
||||||
|
[Key, ForeignKey("Market")]
|
||||||
|
public int MarketId { get; set; }
|
||||||
|
|
||||||
|
public DateTime LastCalculatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public decimal BotActivityScore { get; set; }
|
||||||
|
public int UniqueTradersCount { get; set; }
|
||||||
|
public decimal AverageTradeSize { get; set; }
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public virtual Market Market { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a prediction market (event/question).
|
||||||
|
/// </summary>
|
||||||
|
public class Market
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Platform this market belongs to.</summary>
|
||||||
|
public PlatformType Platform { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Platform-specific market identifier (conditionId on Polymarket).</summary>
|
||||||
|
public string PlatformMarketId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>URL-friendly slug for the market.</summary>
|
||||||
|
public string MarketSlug { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>URL-friendly slug for the parent event.</summary>
|
||||||
|
public string EventSlug { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Detailed market description / resolution criteria.</summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Market image URL.</summary>
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The question being predicted.</summary>
|
||||||
|
public string Question { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Category / tag (e.g. "Politics", "Crypto", "Sports").</summary>
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Current total volume traded.</summary>
|
||||||
|
public decimal Volume { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Current liquidity.</summary>
|
||||||
|
public decimal Liquidity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The time when trading opened for this market.</summary>
|
||||||
|
public DateTime? StartDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the market closes / resolves.</summary>
|
||||||
|
public DateTime? EndDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the market has been resolved.</summary>
|
||||||
|
public bool IsResolved { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Resolution outcome (if resolved).</summary>
|
||||||
|
public string? ResolutionOutcome { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When this market was created on the platform.</summary>
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When this record was first saved to our database.</summary>
|
||||||
|
public DateTime DbCreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Last time market data was refreshed.</summary>
|
||||||
|
public DateTime? LastUpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Last time market trades were polled for trader discovery.</summary>
|
||||||
|
public DateTime? LastTradesUpdatedAt { get; set; }
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public ICollection<MarketOutcome> Outcomes { get; set; } = new List<MarketOutcome>();
|
||||||
|
public virtual MarketAnalytics? Analytics { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single tradeable outcome within a market.
|
||||||
|
/// For example, "Yes" and "No" on a binary market, or named outcomes on a multi-outcome market.
|
||||||
|
/// The TokenId (clobTokenId on Polymarket) is the key link between trades and outcomes.
|
||||||
|
/// </summary>
|
||||||
|
public class MarketOutcome
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Foreign key to the parent market.</summary>
|
||||||
|
public int MarketId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable outcome label (e.g. "Yes", "No", "Trump", "Biden").</summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Positional index of this outcome within the market (0-based).</summary>
|
||||||
|
public int OutcomeIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-specific token identifier for this outcome.
|
||||||
|
/// On Polymarket this is the clobTokenId — the key used in trade asset_id fields.
|
||||||
|
/// </summary>
|
||||||
|
public string TokenId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Current price of this outcome (0.00 to 1.00).</summary>
|
||||||
|
public decimal CurrentPrice { get; set; }
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public Market Market { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
public class PlatformConfig
|
||||||
|
{
|
||||||
|
public PlatformType Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string DisplayName { get; set; } = "";
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public string? BaseUrl { get; set; }
|
||||||
|
public string? SettingsJson { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single trade executed by a trader on a prediction market.
|
||||||
|
/// </summary>
|
||||||
|
public class Trade
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Foreign key to the trader who made this trade.</summary>
|
||||||
|
public int TraderId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Platform where the trade occurred.</summary>
|
||||||
|
public PlatformType Platform { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-specific trade ID for deduplication.
|
||||||
|
/// Format: "{txHash}_{assetId}_{side}" (max ~143 chars for new trades).
|
||||||
|
/// Legacy trades may include wallet address as 2nd segment.
|
||||||
|
/// </summary>
|
||||||
|
public string PlatformTradeId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-specific market identifier string (conditionId on Polymarket, address on Limitless).
|
||||||
|
/// Kept as VARCHAR(66) for cross-referencing during import and reconciliation.
|
||||||
|
/// After full linking, prefer DbMarketId.
|
||||||
|
/// </summary>
|
||||||
|
public string MarketId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key to the internal Markets table. Populated during import when the market is known.
|
||||||
|
/// Null for trades whose market has not yet been synced.
|
||||||
|
/// </summary>
|
||||||
|
public int? DbMarketId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-specific token/asset ID (clobTokenId on Polymarket). Links to MarketOutcome.TokenId.
|
||||||
|
/// Kept as VARCHAR(66) until MarketOutcomeId is resolved.
|
||||||
|
/// </summary>
|
||||||
|
public string AssetId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Foreign key to the resolved MarketOutcome (nullable until resolved via market sync).</summary>
|
||||||
|
public int? MarketOutcomeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The outcome the trader bet on (e.g. "Yes", "No").</summary>
|
||||||
|
public string Outcome { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Buy or Sell.</summary>
|
||||||
|
public TradeSide Side { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Price per share at execution (0.00 to 1.00 on Polymarket).</summary>
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of shares/tokens traded.</summary>
|
||||||
|
public decimal Size { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total notional value in USD.</summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the trade was executed on the platform.</summary>
|
||||||
|
public DateTime ExecutedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Transaction hash (for blockchain-based platforms).</summary>
|
||||||
|
public string? TransactionHash { get; set; }
|
||||||
|
|
||||||
|
// ── Transient (not persisted) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wallet address of the trader, set by the provider during data ingestion.
|
||||||
|
/// NOT stored in the database — used transiently for trader discovery in MarketHistoryWorker.
|
||||||
|
/// </summary>
|
||||||
|
[NotMapped]
|
||||||
|
public string? TransientWallet { get; set; }
|
||||||
|
|
||||||
|
// ── Navigation ────────────────────────────────────────────────────────
|
||||||
|
public Trader Trader { get; set; } = null!;
|
||||||
|
public MarketOutcome? MarketOutcome { get; set; }
|
||||||
|
public Market? DbMarket { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a trader on a prediction market platform.
|
||||||
|
/// Identity is composite: PlatformType + PlatformUserId (e.g. wallet address on Polymarket).
|
||||||
|
/// </summary>
|
||||||
|
public class Trader
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The platform this trader belongs to.</summary>
|
||||||
|
public PlatformType Platform { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Platform-specific user identifier (e.g. Ethereum wallet address for Polymarket).</summary>
|
||||||
|
public string PlatformUserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Display name / alias (can be auto-discovered or manually set).</summary>
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optional notes about the trader.</summary>
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether this trader was auto-discovered or manually added.</summary>
|
||||||
|
public bool IsAutoDiscovered { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Current tier classification.</summary>
|
||||||
|
public TraderTier Tier { get; set; } = TraderTier.Unknown;
|
||||||
|
|
||||||
|
/// <summary>Classified strategy type from deep-dive analysis.</summary>
|
||||||
|
public StrategyType Strategy { get; set; } = StrategyType.Unknown;
|
||||||
|
|
||||||
|
/// <summary>Whether the trader shows bot-like behavior.</summary>
|
||||||
|
public bool IsSuspectedBot { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Manual priority override (null = use calculated score).</summary>
|
||||||
|
public int? ManualPriorityOverride { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When this trader was first tracked.</summary>
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>When data was last polled for this trader.</summary>
|
||||||
|
public DateTime? LastPolledAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the trader's trade history was last fully synced. Used for cooldown.</summary>
|
||||||
|
public DateTime? LastTradesUpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the initial full historical trade import is complete.</summary>
|
||||||
|
public bool IsInitialImportComplete { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the API first returned an error (e.g. 404 for deleted account).</summary>
|
||||||
|
public DateTime? LastApiErrorAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total estimated PnL across all resolved markets.</summary>
|
||||||
|
public decimal TotalPnl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Win rate as a percentage (0-100).</summary>
|
||||||
|
public decimal WinRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total number of trades tracked.</summary>
|
||||||
|
public int TotalTrades { get; set; }
|
||||||
|
|
||||||
|
// Navigation properties
|
||||||
|
public ICollection<Trade> Trades { get; set; } = new List<Trade>();
|
||||||
|
public TraderScore? CurrentScore { get; set; }
|
||||||
|
public virtual TraderAnalytics? Analytics { get; set; }
|
||||||
|
public ICollection<WatchlistEntry> WatchlistEntries { get; set; } = new List<WatchlistEntry>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculated priority and quality score for a trader.
|
||||||
|
/// Updated periodically by the scoring engine.
|
||||||
|
/// </summary>
|
||||||
|
public class TraderScore
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Foreign key to trader.</summary>
|
||||||
|
public int TraderId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Activity score: frequency, recency, and consistency of trading (0-100).</summary>
|
||||||
|
public decimal ActivityScore { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Quality score: win rate, ROI, risk management quality (0-100).</summary>
|
||||||
|
public decimal QualityScore { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Combined weighted score (0-100).</summary>
|
||||||
|
public decimal CombinedScore { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Volume score: size of positions relative to market (0-100).</summary>
|
||||||
|
public decimal VolumeScore { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Timing score: how well-timed entries and exits are (0-100).</summary>
|
||||||
|
public decimal TimingScore { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Overall rank among all tracked traders.</summary>
|
||||||
|
public int Rank { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When this score was last calculated.</summary>
|
||||||
|
public DateTime CalculatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public Trader Trader { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A trader that has been added to the user's watchlist for prioritized tracking.
|
||||||
|
/// </summary>
|
||||||
|
public class WatchlistEntry
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Foreign key to the watched trader.</summary>
|
||||||
|
public int TraderId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>User-defined label for this watchlist entry.</summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optional notes about why this trader is watched.</summary>
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether to receive alerts for this trader's activity.</summary>
|
||||||
|
public bool AlertsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>When this entry was added to the watchlist.</summary>
|
||||||
|
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public Trader Trader { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
public enum AlertType
|
||||||
|
{
|
||||||
|
/// <summary>Large position opened by tracked trader</summary>
|
||||||
|
LargePosition = 0,
|
||||||
|
/// <summary>New trader discovered matching criteria</summary>
|
||||||
|
NewTraderDiscovered = 1,
|
||||||
|
/// <summary>Trader's score changed significantly</summary>
|
||||||
|
ScoreChange = 2,
|
||||||
|
/// <summary>Unusual activity pattern detected</summary>
|
||||||
|
UnusualActivity = 3,
|
||||||
|
/// <summary>Trader exited a position completely</summary>
|
||||||
|
PositionExit = 4,
|
||||||
|
/// <summary>Custom user-defined alert</summary>
|
||||||
|
Custom = 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supported prediction market platforms.
|
||||||
|
/// Add new platforms here as they are integrated.
|
||||||
|
/// </summary>
|
||||||
|
public enum PlatformType
|
||||||
|
{
|
||||||
|
Polymarket = 0,
|
||||||
|
Limitless = 1,
|
||||||
|
Azuro = 2,
|
||||||
|
Myriad = 3,
|
||||||
|
PredictFun = 4,
|
||||||
|
Kalshi = 5,
|
||||||
|
Stake = 6
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classified trading strategy types for deep-dive analysis.
|
||||||
|
/// </summary>
|
||||||
|
public enum StrategyType
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Scalper = 1,
|
||||||
|
SwingTrader = 2,
|
||||||
|
Whale = 3,
|
||||||
|
Hedger = 4,
|
||||||
|
Contrarian = 5,
|
||||||
|
MomentumTrader = 6,
|
||||||
|
Arbitrageur = 7,
|
||||||
|
Bot = 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
public enum TradeSide
|
||||||
|
{
|
||||||
|
Buy = 0,
|
||||||
|
Sell = 1,
|
||||||
|
Split = 2,
|
||||||
|
Merge = 3,
|
||||||
|
Redeem = 4,
|
||||||
|
AddLiquidity = 5,
|
||||||
|
RemoveLiquidity = 6,
|
||||||
|
Unknown = 99
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trader quality tiers based on scoring.
|
||||||
|
/// </summary>
|
||||||
|
public enum TraderTier
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Bronze = 1,
|
||||||
|
Silver = 2,
|
||||||
|
Gold = 3,
|
||||||
|
Platinum = 4,
|
||||||
|
Diamond = 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IAlertRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Alert>> GetRecentAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default);
|
||||||
|
Task AddAsync(Alert alert, CancellationToken ct = default);
|
||||||
|
Task MarkAsReadAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<int> GetUnreadCountAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IMarketRepository
|
||||||
|
{
|
||||||
|
Task<Market?> GetByPlatformIdAsync(PlatformType platform, string platformMarketId, CancellationToken ct = default);
|
||||||
|
Task<MarketOutcome?> GetOutcomeByTokenIdAsync(string tokenId, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<MarketOutcome>> GetOutcomesByTokenIdsAsync(IEnumerable<string> tokenIds, CancellationToken ct = default);
|
||||||
|
Task AddOrUpdateAsync(Market market, CancellationToken ct = default);
|
||||||
|
Task AddOrUpdateRangeAsync(IEnumerable<Market> markets, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Market>> GetActiveAsync(int count = 50, CancellationToken ct = default);
|
||||||
|
Task<int> GetCountAsync(CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Market>> GetMarketsDueForTradeUpdateAsync(int cooldownHours, int limit, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(Market market, CancellationToken ct = default);
|
||||||
|
Task<Market?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Market>> SearchAsync(string query, int take = 20, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-agnostic provider interface. Each prediction market platform
|
||||||
|
/// must implement this to be integrated into the system.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPlatformProvider
|
||||||
|
{
|
||||||
|
/// <summary>Which platform this provider serves.</summary>
|
||||||
|
PlatformType Platform { get; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable name of the platform.</summary>
|
||||||
|
string PlatformName { get; }
|
||||||
|
|
||||||
|
/// <summary>Whether this provider is fully implemented and operational.</summary>
|
||||||
|
bool IsImplemented { get; }
|
||||||
|
|
||||||
|
/// <summary>Fetch recent trades for a specific trader.</summary>
|
||||||
|
Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 50, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Fetch current positions/holdings for a trader.</summary>
|
||||||
|
Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Discover notable/active traders on the platform.</summary>
|
||||||
|
Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 20, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Fetch market metadata by platform-specific market ID.</summary>
|
||||||
|
Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Fetch a batch of markets with their outcomes for bulk sync.</summary>
|
||||||
|
Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Fetch top holders for a market to discover new traders.</summary>
|
||||||
|
Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Fetch recent trades that occurred on a specific market.</summary>
|
||||||
|
Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A trader's current position in a market.
|
||||||
|
/// </summary>
|
||||||
|
public record TraderPositionInfo(
|
||||||
|
string PlatformUserId,
|
||||||
|
string MarketId,
|
||||||
|
string MarketQuestion,
|
||||||
|
string Outcome,
|
||||||
|
decimal Size,
|
||||||
|
decimal AveragePrice,
|
||||||
|
decimal CurrentValue,
|
||||||
|
decimal PnlPercent
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A trader discovered during auto-discovery scanning.
|
||||||
|
/// </summary>
|
||||||
|
public record DiscoveredTrader(
|
||||||
|
string PlatformUserId,
|
||||||
|
string DisplayName,
|
||||||
|
decimal Volume24h,
|
||||||
|
int TradeCount24h,
|
||||||
|
decimal WinRate
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface ITradeRepository
|
||||||
|
{
|
||||||
|
Task<Trade?> GetByPlatformTradeIdAsync(PlatformType platform, string platformTradeId, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Trade>> GetByTraderIdAsync(int traderId, int skip = 0, int take = 50, CancellationToken ct = default);
|
||||||
|
/// <summary>Fast INT-based market lookup (preferred after DbMarketId backfill).</summary>
|
||||||
|
Task<IReadOnlyList<Trade>> GetByDbMarketIdAsync(int dbMarketId, int skip = 0, int take = 50, CancellationToken ct = default);
|
||||||
|
/// <summary>String-based fallback for trades not yet linked to a DB market.</summary>
|
||||||
|
Task<IReadOnlyList<Trade>> GetByMarketIdAsync(string platformMarketId, int skip = 0, int take = 50, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Trade>> GetRecentAsync(int count = 50, PlatformType? platform = null, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Trade>> GetLargestAsync(int count = 5, DateTime? since = null, CancellationToken ct = default);
|
||||||
|
Task<int> GetCountAsync(int? traderId = null, CancellationToken ct = default);
|
||||||
|
Task AddRangeAsync(IEnumerable<Trade> trades, CancellationToken ct = default);
|
||||||
|
Task<decimal> GetTotalVolumeAsync(DateTime? since = null, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Trade>> GetOrphanedTradesAsync(int limit, CancellationToken ct = default);
|
||||||
|
Task<HashSet<string>> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(Trade trade, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface ITraderRepository
|
||||||
|
{
|
||||||
|
Task<Trader?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<Trader?> GetByPlatformIdAsync(PlatformType platform, string platformUserId, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Trader>> GetAllAsync(PlatformType? platform = null, int skip = 0, int take = 50, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Trader>> GetWatchlistedAsync(CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Trader>> GetTopByPnLAsync(int count = 5, DateTime? since = null, CancellationToken ct = default);
|
||||||
|
Task<int> GetCountAsync(PlatformType? platform = null, CancellationToken ct = default);
|
||||||
|
Task AddAsync(Trader trader, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(Trader trader, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Get traders that need trade history update (LastTradesUpdatedAt is null or older than given hours).</summary>
|
||||||
|
Task<IReadOnlyList<Trader>> GetTradersDueForTradeUpdateAsync(int cooldownHours = 6, int take = 20, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Get traders that haven't been polled in a long time or have a prolonged API error for cleanup.</summary>
|
||||||
|
Task<IReadOnlyList<Trader>> GetTradersForCleanupAsync(DateTime inactiveSince, DateTime errorSince, int take = 50, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<Trader>> SearchAsync(string query, int take = 20, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IWatchlistRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default);
|
||||||
|
Task AddAsync(WatchlistEntry entry, CancellationToken ct = default);
|
||||||
|
Task RemoveAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>Predictalytics.Domain</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.ValueObjects;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encapsulates a trader's calculated priority score with all component scores.
|
||||||
|
/// </summary>
|
||||||
|
public record PriorityScore(
|
||||||
|
decimal ActivityScore,
|
||||||
|
decimal QualityScore,
|
||||||
|
decimal VolumeScore,
|
||||||
|
decimal TimingScore,
|
||||||
|
decimal CombinedScore,
|
||||||
|
int? ManualOverride = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The effective score, considering any manual override.
|
||||||
|
/// </summary>
|
||||||
|
public decimal EffectiveScore => ManualOverride.HasValue
|
||||||
|
? ManualOverride.Value
|
||||||
|
: CombinedScore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determine tier based on effective score.
|
||||||
|
/// </summary>
|
||||||
|
public TraderTier DetermineTier() => EffectiveScore switch
|
||||||
|
{
|
||||||
|
>= 90 => TraderTier.Diamond,
|
||||||
|
>= 75 => TraderTier.Platinum,
|
||||||
|
>= 60 => TraderTier.Gold,
|
||||||
|
>= 40 => TraderTier.Silver,
|
||||||
|
>= 20 => TraderTier.Bronze,
|
||||||
|
_ => TraderTier.Unknown
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.ValueObjects;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a deep-dive analysis for a trader.
|
||||||
|
/// </summary>
|
||||||
|
public record TraderAnalysis(
|
||||||
|
int TraderId,
|
||||||
|
StrategyType ClassifiedStrategy,
|
||||||
|
bool IsSuspectedBot,
|
||||||
|
decimal AvgHoldDurationHours,
|
||||||
|
decimal AvgPositionSizeUsd,
|
||||||
|
int MarketsTraded,
|
||||||
|
decimal HedgingFrequency,
|
||||||
|
decimal TimingAccuracy,
|
||||||
|
decimal EntryQuality,
|
||||||
|
decimal ExitQuality,
|
||||||
|
string[] BotIndicators,
|
||||||
|
string Summary
|
||||||
|
);
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Data;
|
||||||
|
|
||||||
|
public class AppDbContext : DbContext
|
||||||
|
{
|
||||||
|
public DbSet<Trader> Traders => Set<Trader>();
|
||||||
|
public DbSet<Trade> Trades => Set<Trade>();
|
||||||
|
public DbSet<Market> Markets => Set<Market>();
|
||||||
|
public DbSet<MarketOutcome> MarketOutcomes => Set<MarketOutcome>();
|
||||||
|
public DbSet<TraderScore> TraderScores => Set<TraderScore>();
|
||||||
|
public DbSet<WatchlistEntry> WatchlistEntries => Set<WatchlistEntry>();
|
||||||
|
public DbSet<Alert> Alerts => Set<Alert>();
|
||||||
|
public DbSet<PlatformConfig> PlatformConfigs => Set<PlatformConfig>();
|
||||||
|
public DbSet<TraderAnalytics> TraderAnalytics => Set<TraderAnalytics>();
|
||||||
|
public DbSet<MarketAnalytics> MarketAnalytics => Set<MarketAnalytics>();
|
||||||
|
|
||||||
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder mb)
|
||||||
|
{
|
||||||
|
// Trader
|
||||||
|
mb.Entity<Trader>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(t => t.Id);
|
||||||
|
e.HasIndex(t => new { t.Platform, t.PlatformUserId }).IsUnique();
|
||||||
|
e.Property(t => t.PlatformUserId).HasMaxLength(128);
|
||||||
|
e.Property(t => t.DisplayName).HasMaxLength(256);
|
||||||
|
e.Property(t => t.TotalPnl).HasPrecision(18, 4);
|
||||||
|
e.Property(t => t.WinRate).HasPrecision(8, 4);
|
||||||
|
e.HasOne(t => t.CurrentScore).WithOne(s => s.Trader)
|
||||||
|
.HasForeignKey<TraderScore>(s => s.TraderId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trade
|
||||||
|
mb.Entity<Trade>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(t => t.Id);
|
||||||
|
e.HasIndex(t => new { t.Platform, t.PlatformTradeId }).IsUnique();
|
||||||
|
e.HasIndex(t => t.TraderId);
|
||||||
|
e.HasIndex(t => t.ExecutedAt);
|
||||||
|
e.HasIndex(t => t.AssetId);
|
||||||
|
e.HasIndex(t => t.DbMarketId);
|
||||||
|
// PlatformTradeId: legacy data up to 256 chars; new trades use shorter format
|
||||||
|
e.Property(t => t.PlatformTradeId).HasMaxLength(256);
|
||||||
|
// MarketId: Polymarket ConditionId is always 66 hex chars
|
||||||
|
e.Property(t => t.MarketId).HasMaxLength(66);
|
||||||
|
// AssetId: clobTokenId; Polymarket uses decimal strings up to 78 chars
|
||||||
|
e.Property(t => t.AssetId).HasMaxLength(80);
|
||||||
|
// Outcome: labels can be long (e.g. anime titles or sports match descriptions)
|
||||||
|
e.Property(t => t.Outcome).HasMaxLength(128);
|
||||||
|
// Price: 0.00–1.00 on prediction markets, 6 decimals sufficient
|
||||||
|
e.Property(t => t.Price).HasPrecision(10, 6);
|
||||||
|
// Size: number of shares, needs more integer digits
|
||||||
|
e.Property(t => t.Size).HasPrecision(14, 6);
|
||||||
|
e.Property(t => t.Amount).HasPrecision(18, 4);
|
||||||
|
// TransactionHash: 0x + 64 hex = 66 chars
|
||||||
|
e.Property(t => t.TransactionHash).HasMaxLength(66);
|
||||||
|
e.HasOne(t => t.Trader).WithMany(tr => tr.Trades).HasForeignKey(t => t.TraderId);
|
||||||
|
e.HasOne(t => t.MarketOutcome).WithMany().HasForeignKey(t => t.MarketOutcomeId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
e.HasOne(t => t.DbMarket).WithMany().HasForeignKey(t => t.DbMarketId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Market
|
||||||
|
mb.Entity<Market>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(m => m.Id);
|
||||||
|
e.HasIndex(m => new { m.Platform, m.PlatformMarketId }).IsUnique();
|
||||||
|
e.Property(m => m.PlatformMarketId).HasMaxLength(256);
|
||||||
|
e.Property(m => m.MarketSlug).HasMaxLength(512);
|
||||||
|
e.Property(m => m.EventSlug).HasMaxLength(512);
|
||||||
|
e.Property(m => m.Question).HasMaxLength(1024);
|
||||||
|
e.Property(m => m.Description).HasMaxLength(4096);
|
||||||
|
e.Property(m => m.ImageUrl).HasMaxLength(1024);
|
||||||
|
e.Property(m => m.Category).HasMaxLength(128);
|
||||||
|
e.Property(m => m.Volume).HasPrecision(18, 4);
|
||||||
|
e.Property(m => m.Liquidity).HasPrecision(18, 4);
|
||||||
|
e.HasMany(m => m.Outcomes).WithOne(o => o.Market).HasForeignKey(o => o.MarketId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// MarketOutcome
|
||||||
|
mb.Entity<MarketOutcome>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(o => o.Id);
|
||||||
|
e.HasIndex(o => o.TokenId);
|
||||||
|
e.HasIndex(o => new { o.MarketId, o.OutcomeIndex }).IsUnique();
|
||||||
|
e.Property(o => o.Label).HasMaxLength(256);
|
||||||
|
e.Property(o => o.TokenId).HasMaxLength(256);
|
||||||
|
e.Property(o => o.CurrentPrice).HasPrecision(18, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TraderScore
|
||||||
|
mb.Entity<TraderScore>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(s => s.Id);
|
||||||
|
e.Property(s => s.ActivityScore).HasPrecision(8, 4);
|
||||||
|
e.Property(s => s.QualityScore).HasPrecision(8, 4);
|
||||||
|
e.Property(s => s.CombinedScore).HasPrecision(8, 4);
|
||||||
|
e.Property(s => s.VolumeScore).HasPrecision(8, 4);
|
||||||
|
e.Property(s => s.TimingScore).HasPrecision(8, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WatchlistEntry
|
||||||
|
mb.Entity<WatchlistEntry>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(w => w.Id);
|
||||||
|
e.HasIndex(w => w.TraderId).IsUnique();
|
||||||
|
e.Property(w => w.Label).HasMaxLength(256);
|
||||||
|
e.HasOne(w => w.Trader).WithMany(t => t.WatchlistEntries).HasForeignKey(w => w.TraderId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alert
|
||||||
|
mb.Entity<Alert>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(a => a.Id);
|
||||||
|
e.HasIndex(a => a.CreatedAt);
|
||||||
|
e.Property(a => a.Title).HasMaxLength(512);
|
||||||
|
e.Property(a => a.Message).HasMaxLength(4096);
|
||||||
|
e.HasOne(a => a.Trader).WithMany().HasForeignKey(a => a.TraderId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PlatformConfig
|
||||||
|
mb.Entity<PlatformConfig>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(p => p.Id);
|
||||||
|
e.Property(p => p.Name).HasMaxLength(128);
|
||||||
|
e.Property(p => p.DisplayName).HasMaxLength(256);
|
||||||
|
e.Property(p => p.BaseUrl).HasMaxLength(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TraderAnalytics
|
||||||
|
mb.Entity<TraderAnalytics>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(a => a.TraderId);
|
||||||
|
e.Property(a => a.OverallPnL).HasPrecision(18, 4);
|
||||||
|
e.Property(a => a.OverallWinRate).HasPrecision(8, 4);
|
||||||
|
e.Property(a => a.PnL30d).HasPrecision(18, 4);
|
||||||
|
e.Property(a => a.WinRate30d).HasPrecision(8, 4);
|
||||||
|
e.Property(a => a.PnL7d).HasPrecision(18, 4);
|
||||||
|
e.Property(a => a.WinRate7d).HasPrecision(8, 4);
|
||||||
|
e.Property(a => a.PnL24h).HasPrecision(18, 4);
|
||||||
|
e.Property(a => a.WinRate24h).HasPrecision(8, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
// MarketAnalytics
|
||||||
|
mb.Entity<MarketAnalytics>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(a => a.MarketId);
|
||||||
|
e.Property(a => a.BotActivityScore).HasPrecision(8, 4);
|
||||||
|
e.Property(a => a.AverageTradeSize).HasPrecision(18, 4);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Data;
|
||||||
|
|
||||||
|
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||||
|
{
|
||||||
|
public AppDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
|
||||||
|
// Fallback for local migrations
|
||||||
|
var connectionString = "Server=localhost;Database=Predictalytics;User=root;Password=;";
|
||||||
|
|
||||||
|
optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 31)));
|
||||||
|
|
||||||
|
return new AppDbContext(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Data.Repositories;
|
||||||
|
|
||||||
|
public class AlertRepository : IAlertRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public AlertRepository(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Alert>> GetRecentAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _db.Alerts.AsQueryable();
|
||||||
|
if (unreadOnly) q = q.Where(a => !a.IsRead);
|
||||||
|
return await q.OrderByDescending(a => a.CreatedAt).Take(count).ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(Alert alert, CancellationToken ct = default)
|
||||||
|
{ _db.Alerts.Add(alert); await _db.SaveChangesAsync(ct); }
|
||||||
|
|
||||||
|
public async Task MarkAsReadAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var a = await _db.Alerts.FindAsync(new object[] { id }, ct);
|
||||||
|
if (a != null) { a.IsRead = true; await _db.SaveChangesAsync(ct); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetUnreadCountAsync(CancellationToken ct = default)
|
||||||
|
=> await _db.Alerts.CountAsync(a => !a.IsRead, ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using System.Threading;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Data.Repositories;
|
||||||
|
|
||||||
|
public class MarketRepository : IMarketRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private static readonly SemaphoreSlim _syncSemaphore = new(1, 1);
|
||||||
|
|
||||||
|
public MarketRepository(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<Market?> GetByPlatformIdAsync(PlatformType platform, string platformMarketId, CancellationToken ct = default)
|
||||||
|
=> await _db.Markets.Include(m => m.Outcomes)
|
||||||
|
.FirstOrDefaultAsync(m => m.Platform == platform && m.PlatformMarketId == platformMarketId, ct);
|
||||||
|
|
||||||
|
public async Task<MarketOutcome?> GetOutcomeByTokenIdAsync(string tokenId, CancellationToken ct = default)
|
||||||
|
=> await _db.MarketOutcomes.Include(o => o.Market)
|
||||||
|
.FirstOrDefaultAsync(o => o.TokenId == tokenId, ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<MarketOutcome>> GetOutcomesByTokenIdsAsync(IEnumerable<string> tokenIds, CancellationToken ct = default)
|
||||||
|
=> await _db.MarketOutcomes.Include(o => o.Market)
|
||||||
|
.Where(o => tokenIds.Contains(o.TokenId))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task AddOrUpdateAsync(Market market, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
TruncateMarketStrings(market);
|
||||||
|
|
||||||
|
var existing = await _db.Markets.Include(m => m.Outcomes)
|
||||||
|
.FirstOrDefaultAsync(m => m.Platform == market.Platform && m.PlatformMarketId == market.PlatformMarketId, ct);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
UpdateMarketFields(existing, market);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_db.Markets.Add(market);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddOrUpdateRangeAsync(IEnumerable<Market> markets, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Deduplicate input by PlatformMarketId to avoid processing the same ID twice in one call
|
||||||
|
var marketList = markets
|
||||||
|
.GroupBy(m => new { m.Platform, m.PlatformMarketId })
|
||||||
|
.Select(g => g.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!marketList.Any()) return;
|
||||||
|
|
||||||
|
await _syncSemaphore.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Process in sub-batches to avoid too large SQL queries
|
||||||
|
const int subBatchSize = 500;
|
||||||
|
for (int i = 0; i < marketList.Count; i += subBatchSize)
|
||||||
|
{
|
||||||
|
var currentBatch = marketList.Skip(i).Take(subBatchSize).ToList();
|
||||||
|
var platform = currentBatch.First().Platform;
|
||||||
|
var ids = currentBatch.Select(m => m.PlatformMarketId).ToList();
|
||||||
|
|
||||||
|
// Fetch all existing markets in this batch at once
|
||||||
|
var existingMarkets = await _db.Markets.Include(m => m.Outcomes)
|
||||||
|
.Where(m => m.Platform == platform && ids.Contains(m.PlatformMarketId))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var existingMap = existingMarkets.ToDictionary(m => m.PlatformMarketId);
|
||||||
|
|
||||||
|
foreach (var market in currentBatch)
|
||||||
|
{
|
||||||
|
TruncateMarketStrings(market);
|
||||||
|
|
||||||
|
if (existingMap.TryGetValue(market.PlatformMarketId, out var existing))
|
||||||
|
{
|
||||||
|
UpdateMarketFields(existing, market);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_db.Markets.Add(market);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncSemaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateMarketFields(Market existing, Market updated)
|
||||||
|
{
|
||||||
|
existing.Question = updated.Question;
|
||||||
|
existing.MarketSlug = updated.MarketSlug;
|
||||||
|
existing.EventSlug = updated.EventSlug;
|
||||||
|
existing.Description = updated.Description;
|
||||||
|
existing.ImageUrl = updated.ImageUrl;
|
||||||
|
existing.Category = updated.Category;
|
||||||
|
existing.Volume = updated.Volume;
|
||||||
|
existing.Liquidity = updated.Liquidity;
|
||||||
|
existing.StartDate = updated.StartDate;
|
||||||
|
existing.EndDate = updated.EndDate;
|
||||||
|
existing.IsResolved = updated.IsResolved;
|
||||||
|
existing.ResolutionOutcome = updated.ResolutionOutcome;
|
||||||
|
existing.CreatedAt = updated.CreatedAt; // Platform creation date
|
||||||
|
existing.LastUpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Upsert outcomes
|
||||||
|
foreach (var newOutcome in updated.Outcomes)
|
||||||
|
{
|
||||||
|
var existingOutcome = existing.Outcomes
|
||||||
|
.FirstOrDefault(o => o.OutcomeIndex == newOutcome.OutcomeIndex);
|
||||||
|
|
||||||
|
if (existingOutcome != null)
|
||||||
|
{
|
||||||
|
existingOutcome.Label = newOutcome.Label;
|
||||||
|
existingOutcome.TokenId = newOutcome.TokenId;
|
||||||
|
existingOutcome.CurrentPrice = newOutcome.CurrentPrice;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
newOutcome.MarketId = existing.Id;
|
||||||
|
existing.Outcomes.Add(newOutcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TruncateMarketStrings(Market market)
|
||||||
|
{
|
||||||
|
market.Question = StringHelper.Truncate(market.Question, 1024) ?? "";
|
||||||
|
market.Description = StringHelper.Truncate(market.Description, 4096);
|
||||||
|
market.MarketSlug = StringHelper.Truncate(market.MarketSlug, 512) ?? "";
|
||||||
|
market.EventSlug = StringHelper.Truncate(market.EventSlug, 512) ?? "";
|
||||||
|
market.ImageUrl = StringHelper.Truncate(market.ImageUrl, 1024);
|
||||||
|
market.Category = StringHelper.Truncate(market.Category, 128) ?? "";
|
||||||
|
|
||||||
|
foreach (var o in market.Outcomes)
|
||||||
|
{
|
||||||
|
o.Label = StringHelper.Truncate(o.Label, 256) ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Market>> GetActiveAsync(int count = 50, CancellationToken ct = default)
|
||||||
|
=> await _db.Markets.Include(m => m.Outcomes)
|
||||||
|
.Where(m => !m.IsResolved)
|
||||||
|
.OrderByDescending(m => m.Volume)
|
||||||
|
.Take(count)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<int> GetCountAsync(CancellationToken ct = default)
|
||||||
|
=> await _db.Markets.CountAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Market>> GetMarketsDueForTradeUpdateAsync(int cooldownHours, int limit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.UtcNow.AddHours(-cooldownHours);
|
||||||
|
return await _db.Markets
|
||||||
|
.Where(m => !m.IsResolved && (m.LastTradesUpdatedAt == null || m.LastTradesUpdatedAt < cutoff))
|
||||||
|
.OrderBy(m => m.LastTradesUpdatedAt ?? DateTime.MinValue)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Market market, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
TruncateMarketStrings(market);
|
||||||
|
_db.Markets.Update(market);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Market?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
=> await _db.Markets.Include(m => m.Outcomes).FirstOrDefaultAsync(m => m.Id == id, ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Market>> SearchAsync(string query, int take = 20, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query)) return Array.Empty<Market>();
|
||||||
|
|
||||||
|
return await _db.Markets.Include(m => m.Outcomes)
|
||||||
|
.Where(m => m.Question.Contains(query) ||
|
||||||
|
m.PlatformMarketId.Contains(query) ||
|
||||||
|
m.Id.ToString() == query)
|
||||||
|
.OrderByDescending(m => m.Volume)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Data.Repositories;
|
||||||
|
|
||||||
|
public class TradeRepository : ITradeRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public TradeRepository(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<Trade?> GetByPlatformTradeIdAsync(PlatformType platform, string platformTradeId, CancellationToken ct = default)
|
||||||
|
=> await _db.Trades.FirstOrDefaultAsync(t => t.Platform == platform && t.PlatformTradeId == platformTradeId, ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trade>> GetByTraderIdAsync(int traderId, int skip = 0, int take = 50, CancellationToken ct = default)
|
||||||
|
=> await _db.Trades.Include(t => t.Trader).Where(t => t.TraderId == traderId)
|
||||||
|
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trade>> GetByDbMarketIdAsync(int dbMarketId, int skip = 0, int take = 50, CancellationToken ct = default)
|
||||||
|
=> await _db.Trades.Include(t => t.Trader).Where(t => t.DbMarketId == dbMarketId)
|
||||||
|
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trade>> GetByMarketIdAsync(string platformMarketId, int skip = 0, int take = 50, CancellationToken ct = default)
|
||||||
|
=> await _db.Trades.Include(t => t.Trader).Where(t => t.MarketId == platformMarketId)
|
||||||
|
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trade>> GetRecentAsync(int count = 50, PlatformType? platform = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _db.Trades.Include(t => t.Trader).AsQueryable();
|
||||||
|
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
|
||||||
|
return await q.OrderByDescending(t => t.ExecutedAt).Take(count).ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trade>> GetLargestAsync(int count = 5, DateTime? since = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _db.Trades.Include(t => t.Trader).AsQueryable();
|
||||||
|
if (since.HasValue) q = q.Where(t => t.ExecutedAt >= since.Value);
|
||||||
|
return await q.OrderByDescending(t => t.Amount).Take(count).ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetCountAsync(int? traderId = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _db.Trades.AsQueryable();
|
||||||
|
if (traderId.HasValue) q = q.Where(t => t.TraderId == traderId.Value);
|
||||||
|
return await q.CountAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddRangeAsync(IEnumerable<Trade> trades, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
foreach (var t in trades)
|
||||||
|
{
|
||||||
|
t.Outcome = StringHelper.Truncate(t.Outcome, 128) ?? "";
|
||||||
|
t.PlatformTradeId = StringHelper.Truncate(t.PlatformTradeId, 256) ?? "";
|
||||||
|
t.MarketId = StringHelper.Truncate(t.MarketId, 66) ?? "";
|
||||||
|
t.AssetId = StringHelper.Truncate(t.AssetId, 80) ?? "";
|
||||||
|
if (t.TransactionHash != null)
|
||||||
|
t.TransactionHash = StringHelper.Truncate(t.TransactionHash, 66);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_db.Trades.AddRange(trades);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
foreach (var t in trades)
|
||||||
|
{
|
||||||
|
try { _db.Entry(t).State = EntityState.Detached; } catch { }
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal> GetTotalVolumeAsync(DateTime? since = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _db.Trades.AsQueryable();
|
||||||
|
if (since.HasValue) q = q.Where(t => t.ExecutedAt >= since.Value);
|
||||||
|
return await q.SumAsync(t => t.Amount, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trade>> GetOrphanedTradesAsync(int limit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _db.Trades
|
||||||
|
.Where(t => t.MarketOutcomeId == null && !string.IsNullOrEmpty(t.AssetId))
|
||||||
|
.OrderByDescending(t => t.ExecutedAt)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HashSet<string>> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ids = await _db.Trades
|
||||||
|
.Where(t => t.Platform == platform && t.TraderId == traderId)
|
||||||
|
.Select(t => t.PlatformTradeId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return new HashSet<string>(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Trade trade, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
trade.Outcome = StringHelper.Truncate(trade.Outcome, 128) ?? "";
|
||||||
|
trade.PlatformTradeId = StringHelper.Truncate(trade.PlatformTradeId, 256) ?? "";
|
||||||
|
trade.MarketId = StringHelper.Truncate(trade.MarketId, 66) ?? "";
|
||||||
|
trade.AssetId = StringHelper.Truncate(trade.AssetId, 80) ?? "";
|
||||||
|
|
||||||
|
_db.Trades.Update(trade);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Data.Repositories;
|
||||||
|
|
||||||
|
public class TraderRepository : ITraderRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public TraderRepository(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<Trader?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
=> await _db.Traders.Include(t => t.CurrentScore).FirstOrDefaultAsync(t => t.Id == id, ct);
|
||||||
|
|
||||||
|
public async Task<Trader?> GetByPlatformIdAsync(PlatformType platform, string platformUserId, CancellationToken ct = default)
|
||||||
|
=> await _db.Traders.Include(t => t.CurrentScore)
|
||||||
|
.FirstOrDefaultAsync(t => t.Platform == platform && t.PlatformUserId == platformUserId, ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trader>> GetAllAsync(PlatformType? platform = null, int skip = 0, int take = 50, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _db.Traders
|
||||||
|
.Include(t => t.CurrentScore)
|
||||||
|
.Include(t => t.Analytics)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
|
||||||
|
|
||||||
|
// Sort by CombinedScore, then by PnL as fallback
|
||||||
|
return await q.OrderByDescending(t => t.CurrentScore != null ? t.CurrentScore.CombinedScore : 0)
|
||||||
|
.ThenByDescending(t => t.TotalPnl)
|
||||||
|
.Skip(skip).Take(take).ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trader>> GetWatchlistedAsync(CancellationToken ct = default)
|
||||||
|
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.WatchlistEntries)
|
||||||
|
.Where(t => t.WatchlistEntries.Any()).ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default)
|
||||||
|
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.Analytics)
|
||||||
|
.OrderByDescending(t => t.CurrentScore!.CombinedScore).Take(count).ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trader>> GetTopByPnLAsync(int count = 5, DateTime? since = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _db.Traders.Include(t => t.CurrentScore).Include(t => t.Analytics).AsQueryable();
|
||||||
|
|
||||||
|
// If 'since' is 7 days ago, try to use PnL7d from Analytics
|
||||||
|
if (since.HasValue && (DateTime.UtcNow - since.Value).TotalDays >= 6.9)
|
||||||
|
{
|
||||||
|
return await q.OrderByDescending(t => t.Analytics != null ? t.Analytics.PnL7d : t.TotalPnl)
|
||||||
|
.Take(count).ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await q.OrderByDescending(t => t.TotalPnl).Take(count).ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetCountAsync(PlatformType? platform = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _db.Traders.AsQueryable();
|
||||||
|
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
|
||||||
|
return await q.CountAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(Trader trader, CancellationToken ct = default)
|
||||||
|
{ _db.Traders.Add(trader); await _db.SaveChangesAsync(ct); }
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Trader trader, CancellationToken ct = default)
|
||||||
|
{ _db.Traders.Update(trader); await _db.SaveChangesAsync(ct); }
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var t = await _db.Traders.FindAsync(new object[] { id }, ct);
|
||||||
|
if (t != null) { _db.Traders.Remove(t); await _db.SaveChangesAsync(ct); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trader>> GetTradersDueForTradeUpdateAsync(int cooldownHours = 12, int take = 20, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Prioritize:
|
||||||
|
// 1. Traders needing initial import (IsInitialImportComplete == false)
|
||||||
|
// 2. Traders where LastTradesUpdatedAt < cutoff (cooldownHours)
|
||||||
|
|
||||||
|
var cutoff = DateTime.UtcNow.AddHours(-cooldownHours);
|
||||||
|
|
||||||
|
return await _db.Traders
|
||||||
|
.Where(t => !t.IsInitialImportComplete || t.LastTradesUpdatedAt == null || t.LastTradesUpdatedAt < cutoff)
|
||||||
|
.OrderBy(t => t.IsInitialImportComplete) // false (0) comes before true (1)
|
||||||
|
.ThenBy(t => t.LastTradesUpdatedAt ?? DateTime.MinValue) // Oldest first
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trader>> GetTradersForCleanupAsync(DateTime inactiveSince, DateTime errorSince, int take = 50, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _db.Traders
|
||||||
|
.Where(t => (t.LastPolledAt != null && t.LastPolledAt < inactiveSince) ||
|
||||||
|
(t.LastApiErrorAt != null && t.LastApiErrorAt < errorSince))
|
||||||
|
.OrderBy(t => t.LastApiErrorAt ?? DateTime.MaxValue) // Prioritize errors first
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trader>> SearchAsync(string query, int take = 20, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query)) return Array.Empty<Trader>();
|
||||||
|
|
||||||
|
return await _db.Traders
|
||||||
|
.Include(t => t.CurrentScore)
|
||||||
|
.Include(t => t.Analytics)
|
||||||
|
.Where(t => t.DisplayName.Contains(query) ||
|
||||||
|
t.PlatformUserId.Contains(query) ||
|
||||||
|
t.Id.ToString() == query)
|
||||||
|
.OrderByDescending(t => t.CurrentScore != null ? t.CurrentScore.CombinedScore : 0)
|
||||||
|
.ThenByDescending(t => t.TotalPnl)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Data.Repositories;
|
||||||
|
|
||||||
|
public class WatchlistRepository : IWatchlistRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public WatchlistRepository(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> await _db.WatchlistEntries.Include(w => w.Trader).ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default)
|
||||||
|
=> await _db.WatchlistEntries.FirstOrDefaultAsync(w => w.TraderId == traderId, ct);
|
||||||
|
|
||||||
|
public async Task AddAsync(WatchlistEntry entry, CancellationToken ct = default)
|
||||||
|
{ _db.WatchlistEntries.Add(entry); await _db.SaveChangesAsync(ct); }
|
||||||
|
|
||||||
|
public async Task RemoveAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var e = await _db.WatchlistEntries.FindAsync(new object[] { id }, ct);
|
||||||
|
if (e != null) { _db.WatchlistEntries.Remove(e); await _db.SaveChangesAsync(ct); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Predictalytics.Infrastructure.Data;
|
||||||
|
|
||||||
|
public static class StringHelper
|
||||||
|
{
|
||||||
|
public static string? Truncate(string? value, int maxLength)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)) return value;
|
||||||
|
return value.Length <= maxLength ? value : value[..maxLength];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Application.Services;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Predictalytics.Infrastructure.Data;
|
||||||
|
using Predictalytics.Infrastructure.Data.Repositories;
|
||||||
|
using Predictalytics.Infrastructure.Providers.Azuro;
|
||||||
|
using Predictalytics.Infrastructure.Providers.Limitless;
|
||||||
|
using Predictalytics.Infrastructure.Providers.Polymarket;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure;
|
||||||
|
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddPredictalytics(this IServiceCollection services, IConfiguration configuration, string? connectionStringOverride = null, bool dbDebug = false)
|
||||||
|
{
|
||||||
|
if (dbDebug) Serilog.Log.Warning(">>> INFRASTRUCTURE: AddPredictalytics STARTING");
|
||||||
|
|
||||||
|
// MySQL / EF Core
|
||||||
|
var connectionString = connectionStringOverride;
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||||
|
?? "Server=localhost;Database=Predictalytics_dev;User=root;Password=;";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use MySqlConnectionStringBuilder to ensure valid format and parse components
|
||||||
|
var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(connectionString);
|
||||||
|
|
||||||
|
// Final safety check
|
||||||
|
if (string.IsNullOrWhiteSpace(csBuilder.Database))
|
||||||
|
{
|
||||||
|
Serilog.Log.Error("❌ INVALID CONNECTION STRING: Database name is empty! (Input length: {Length})", connectionString.Length);
|
||||||
|
throw new InvalidOperationException("The connection string is missing a valid 'Database' parameter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var maskedCs = csBuilder.ConnectionString.Replace(csBuilder.Password, "****");
|
||||||
|
if (dbDebug)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning("🗄️ Initializing database connection: {ConnectionString}", maskedCs);
|
||||||
|
Serilog.Log.Warning("🗄️ Target Server: {Server}, Database: {Database}", csBuilder.Server, csBuilder.Database);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly register the connection string so we can use it elsewhere if needed
|
||||||
|
services.AddSingleton(csBuilder.ConnectionString);
|
||||||
|
|
||||||
|
services.AddDbContext<AppDbContext>(options =>
|
||||||
|
{
|
||||||
|
// Log exactly what is being used at the moment of configuration
|
||||||
|
if (dbDebug) Serilog.Log.Warning("🛠️ EF: Configuring AppDbContext. Target DB: '{Database}'", csBuilder.Database);
|
||||||
|
|
||||||
|
options.UseMySql(csBuilder.ConnectionString, new MySqlServerVersion(new Version(8, 0, 31)),
|
||||||
|
mysql => mysql.EnableRetryOnFailure(3, TimeSpan.FromSeconds(10), null));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Repositories
|
||||||
|
services.AddScoped<ITraderRepository, TraderRepository>();
|
||||||
|
services.AddScoped<ITradeRepository, TradeRepository>();
|
||||||
|
services.AddScoped<IMarketRepository, MarketRepository>();
|
||||||
|
services.AddScoped<IWatchlistRepository, WatchlistRepository>();
|
||||||
|
services.AddScoped<IAlertRepository, AlertRepository>();
|
||||||
|
|
||||||
|
// Application Services
|
||||||
|
services.AddScoped<IScoringService, ScoringService>();
|
||||||
|
services.AddScoped<IDiscoveryService, DiscoveryService>();
|
||||||
|
services.AddScoped<IAlertService, AlertService>();
|
||||||
|
services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||||
|
services.AddScoped<WatchlistService>();
|
||||||
|
services.AddSingleton<IRateLimiter, RateLimiterService>();
|
||||||
|
services.AddSingleton<IPlatformStatisticsService, PlatformStatisticsService>();
|
||||||
|
|
||||||
|
// Platform Providers
|
||||||
|
services.AddHttpClient();
|
||||||
|
services.AddHttpClient("LimitlessApi", c =>
|
||||||
|
{
|
||||||
|
c.BaseAddress = new Uri("https://api.limitless.exchange/");
|
||||||
|
c.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||||
|
c.Timeout = TimeSpan.FromSeconds(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddSingleton<PolymarketApiClient>();
|
||||||
|
services.AddSingleton<LimitlessApiClient>();
|
||||||
|
services.AddSingleton<IPlatformProvider, PolymarketProvider>();
|
||||||
|
services.AddSingleton<IPlatformProvider, LimitlessProvider>();
|
||||||
|
services.AddSingleton<IPlatformProvider, AzuroProvider>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies pending EF Core migrations and seeds default platform rows.
|
||||||
|
/// Requires the target database to already be "stamped" with the InitialBaseline
|
||||||
|
/// migration in __EFMigrationsHistory (see UMSETZUNGSPLAN.md, section B1) if it was
|
||||||
|
/// previously created via the old EnsureCreated + manual ALTER approach.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task EnsureDatabaseAsync(IServiceProvider services, bool dbDebug = false)
|
||||||
|
{
|
||||||
|
using var scope = services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (dbDebug)
|
||||||
|
{
|
||||||
|
var maskedConnStr = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
db.Database.GetDbConnection().ConnectionString ?? "NULL", "Password=[^;]+", "Password=****");
|
||||||
|
Serilog.Log.Warning("🔍 DEBUG: Applying EF Core migrations. ConnectionString: {CS}", maskedConnStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
|
||||||
|
if (dbDebug) Serilog.Log.Warning("✅ DEBUG: Migrations applied successfully.");
|
||||||
|
|
||||||
|
// Seed default platform rows (idempotent)
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync();
|
||||||
|
|
||||||
|
using var seedPlatform = conn.CreateCommand();
|
||||||
|
seedPlatform.CommandText = @"
|
||||||
|
INSERT IGNORE INTO `PlatformConfigs` (`Id`, `Name`, `DisplayName`, `IsActive`, `CreatedAt`, `UpdatedAt`) VALUES
|
||||||
|
(0, 'Unknown', 'Unknown Platform', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP()),
|
||||||
|
(1, 'Polymarket', 'Polymarket', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP()),
|
||||||
|
(2, 'Azuro', 'Azuro', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP()),
|
||||||
|
(3, 'Limitless', 'Limitless', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP());";
|
||||||
|
await seedPlatform.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
await conn.CloseAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning("⚠️ Could not connect to database or apply migrations: {Message}. Background workers will retry connection automatically.", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Serilog.Context;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to push platform context into Serilog LogContext for platform-filtered file sinks.
|
||||||
|
/// Usage: using (PlatformLogContext.Push("Polymarket")) { ... }
|
||||||
|
/// </summary>
|
||||||
|
public static class PlatformLogContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes the platform name to the Serilog LogContext.
|
||||||
|
/// Dispose the returned IDisposable to remove it.
|
||||||
|
/// </summary>
|
||||||
|
public static IDisposable Push(string platformName)
|
||||||
|
{
|
||||||
|
return LogContext.PushProperty("Platform", platformName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom Serilog sink that delegates log writes to a provided action.
|
||||||
|
/// The action is responsible for marshaling to the correct thread (e.g. UI thread).
|
||||||
|
/// </summary>
|
||||||
|
public class RichTextBoxSink : ILogEventSink
|
||||||
|
{
|
||||||
|
private readonly Action<string, LogEventLevel> _writeAction;
|
||||||
|
|
||||||
|
public RichTextBoxSink(Action<string, LogEventLevel> writeAction)
|
||||||
|
{
|
||||||
|
_writeAction = writeAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Emit(LogEvent logEvent)
|
||||||
|
{
|
||||||
|
var message = $"[{logEvent.Timestamp:HH:mm:ss}] [{logEvent.Level.ToString()[..3].ToUpper()}] {logEvent.RenderMessage()}";
|
||||||
|
if (logEvent.Exception != null)
|
||||||
|
message += $"\n ⚠ {logEvent.Exception.Message}";
|
||||||
|
|
||||||
|
_writeAction(message + "\n", logEvent.Level);
|
||||||
|
}
|
||||||
|
}
|
||||||
+641
@@ -0,0 +1,641 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Predictalytics.Infrastructure.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260701102311_InitialBaseline")]
|
||||||
|
partial class InitialBaseline
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.11")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("varchar(4096)");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Severity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<int?>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId");
|
||||||
|
|
||||||
|
b.ToTable("Alerts");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DbCreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("varchar(4096)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("EventSlug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<string>("ImageUrl")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsResolved")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastTradesUpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastUpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Liquidity")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("MarketSlug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PlatformMarketId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Question")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<string>("ResolutionOutcome")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Volume")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "PlatformMarketId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Markets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("MarketId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("AverageTradeSize")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("BotActivityScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastCalculatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("UniqueTradersCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("MarketId");
|
||||||
|
|
||||||
|
b.ToTable("MarketAnalytics");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasPrecision(18, 8)
|
||||||
|
.HasColumnType("decimal(18,8)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MarketId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("OutcomeIndex")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenId");
|
||||||
|
|
||||||
|
b.HasIndex("MarketId", "OutcomeIndex")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MarketOutcomes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("BaseUrl")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("SettingsJson")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("PlatformConfigs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("AssetId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(80)
|
||||||
|
.HasColumnType("varchar(80)");
|
||||||
|
|
||||||
|
b.Property<int?>("DbMarketId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExecutedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("MarketId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(66)
|
||||||
|
.HasColumnType("varchar(66)");
|
||||||
|
|
||||||
|
b.Property<int?>("MarketOutcomeId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Outcome")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PlatformTradeId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasPrecision(10, 6)
|
||||||
|
.HasColumnType("decimal(10,6)");
|
||||||
|
|
||||||
|
b.Property<int>("Side")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("Size")
|
||||||
|
.HasPrecision(14, 6)
|
||||||
|
.HasColumnType("decimal(14,6)");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("TransactionHash")
|
||||||
|
.HasMaxLength(66)
|
||||||
|
.HasColumnType("varchar(66)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssetId");
|
||||||
|
|
||||||
|
b.HasIndex("DbMarketId");
|
||||||
|
|
||||||
|
b.HasIndex("ExecutedAt");
|
||||||
|
|
||||||
|
b.HasIndex("MarketOutcomeId");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "PlatformTradeId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Trades");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAutoDiscovered")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsInitialImportComplete")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSuspectedBot")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastApiErrorAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastPolledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastTradesUpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("ManualPriorityOverride")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PlatformUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Strategy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Tier")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalPnl")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("TotalTrades")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "PlatformUserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Traders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastCalculatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("OverallPnL")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("OverallWinRate")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PnL24h")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PnL30d")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PnL7d")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate24h")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate30d")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate7d")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.HasKey("TraderId");
|
||||||
|
|
||||||
|
b.ToTable("TraderAnalytics");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("ActivityScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CalculatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CombinedScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<int>("Rank")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("TimingScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("VolumeScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TraderScores");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("AddedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("AlertsEnabled")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("WatchlistEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
|
||||||
|
.WithOne("Analytics")
|
||||||
|
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Market");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
|
||||||
|
.WithMany("Outcomes")
|
||||||
|
.HasForeignKey("MarketId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Market");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DbMarketId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MarketOutcomeId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany("Trades")
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("DbMarket");
|
||||||
|
|
||||||
|
b.Navigation("MarketOutcome");
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithOne("Analytics")
|
||||||
|
.HasForeignKey("Predictalytics.Domain.Entities.TraderAnalytics", "TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithOne("CurrentScore")
|
||||||
|
.HasForeignKey("Predictalytics.Domain.Entities.TraderScore", "TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany("WatchlistEntries")
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Analytics");
|
||||||
|
|
||||||
|
b.Navigation("Outcomes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Analytics");
|
||||||
|
|
||||||
|
b.Navigation("CurrentScore");
|
||||||
|
|
||||||
|
b.Navigation("Trades");
|
||||||
|
|
||||||
|
b.Navigation("WatchlistEntries");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialBaseline : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Markets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
Platform = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PlatformMarketId = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
MarketSlug = table.Column<string>(type: "varchar(512)", maxLength: 512, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
EventSlug = table.Column<string>(type: "varchar(512)", maxLength: 512, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Description = table.Column<string>(type: "varchar(4096)", maxLength: 4096, nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
ImageUrl = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Question = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Category = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Volume = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
Liquidity = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
StartDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
EndDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
IsResolved = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
ResolutionOutcome = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
DbCreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
LastUpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
LastTradesUpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Markets", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PlatformConfigs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
DisplayName = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
BaseUrl = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
SettingsJson = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PlatformConfigs", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Traders",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
Platform = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PlatformUserId = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
DisplayName = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Notes = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
IsAutoDiscovered = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
Tier = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Strategy = table.Column<int>(type: "int", nullable: false),
|
||||||
|
IsSuspectedBot = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
ManualPriorityOverride = table.Column<int>(type: "int", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
LastPolledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
LastTradesUpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
IsInitialImportComplete = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
LastApiErrorAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
TotalPnl = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
WinRate = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
|
||||||
|
TotalTrades = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Traders", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "MarketAnalytics",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
MarketId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
LastCalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
BotActivityScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
|
||||||
|
UniqueTradersCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AverageTradeSize = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_MarketAnalytics", x => x.MarketId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_MarketAnalytics_Markets_MarketId",
|
||||||
|
column: x => x.MarketId,
|
||||||
|
principalTable: "Markets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "MarketOutcomes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
MarketId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Label = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
OutcomeIndex = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TokenId = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
CurrentPrice = table.Column<decimal>(type: "decimal(18,8)", precision: 18, scale: 8, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_MarketOutcomes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_MarketOutcomes_Markets_MarketId",
|
||||||
|
column: x => x.MarketId,
|
||||||
|
principalTable: "Markets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Alerts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
Type = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Platform = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TraderId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
Title = table.Column<string>(type: "varchar(512)", maxLength: 512, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Message = table.Column<string>(type: "varchar(4096)", maxLength: 4096, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Severity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
IsRead = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Alerts", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Alerts_Traders_TraderId",
|
||||||
|
column: x => x.TraderId,
|
||||||
|
principalTable: "Traders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TraderAnalytics",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
TraderId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
LastCalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
OverallPnL = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
OverallWinRate = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
|
||||||
|
PnL30d = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
WinRate30d = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
|
||||||
|
PnL7d = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
WinRate7d = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
|
||||||
|
PnL24h = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
WinRate24h = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TraderAnalytics", x => x.TraderId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TraderAnalytics_Traders_TraderId",
|
||||||
|
column: x => x.TraderId,
|
||||||
|
principalTable: "Traders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TraderScores",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
TraderId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ActivityScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
|
||||||
|
QualityScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
|
||||||
|
CombinedScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
|
||||||
|
VolumeScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
|
||||||
|
TimingScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
|
||||||
|
Rank = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TraderScores", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TraderScores_Traders_TraderId",
|
||||||
|
column: x => x.TraderId,
|
||||||
|
principalTable: "Traders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "WatchlistEntries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
TraderId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Label = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Notes = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
AlertsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
AddedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_WatchlistEntries", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_WatchlistEntries_Traders_TraderId",
|
||||||
|
column: x => x.TraderId,
|
||||||
|
principalTable: "Traders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Trades",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
TraderId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Platform = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PlatformTradeId = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
MarketId = table.Column<string>(type: "varchar(66)", maxLength: 66, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
DbMarketId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
AssetId = table.Column<string>(type: "varchar(80)", maxLength: 80, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
MarketOutcomeId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
Outcome = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Side = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Price = table.Column<decimal>(type: "decimal(10,6)", precision: 10, scale: 6, nullable: false),
|
||||||
|
Size = table.Column<decimal>(type: "decimal(14,6)", precision: 14, scale: 6, nullable: false),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
ExecutedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
TransactionHash = table.Column<string>(type: "varchar(66)", maxLength: 66, nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Trades", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Trades_MarketOutcomes_MarketOutcomeId",
|
||||||
|
column: x => x.MarketOutcomeId,
|
||||||
|
principalTable: "MarketOutcomes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Trades_Markets_DbMarketId",
|
||||||
|
column: x => x.DbMarketId,
|
||||||
|
principalTable: "Markets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Trades_Traders_TraderId",
|
||||||
|
column: x => x.TraderId,
|
||||||
|
principalTable: "Traders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Alerts_CreatedAt",
|
||||||
|
table: "Alerts",
|
||||||
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Alerts_TraderId",
|
||||||
|
table: "Alerts",
|
||||||
|
column: "TraderId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_MarketOutcomes_MarketId_OutcomeIndex",
|
||||||
|
table: "MarketOutcomes",
|
||||||
|
columns: new[] { "MarketId", "OutcomeIndex" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_MarketOutcomes_TokenId",
|
||||||
|
table: "MarketOutcomes",
|
||||||
|
column: "TokenId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Markets_Platform_PlatformMarketId",
|
||||||
|
table: "Markets",
|
||||||
|
columns: new[] { "Platform", "PlatformMarketId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Traders_Platform_PlatformUserId",
|
||||||
|
table: "Traders",
|
||||||
|
columns: new[] { "Platform", "PlatformUserId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TraderScores_TraderId",
|
||||||
|
table: "TraderScores",
|
||||||
|
column: "TraderId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Trades_AssetId",
|
||||||
|
table: "Trades",
|
||||||
|
column: "AssetId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Trades_DbMarketId",
|
||||||
|
table: "Trades",
|
||||||
|
column: "DbMarketId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Trades_ExecutedAt",
|
||||||
|
table: "Trades",
|
||||||
|
column: "ExecutedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Trades_MarketOutcomeId",
|
||||||
|
table: "Trades",
|
||||||
|
column: "MarketOutcomeId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Trades_Platform_PlatformTradeId",
|
||||||
|
table: "Trades",
|
||||||
|
columns: new[] { "Platform", "PlatformTradeId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Trades_TraderId",
|
||||||
|
table: "Trades",
|
||||||
|
column: "TraderId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_WatchlistEntries_TraderId",
|
||||||
|
table: "WatchlistEntries",
|
||||||
|
column: "TraderId",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Alerts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "MarketAnalytics");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PlatformConfigs");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TraderAnalytics");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TraderScores");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Trades");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "WatchlistEntries");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "MarketOutcomes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Traders");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Markets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Predictalytics.Infrastructure.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.11")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("varchar(4096)");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Severity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<int?>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId");
|
||||||
|
|
||||||
|
b.ToTable("Alerts");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DbCreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("varchar(4096)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("EventSlug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<string>("ImageUrl")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsResolved")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastTradesUpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastUpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Liquidity")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("MarketSlug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PlatformMarketId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Question")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<string>("ResolutionOutcome")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Volume")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "PlatformMarketId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Markets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("MarketId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("AverageTradeSize")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("BotActivityScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastCalculatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("UniqueTradersCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("MarketId");
|
||||||
|
|
||||||
|
b.ToTable("MarketAnalytics");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasPrecision(18, 8)
|
||||||
|
.HasColumnType("decimal(18,8)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MarketId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("OutcomeIndex")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenId");
|
||||||
|
|
||||||
|
b.HasIndex("MarketId", "OutcomeIndex")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MarketOutcomes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("BaseUrl")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("SettingsJson")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("PlatformConfigs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("AssetId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(80)
|
||||||
|
.HasColumnType("varchar(80)");
|
||||||
|
|
||||||
|
b.Property<int?>("DbMarketId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExecutedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("MarketId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(66)
|
||||||
|
.HasColumnType("varchar(66)");
|
||||||
|
|
||||||
|
b.Property<int?>("MarketOutcomeId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Outcome")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PlatformTradeId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasPrecision(10, 6)
|
||||||
|
.HasColumnType("decimal(10,6)");
|
||||||
|
|
||||||
|
b.Property<int>("Side")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("Size")
|
||||||
|
.HasPrecision(14, 6)
|
||||||
|
.HasColumnType("decimal(14,6)");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("TransactionHash")
|
||||||
|
.HasMaxLength(66)
|
||||||
|
.HasColumnType("varchar(66)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssetId");
|
||||||
|
|
||||||
|
b.HasIndex("DbMarketId");
|
||||||
|
|
||||||
|
b.HasIndex("ExecutedAt");
|
||||||
|
|
||||||
|
b.HasIndex("MarketOutcomeId");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "PlatformTradeId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Trades");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAutoDiscovered")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsInitialImportComplete")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSuspectedBot")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastApiErrorAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastPolledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastTradesUpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int?>("ManualPriorityOverride")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PlatformUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Strategy")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Tier")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalPnl")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("TotalTrades")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "PlatformUserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Traders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastCalculatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("OverallPnL")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("OverallWinRate")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PnL24h")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PnL30d")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PnL7d")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate24h")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate30d")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("WinRate7d")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.HasKey("TraderId");
|
||||||
|
|
||||||
|
b.ToTable("TraderAnalytics");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("ActivityScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CalculatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CombinedScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<int>("Rank")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("TimingScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("VolumeScore")
|
||||||
|
.HasPrecision(8, 4)
|
||||||
|
.HasColumnType("decimal(8,4)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TraderScores");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("AddedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("AlertsEnabled")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("WatchlistEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
|
||||||
|
.WithOne("Analytics")
|
||||||
|
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Market");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
|
||||||
|
.WithMany("Outcomes")
|
||||||
|
.HasForeignKey("MarketId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Market");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DbMarketId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MarketOutcomeId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany("Trades")
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("DbMarket");
|
||||||
|
|
||||||
|
b.Navigation("MarketOutcome");
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithOne("Analytics")
|
||||||
|
.HasForeignKey("Predictalytics.Domain.Entities.TraderAnalytics", "TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithOne("CurrentScore")
|
||||||
|
.HasForeignKey("Predictalytics.Domain.Entities.TraderScore", "TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany("WatchlistEntries")
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Analytics");
|
||||||
|
|
||||||
|
b.Navigation("Outcomes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Analytics");
|
||||||
|
|
||||||
|
b.Navigation("CurrentScore");
|
||||||
|
|
||||||
|
b.Navigation("Trades");
|
||||||
|
|
||||||
|
b.Navigation("WatchlistEntries");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>Predictalytics.Infrastructure</RootNamespace>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
|
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Predictalytics.Domain\Predictalytics.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\Predictalytics.Application\Predictalytics.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Predictalytics.Infrastructure.Logging;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Providers.Azuro;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Placeholder provider for Azuro prediction market.
|
||||||
|
/// All log entries include Platform=Azuro for log-file routing.
|
||||||
|
/// </summary>
|
||||||
|
public class AzuroProvider : IPlatformProvider
|
||||||
|
{
|
||||||
|
private readonly ILogger<AzuroProvider> _logger;
|
||||||
|
public AzuroProvider(ILogger<AzuroProvider> logger) => _logger = logger;
|
||||||
|
|
||||||
|
public PlatformType Platform => PlatformType.Azuro;
|
||||||
|
public string PlatformName => "Azuro";
|
||||||
|
public bool IsImplemented => false;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 50, CancellationToken ct = default)
|
||||||
|
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<IReadOnlyList<Trade>>(Array.Empty<Trade>()); }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default)
|
||||||
|
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<IReadOnlyList<TraderPositionInfo>>(Array.Empty<TraderPositionInfo>()); }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 20, CancellationToken ct = default)
|
||||||
|
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<IReadOnlyList<DiscoveredTrader>>(Array.Empty<DiscoveredTrader>()); }
|
||||||
|
|
||||||
|
public Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
|
||||||
|
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<Market?>(null); }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
|
||||||
|
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<Market>>(Array.Empty<Market>()); }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
|
||||||
|
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<DiscoveredTrader>>(Array.Empty<DiscoveredTrader>()); }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default)
|
||||||
|
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<Trade>>(Array.Empty<Trade>()); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Providers.Limitless;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP client for Limitless API.
|
||||||
|
/// Base URL: https://api.limitless.exchange
|
||||||
|
/// </summary>
|
||||||
|
public class LimitlessApiClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly ILogger<LimitlessApiClient> _logger;
|
||||||
|
|
||||||
|
public LimitlessApiClient(IHttpClientFactory httpFactory, ILogger<LimitlessApiClient> logger)
|
||||||
|
{
|
||||||
|
_client = httpFactory.CreateClient("LimitlessApi");
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<LimitlessMarketResponse>> GetActiveMarketsAsync(int limit = 100, int offset = 0, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var url = $"markets/active?limit={Math.Min(limit, 25)}"; // Offset is not supported by this endpoint, limit max 25
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync(url, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
_logger.LogError("Limitless API 400/Error for {Url}: {Error}", url, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<LimitlessActiveMarketsResponse>(cancellationToken: ct);
|
||||||
|
return result?.Data ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to fetch active markets from Limitless via {Url}", url);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LimitlessMarketResponse?> GetMarketAsync(string addressOrSlug, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var url = $"markets/{addressOrSlug}";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync(url, ct);
|
||||||
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
return await response.Content.ReadFromJsonAsync<LimitlessMarketResponse>(cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to fetch Limitless market: {Url}", url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LimitlessPortfolioResponse?> GetPositionsAsync(string walletAddress, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var url = $"portfolio/{walletAddress}/positions";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync(url, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
_logger.LogError("Limitless API Error for {Url}: {Error}", url, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await response.Content.ReadFromJsonAsync<LimitlessPortfolioResponse>(cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to fetch Limitless positions for {Url}", url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<LimitlessEventResponse>> GetMarketEventsAsync(string slug, int limit = 50, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var url = $"markets/{slug}/events?limit={limit}";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync(url, ct);
|
||||||
|
if (!response.IsSuccessStatusCode) return [];
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<LimitlessEventsResponse>(cancellationToken: ct);
|
||||||
|
return result?.Events ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to fetch events for Limitless market {Url}", url);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Providers.Limitless;
|
||||||
|
|
||||||
|
public class LimitlessActiveMarketsResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("data")]
|
||||||
|
public List<LimitlessMarketResponse>? Data { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("totalMarketsCount")]
|
||||||
|
public int TotalMarketsCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitlessEventsResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("events")]
|
||||||
|
public List<LimitlessEventResponse>? Events { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitlessPortfolioResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("clob")]
|
||||||
|
public List<LimitlessClobItem>? Clob { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("amm")]
|
||||||
|
public List<LimitlessClobItem>? Amm { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("group")]
|
||||||
|
public List<LimitlessClobItem>? Group { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitlessClobItem
|
||||||
|
{
|
||||||
|
[JsonPropertyName("market")]
|
||||||
|
public LimitlessMarketResponse? Market { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("positions")]
|
||||||
|
public LimitlessPositionsContainer? Positions { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitlessPositionsContainer
|
||||||
|
{
|
||||||
|
[JsonPropertyName("yes")]
|
||||||
|
public LimitlessPositionDetails? Yes { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("no")]
|
||||||
|
public LimitlessPositionDetails? No { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitlessPositionDetails
|
||||||
|
{
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
public string? Size { get; set; } // API uses strings for numbers here
|
||||||
|
|
||||||
|
[JsonPropertyName("fillPrice")]
|
||||||
|
public string? FillPrice { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("marketValue")]
|
||||||
|
public string? MarketValue { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("realisedPnl")]
|
||||||
|
public string? RealisedPnl { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("unrealizedPnl")]
|
||||||
|
public string? UnrealizedPnl { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("latestTrade")]
|
||||||
|
public LimitlessTradeInfo? LatestTrade { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitlessMarketResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("address")]
|
||||||
|
public string? Address { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("slug")]
|
||||||
|
public string? Slug { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("description")]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("categories")]
|
||||||
|
public List<string>? Categories { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("imageUrl")]
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expirationDate")]
|
||||||
|
public string? ExpirationDate { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expirationTimestamp")]
|
||||||
|
public long? ExpirationTimestamp { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("active")]
|
||||||
|
public bool Active { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("closed")]
|
||||||
|
public bool Closed { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("volumeFormatted")]
|
||||||
|
public string? VolumeFormatted { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("liquidity")]
|
||||||
|
public double? Liquidity { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("prices")]
|
||||||
|
public List<double>? Prices { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("tokens")]
|
||||||
|
public LimitlessTokens? Tokens { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitlessTokens
|
||||||
|
{
|
||||||
|
[JsonPropertyName("yes")]
|
||||||
|
public string? Yes { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("no")]
|
||||||
|
public string? No { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitlessTradeInfo
|
||||||
|
{
|
||||||
|
[JsonPropertyName("timestamp")]
|
||||||
|
public long Timestamp { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("price")]
|
||||||
|
public string? Price { get; set; } // Might be string in this context
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
public string? Size { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("side")]
|
||||||
|
public object? Side { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("transactionHash")]
|
||||||
|
public string? TransactionHash { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitlessEventResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("txHash")]
|
||||||
|
public string? TxHash { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("side")]
|
||||||
|
public object? Side { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("price")]
|
||||||
|
public double? Price { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
public double? Size { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("createdAt")]
|
||||||
|
public string? CreatedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("profile")]
|
||||||
|
public LimitlessProfile? Profile { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("asset")]
|
||||||
|
public string? Asset { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitlessProfile
|
||||||
|
{
|
||||||
|
[JsonPropertyName("account")]
|
||||||
|
public string? Account { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Predictalytics.Infrastructure.Logging;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Providers.Limitless;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Placeholder provider for Limitless prediction market.
|
||||||
|
/// All log entries include Platform=Limitless for log-file routing.
|
||||||
|
/// </summary>
|
||||||
|
public class LimitlessProvider : IPlatformProvider
|
||||||
|
{
|
||||||
|
private readonly LimitlessApiClient _api;
|
||||||
|
private readonly ILogger<LimitlessProvider> _logger;
|
||||||
|
|
||||||
|
public PlatformType Platform => PlatformType.Limitless;
|
||||||
|
public string PlatformName => "Limitless";
|
||||||
|
public bool IsImplemented => true;
|
||||||
|
|
||||||
|
public LimitlessProvider(LimitlessApiClient api, ILogger<LimitlessProvider> logger)
|
||||||
|
{
|
||||||
|
_api = api;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 50, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var _ = PlatformLogContext.Push(PlatformName);
|
||||||
|
_logger.LogDebug("Fetching latest trade activity via positions for {Wallet}", platformUserId);
|
||||||
|
|
||||||
|
var portfolio = await _api.GetPositionsAsync(platformUserId, ct);
|
||||||
|
if (portfolio == null) return [];
|
||||||
|
|
||||||
|
var items = (portfolio.Clob ?? []).Concat(portfolio.Amm ?? []).Concat(portfolio.Group ?? []);
|
||||||
|
var trades = new List<Trade>();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (item.Positions == null || item.Market == null) continue;
|
||||||
|
|
||||||
|
// Check Yes and No positions for latest trades
|
||||||
|
var posList = new[] {
|
||||||
|
(Details: item.Positions.Yes, Outcome: "Yes", TokenId: item.Market.Tokens?.Yes),
|
||||||
|
(Details: item.Positions.No, Outcome: "No", TokenId: item.Market.Tokens?.No)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var pos in posList)
|
||||||
|
{
|
||||||
|
if (pos.Details?.LatestTrade == null) continue;
|
||||||
|
|
||||||
|
var lt = pos.Details.LatestTrade;
|
||||||
|
decimal.TryParse(lt.Price, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var price);
|
||||||
|
decimal.TryParse(lt.Size, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var size);
|
||||||
|
|
||||||
|
trades.Add(new Trade
|
||||||
|
{
|
||||||
|
Platform = PlatformType.Limitless,
|
||||||
|
// Compact format: {txHash}_{assetId} — wallet via TransientWallet
|
||||||
|
PlatformTradeId = lt.TransactionHash != null
|
||||||
|
? $"{lt.TransactionHash}_{pos.TokenId}"
|
||||||
|
: $"{lt.Timestamp}_{pos.TokenId}",
|
||||||
|
MarketId = item.Market.Address ?? item.Market.Slug ?? "",
|
||||||
|
AssetId = pos.TokenId ?? "",
|
||||||
|
Outcome = pos.Outcome,
|
||||||
|
Side = ParseSide(lt.Side),
|
||||||
|
Price = price,
|
||||||
|
Size = size,
|
||||||
|
Amount = price * size,
|
||||||
|
ExecutedAt = (lt.Timestamp > 0 && lt.Timestamp < 253402300799)
|
||||||
|
? DateTimeOffset.FromUnixTimeSeconds(lt.Timestamp).UtcDateTime
|
||||||
|
: DateTime.UtcNow,
|
||||||
|
TransactionHash = lt.TransactionHash,
|
||||||
|
TransientWallet = platformUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trades.OrderByDescending(t => t.ExecutedAt).Take(limit).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var _ = PlatformLogContext.Push(PlatformName);
|
||||||
|
var portfolio = await _api.GetPositionsAsync(platformUserId, ct);
|
||||||
|
if (portfolio == null) return [];
|
||||||
|
|
||||||
|
var items = (portfolio.Clob ?? []).Concat(portfolio.Amm ?? []).Concat(portfolio.Group ?? []);
|
||||||
|
var result = new List<TraderPositionInfo>();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (item.Positions == null || item.Market == null) continue;
|
||||||
|
|
||||||
|
var posList = new[] {
|
||||||
|
(Details: item.Positions.Yes, Outcome: "Yes", TokenId: item.Market.Tokens?.Yes),
|
||||||
|
(Details: item.Positions.No, Outcome: "No", TokenId: item.Market.Tokens?.No)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var pos in posList)
|
||||||
|
{
|
||||||
|
if (pos.Details == null) continue;
|
||||||
|
|
||||||
|
decimal.TryParse(pos.Details.Size, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var size);
|
||||||
|
if (size == 0) continue;
|
||||||
|
|
||||||
|
decimal.TryParse(pos.Details.FillPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var avgPrice);
|
||||||
|
decimal.TryParse(pos.Details.MarketValue, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var val);
|
||||||
|
decimal.TryParse(pos.Details.UnrealizedPnl, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var pnl);
|
||||||
|
|
||||||
|
result.Add(new TraderPositionInfo(
|
||||||
|
platformUserId,
|
||||||
|
item.Market.Address ?? item.Market.Slug ?? "",
|
||||||
|
item.Market.Title ?? "",
|
||||||
|
pos.Outcome,
|
||||||
|
size,
|
||||||
|
avgPrice,
|
||||||
|
val,
|
||||||
|
0 // PercentPnl not directly available as decimal in this view
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 20, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var _ = PlatformLogContext.Push(PlatformName);
|
||||||
|
_logger.LogInformation("Trader discovery for Limitless via active markets...");
|
||||||
|
|
||||||
|
var markets = await _api.GetActiveMarketsAsync(10, 0, ct);
|
||||||
|
var traders = new List<DiscoveredTrader>();
|
||||||
|
|
||||||
|
foreach (var m in markets.Take(5))
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
|
var events = await _api.GetMarketEventsAsync(m.Slug ?? m.Address ?? "", 20, ct);
|
||||||
|
|
||||||
|
foreach (var e in events.Where(ev => ev.Profile?.Account != null))
|
||||||
|
{
|
||||||
|
traders.Add(new DiscoveredTrader(
|
||||||
|
e.Profile!.Account!,
|
||||||
|
e.Profile.Account![..10] + "...",
|
||||||
|
(decimal)(e.Price * e.Size ?? 0),
|
||||||
|
1, 0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return traders.GroupBy(t => t.PlatformUserId)
|
||||||
|
.Select(g => g.First())
|
||||||
|
.Take(limit)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var _ = PlatformLogContext.Push(PlatformName);
|
||||||
|
var raw = await _api.GetMarketAsync(platformMarketId, ct);
|
||||||
|
if (raw == null) return null;
|
||||||
|
|
||||||
|
return MapLimitlessMarket(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var _ = PlatformLogContext.Push(PlatformName);
|
||||||
|
int.TryParse(cursor, out var offset);
|
||||||
|
|
||||||
|
// Since Limitless /markets/active doesn't support offset, we only return the first page.
|
||||||
|
// Returning data for offset > 0 would cause an infinite loop in MarketSyncWorker.
|
||||||
|
if (offset > 0) return [];
|
||||||
|
|
||||||
|
var raw = await _api.GetActiveMarketsAsync(limit, offset, ct);
|
||||||
|
return raw.Select(MapLimitlessMarket).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var events = await _api.GetMarketEventsAsync(platformMarketId, limit * 2, ct);
|
||||||
|
return events
|
||||||
|
.Where(e => e.Profile?.Account != null)
|
||||||
|
.GroupBy(e => e.Profile!.Account)
|
||||||
|
.Select(g => new DiscoveredTrader(g.Key!, g.Key![..10] + "...", 0, g.Count(), 0))
|
||||||
|
.Take(limit)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var events = await _api.GetMarketEventsAsync(platformMarketId, limit, ct);
|
||||||
|
return events
|
||||||
|
.Where(e => e.Profile?.Account != null)
|
||||||
|
.Select(e =>
|
||||||
|
{
|
||||||
|
var wallet = e.Profile!.Account!;
|
||||||
|
var asset = e.Asset ?? "";
|
||||||
|
var side = e.Side ?? "";
|
||||||
|
// Compact format: {txHash}_{assetId} — wallet via TransientWallet
|
||||||
|
var tradeId = e.TxHash != null
|
||||||
|
? $"{e.TxHash}_{asset}"
|
||||||
|
: $"{e.CreatedAt}_{asset}_{side}";
|
||||||
|
|
||||||
|
return new Trade
|
||||||
|
{
|
||||||
|
Platform = PlatformType.Limitless,
|
||||||
|
PlatformTradeId = tradeId,
|
||||||
|
MarketId = platformMarketId,
|
||||||
|
AssetId = asset,
|
||||||
|
Outcome = "",
|
||||||
|
Side = ParseSide(side),
|
||||||
|
Price = (decimal)(e.Price ?? 0),
|
||||||
|
Size = (decimal)(e.Size ?? 0),
|
||||||
|
Amount = (decimal)(e.Price * e.Size ?? 0),
|
||||||
|
ExecutedAt = DateTime.TryParse(e.CreatedAt, out var dt) ? dt : DateTime.UtcNow,
|
||||||
|
TransactionHash = e.TxHash,
|
||||||
|
TransientWallet = wallet,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Market MapLimitlessMarket(LimitlessMarketResponse raw)
|
||||||
|
{
|
||||||
|
var market = new Market
|
||||||
|
{
|
||||||
|
Platform = PlatformType.Limitless,
|
||||||
|
PlatformMarketId = raw.Address ?? raw.Slug ?? "",
|
||||||
|
MarketSlug = raw.Slug ?? "",
|
||||||
|
EventSlug = "", // Limitless doesn't seem to have a clear Event/Market split in this model
|
||||||
|
Question = raw.Title ?? "",
|
||||||
|
Description = raw.Description ?? "",
|
||||||
|
Category = raw.Categories?.FirstOrDefault() ?? "",
|
||||||
|
ImageUrl = raw.ImageUrl ?? "",
|
||||||
|
Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""), out var vol) ? vol : 0,
|
||||||
|
Liquidity = (decimal)(raw.Liquidity ?? 0),
|
||||||
|
EndDate = (raw.ExpirationTimestamp.HasValue && raw.ExpirationTimestamp.Value > 0 && raw.ExpirationTimestamp.Value < 253402300799)
|
||||||
|
? DateTimeOffset.FromUnixTimeSeconds(raw.ExpirationTimestamp.Value).UtcDateTime
|
||||||
|
: (DateTime.TryParse(raw.ExpirationDate, out var ed) ? ed : null),
|
||||||
|
IsResolved = raw.Closed,
|
||||||
|
LastUpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map Outcomes from tokens object and prices array
|
||||||
|
if (raw.Tokens != null)
|
||||||
|
{
|
||||||
|
// Yes Outcome
|
||||||
|
market.Outcomes.Add(new MarketOutcome
|
||||||
|
{
|
||||||
|
Label = "Yes",
|
||||||
|
OutcomeIndex = 0,
|
||||||
|
TokenId = raw.Tokens.Yes ?? "",
|
||||||
|
CurrentPrice = (decimal)(raw.Prices != null && raw.Prices.Count > 0 ? raw.Prices[0] : 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
// No Outcome
|
||||||
|
market.Outcomes.Add(new MarketOutcome
|
||||||
|
{
|
||||||
|
Label = "No",
|
||||||
|
OutcomeIndex = 1,
|
||||||
|
TokenId = raw.Tokens.No ?? "",
|
||||||
|
CurrentPrice = (decimal)(raw.Prices != null && raw.Prices.Count > 1 ? raw.Prices[1] : 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return market;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TradeSide ParseSide(object? sideObj)
|
||||||
|
{
|
||||||
|
var sideStr = sideObj?.ToString()?.ToUpper();
|
||||||
|
if (sideStr == "0" || sideStr == "BUY") return TradeSide.Buy;
|
||||||
|
return TradeSide.Sell;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Providers.Polymarket;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP client for Polymarket Data API.
|
||||||
|
/// All endpoints use https://data-api.polymarket.com
|
||||||
|
/// </summary>
|
||||||
|
public class PolymarketApiClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly HttpClient _gammaClient;
|
||||||
|
private readonly IRateLimiter _rateLimiter;
|
||||||
|
private readonly ILogger<PolymarketApiClient> _logger;
|
||||||
|
|
||||||
|
private const string DataApiBase = "https://data-api.polymarket.com";
|
||||||
|
private const string GammaApiBase = "https://gamma-api.polymarket.com";
|
||||||
|
|
||||||
|
public PolymarketApiClient(IHttpClientFactory httpFactory, IRateLimiter rateLimiter, ILogger<PolymarketApiClient> logger)
|
||||||
|
{
|
||||||
|
_client = httpFactory.CreateClient("PolymarketData");
|
||||||
|
_client.BaseAddress = new Uri(DataApiBase);
|
||||||
|
_client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||||
|
|
||||||
|
_gammaClient = httpFactory.CreateClient("PolymarketGamma");
|
||||||
|
_gammaClient.BaseAddress = new Uri(GammaApiBase);
|
||||||
|
_gammaClient.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||||
|
|
||||||
|
_rateLimiter = rateLimiter;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PolymarketTradeResponse>> GetTradesAsync(string walletAddress, int limit = 1000, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var url = $"/activity?user={walletAddress}&limit={limit}";
|
||||||
|
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, ct) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PolymarketTradeResponse>> GetMarketTradesAsync(string conditionId, int limit = 1000, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var url = $"/trades?condition_id={conditionId}&limit={limit}";
|
||||||
|
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, ct) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PolymarketPositionResponse>> GetPositionsAsync(string walletAddress, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var url = $"/positions?user={walletAddress}&sizeThreshold=0.1&sortBy=CURRENT&sortOrder=DESC";
|
||||||
|
return await ExecuteWithRetryAsync<List<PolymarketPositionResponse>>(_client, url, ct) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GammaMarketResponse?> GetMarketAsync(string conditionId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var url = $"/markets?condition_id={conditionId}";
|
||||||
|
var results = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, ct);
|
||||||
|
return results?.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetch a batch of markets from the Gamma API with pagination.
|
||||||
|
/// Supports offset-based pagination via the offset parameter.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<GammaMarketResponse>> GetMarketsAsync(int limit = 1000, int offset = 0, bool includeClosed = false, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var activeOnly = !includeClosed;
|
||||||
|
var url = $"/markets?limit={limit}&offset={offset}&active={activeOnly.ToString().ToLower()}&closed={includeClosed.ToString().ToLower()}";
|
||||||
|
_logger.LogDebug("Fetching markets: {Url}", url);
|
||||||
|
var result = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, ct);
|
||||||
|
_logger.LogInformation("Fetched {Count} markets (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed);
|
||||||
|
return result ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetch top holders for a specific market (conditionId) from the Data API.
|
||||||
|
/// Returns holders grouped by token (outcome).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<HoldersResponse>> GetHoldersAsync(string conditionId, int limit = 20, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var url = $"/holders?market={conditionId}&limit={limit}";
|
||||||
|
_logger.LogDebug("Fetching holders: {Url}", url);
|
||||||
|
var result = await ExecuteWithRetryAsync<List<HoldersResponse>>(_client, url, ct);
|
||||||
|
_logger.LogInformation("Fetched holders for {Market}: {Count} token groups",
|
||||||
|
conditionId.Length > 12 ? conditionId[..12] + "..." : conditionId, result?.Count ?? 0);
|
||||||
|
return result ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get leaderboard from the official Polymarket Data API v1.
|
||||||
|
/// Endpoint: GET https://data-api.polymarket.com/v1/leaderboard
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<LeaderboardEntry>> GetLeaderboardAsync(
|
||||||
|
int limit = 50,
|
||||||
|
string timePeriod = "ALL",
|
||||||
|
string orderBy = "PNL",
|
||||||
|
string category = "OVERALL",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var url = $"/v1/leaderboard?limit={Math.Min(limit, 50)}&time_period={timePeriod}&order_by={orderBy}&category={category}";
|
||||||
|
_logger.LogDebug("Fetching leaderboard: {Url}", url);
|
||||||
|
var result = await ExecuteWithRetryAsync<List<LeaderboardEntry>>(_client, url, ct);
|
||||||
|
_logger.LogInformation("Leaderboard returned {Count} entries", result?.Count ?? 0);
|
||||||
|
return result ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, CancellationToken ct, int attempt = 1)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync(url, ct);
|
||||||
|
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
TimeSpan? retryAfter = null;
|
||||||
|
if (response.Headers.RetryAfter != null)
|
||||||
|
{
|
||||||
|
retryAfter = response.Headers.RetryAfter.Delta ??
|
||||||
|
(response.Headers.RetryAfter.Date.HasValue
|
||||||
|
? response.Headers.RetryAfter.Date.Value - DateTimeOffset.UtcNow
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var waitTime = retryAfter ?? TimeSpan.FromSeconds(30);
|
||||||
|
if (waitTime.TotalSeconds < 5)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Got 429 but Retry-After was {RawWait}s. Enforcing 30s minimum.", waitTime.TotalSeconds);
|
||||||
|
waitTime = TimeSpan.FromSeconds(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket. Pausing for {WaitTime}s...", (int)waitTime.TotalSeconds);
|
||||||
|
|
||||||
|
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime);
|
||||||
|
|
||||||
|
if (attempt < 3)
|
||||||
|
{
|
||||||
|
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct);
|
||||||
|
_logger.LogWarning("Retrying {Url} (attempt {NextAttempt})...", url, attempt + 1);
|
||||||
|
return await ExecuteWithRetryAsync<T>(client, url, ct, attempt + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)response.StatusCode == 422)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("End of data reached (422) for {Url}. Stopping pagination.", url);
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (ex is HttpRequestException hex && hex.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
_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);
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Providers.Polymarket;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converter that handles JSON values that may be either a number or a string.
|
||||||
|
/// Polymarket API is inconsistent — some fields are numbers in one endpoint and strings in another.
|
||||||
|
/// </summary>
|
||||||
|
public class FlexibleDoubleConverter : JsonConverter<double>
|
||||||
|
{
|
||||||
|
public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
return reader.TokenType switch
|
||||||
|
{
|
||||||
|
JsonTokenType.Number => reader.GetDouble(),
|
||||||
|
JsonTokenType.String => double.TryParse(reader.GetString(), System.Globalization.NumberStyles.Any,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0,
|
||||||
|
JsonTokenType.Null => 0,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
|
||||||
|
=> writer.WriteNumberValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FlexibleLongConverter : JsonConverter<long>
|
||||||
|
{
|
||||||
|
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
return reader.TokenType switch
|
||||||
|
{
|
||||||
|
JsonTokenType.Number => reader.GetInt64(),
|
||||||
|
JsonTokenType.String => long.TryParse(reader.GetString(), out var v) ? v : 0,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
|
||||||
|
=> writer.WriteNumberValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Polymarket Data API response models
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
public class PolymarketTradeResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("conditionId")] public string ConditionId { get; set; } = "";
|
||||||
|
[JsonPropertyName("asset")] public string Asset { get; set; } = "";
|
||||||
|
[JsonPropertyName("side")] public string Side { get; set; } = "";
|
||||||
|
[JsonPropertyName("action")] public string Action { get; set; } = "";
|
||||||
|
[JsonPropertyName("type")] public string Type { get; set; } = "";
|
||||||
|
[JsonPropertyName("user")] public string? User { get; set; }
|
||||||
|
[JsonPropertyName("proxyWallet")] public string? ProxyWallet { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double Size { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("price")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double Price { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("outcome")] public string Outcome { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("timestamp")]
|
||||||
|
[JsonConverter(typeof(FlexibleLongConverter))]
|
||||||
|
public long Timestamp { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("transactionHash")] public string? TransactionHash { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PolymarketPositionResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("asset_id")] public string AssetId { get; set; } = "";
|
||||||
|
[JsonPropertyName("market")] public string Market { get; set; } = "";
|
||||||
|
[JsonPropertyName("outcome")] public string Outcome { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double Size { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("avgPrice")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double AvgPrice { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("currentValue")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double CurrentValue { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("cashPnl")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double CashPnl { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("percentPnl")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double PercentPnl { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("question")] public string Question { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Gamma API — Market metadata (full market response)
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
public class GammaMarketResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||||
|
[JsonPropertyName("conditionId")] public string ConditionId { get; set; } = "";
|
||||||
|
[JsonPropertyName("question")] public string Question { get; set; } = "";
|
||||||
|
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
|
||||||
|
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||||
|
[JsonPropertyName("image")] public string? Image { get; set; }
|
||||||
|
[JsonPropertyName("category")] public string Category { get; set; } = "";
|
||||||
|
[JsonPropertyName("groupItemTitle")] public string? GroupItemTitle { get; set; }
|
||||||
|
[JsonPropertyName("events")] public List<GammaEventResponse>? Events { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("volumeNum")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double Volume { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("liquidityNum")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double Liquidity { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("endDateIso")] public string? EndDate { get; set; }
|
||||||
|
[JsonPropertyName("startDate")] public string? StartDate { get; set; }
|
||||||
|
[JsonPropertyName("createdAt")] public string? CreatedAt { get; set; }
|
||||||
|
[JsonPropertyName("closed")] public bool Closed { get; set; }
|
||||||
|
[JsonPropertyName("active")] public bool Active { get; set; }
|
||||||
|
[JsonPropertyName("resolved")] public bool Resolved { get; set; }
|
||||||
|
[JsonPropertyName("resolution_outcome")] public string? ResolutionOutcome { get; set; }
|
||||||
|
|
||||||
|
/// <summary>JSON string of outcomes, e.g. "[\"Yes\", \"No\"]"</summary>
|
||||||
|
[JsonPropertyName("outcomes")] public string? Outcomes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>JSON string of outcome prices, e.g. "[\"0.55\", \"0.45\"]"</summary>
|
||||||
|
[JsonPropertyName("outcomePrices")] public string? OutcomePrices { get; set; }
|
||||||
|
|
||||||
|
/// <summary>JSON string of CLOB token IDs, e.g. "[\"12345...\", \"67890...\"]"</summary>
|
||||||
|
[JsonPropertyName("clobTokenIds")] public string? ClobTokenIds { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GammaEventResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||||
|
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
|
||||||
|
[JsonPropertyName("title")] public string Title { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Data API — Holders response
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
public class HoldersResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("token")] public string Token { get; set; } = "";
|
||||||
|
[JsonPropertyName("holders")] public List<HolderEntry> Holders { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HolderEntry
|
||||||
|
{
|
||||||
|
[JsonPropertyName("proxyWallet")] public string ProxyWallet { get; set; } = "";
|
||||||
|
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||||
|
[JsonPropertyName("pseudonym")] public string Pseudonym { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("amount")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double Amount { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("outcomeIndex")] public int OutcomeIndex { get; set; }
|
||||||
|
[JsonPropertyName("profileImage")] public string? ProfileImage { get; set; }
|
||||||
|
[JsonPropertyName("verified")] public bool Verified { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Polymarket Data API v1 Leaderboard response
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
public class LeaderboardEntry
|
||||||
|
{
|
||||||
|
[JsonPropertyName("rank")] public string Rank { get; set; } = "";
|
||||||
|
[JsonPropertyName("proxyWallet")] public string ProxyWallet { get; set; } = "";
|
||||||
|
[JsonPropertyName("userName")] public string UserName { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("vol")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double Vol { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("pnl")]
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double Pnl { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("profileImage")] public string? ProfileImage { get; set; }
|
||||||
|
[JsonPropertyName("xUsername")] public string? XUsername { get; set; }
|
||||||
|
[JsonPropertyName("verifiedBadge")] public bool VerifiedBadge { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Predictalytics.Infrastructure.Logging;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Providers.Polymarket;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full implementation of IPlatformProvider for Polymarket.
|
||||||
|
/// All log entries include Platform=Polymarket for log-file routing.
|
||||||
|
/// </summary>
|
||||||
|
public class PolymarketProvider : IPlatformProvider
|
||||||
|
{
|
||||||
|
private readonly PolymarketApiClient _api;
|
||||||
|
private readonly ILogger<PolymarketProvider> _logger;
|
||||||
|
|
||||||
|
public PlatformType Platform => PlatformType.Polymarket;
|
||||||
|
public string PlatformName => "Polymarket";
|
||||||
|
public bool IsImplemented => true;
|
||||||
|
|
||||||
|
public PolymarketProvider(PolymarketApiClient api, ILogger<PolymarketProvider> logger)
|
||||||
|
{ _api = api; _logger = logger; }
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 1000, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var _ = PlatformLogContext.Push(PlatformName);
|
||||||
|
_logger.LogDebug("Fetching trades for {Wallet} (limit={Limit})", platformUserId, limit);
|
||||||
|
var raw = await _api.GetTradesAsync(platformUserId, limit, ct);
|
||||||
|
_logger.LogInformation("Fetched {Count} trades for {Wallet}", raw.Count, platformUserId);
|
||||||
|
|
||||||
|
var mappedTrades = raw.Select(r =>
|
||||||
|
{
|
||||||
|
var wallet = r.User ?? r.ProxyWallet ?? "";
|
||||||
|
var side = MapTradeSide(r);
|
||||||
|
var sideStr = side.ToString().ToUpperInvariant();
|
||||||
|
// Compact format: {txHash}_{assetId}_{side} — no wallet in ID to reduce index size.
|
||||||
|
// Wallet passed transiently via TransientWallet [NotMapped] for MarketHistoryWorker.
|
||||||
|
return new Trade
|
||||||
|
{
|
||||||
|
Platform = PlatformType.Polymarket,
|
||||||
|
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
|
||||||
|
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
|
||||||
|
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
|
||||||
|
MarketId = r.ConditionId ?? "",
|
||||||
|
AssetId = r.Asset ?? "",
|
||||||
|
Outcome = r.Outcome ?? "",
|
||||||
|
Side = side,
|
||||||
|
Price = (decimal)r.Price,
|
||||||
|
Size = (decimal)r.Size,
|
||||||
|
Amount = (decimal)(r.Price * r.Size),
|
||||||
|
ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
|
||||||
|
TransactionHash = r.TransactionHash,
|
||||||
|
TraderId = 0,
|
||||||
|
TransientWallet = wallet,
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 1000, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var raw = await _api.GetMarketTradesAsync(platformMarketId, limit, ct);
|
||||||
|
_logger.LogInformation("Fetched {Count} trades for Market {Market} (limit={Limit})", raw.Count, platformMarketId, limit);
|
||||||
|
|
||||||
|
var mappedTrades = raw.Select(r =>
|
||||||
|
{
|
||||||
|
var wallet = !string.IsNullOrEmpty(r.User) ? r.User : (r.ProxyWallet ?? "");
|
||||||
|
var side = MapTradeSide(r);
|
||||||
|
var sideStr = side.ToString().ToUpperInvariant();
|
||||||
|
return new Trade
|
||||||
|
{
|
||||||
|
Platform = PlatformType.Polymarket,
|
||||||
|
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
|
||||||
|
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
|
||||||
|
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
|
||||||
|
MarketId = r.ConditionId ?? "",
|
||||||
|
AssetId = r.Asset ?? "",
|
||||||
|
Outcome = r.Outcome ?? "",
|
||||||
|
Side = side,
|
||||||
|
Price = (decimal)r.Price,
|
||||||
|
Size = (decimal)r.Size,
|
||||||
|
Amount = (decimal)(r.Price * r.Size),
|
||||||
|
ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
|
||||||
|
TransactionHash = r.TransactionHash,
|
||||||
|
TraderId = 0,
|
||||||
|
TransientWallet = wallet,
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var _ = PlatformLogContext.Push(PlatformName);
|
||||||
|
_logger.LogDebug("Fetching positions for {Wallet}", platformUserId);
|
||||||
|
var raw = await _api.GetPositionsAsync(platformUserId, ct);
|
||||||
|
_logger.LogInformation("Fetched {Count} positions for {Wallet}", raw.Count, platformUserId);
|
||||||
|
|
||||||
|
return raw.Select(r => new TraderPositionInfo(
|
||||||
|
platformUserId, r.Market, r.Question, r.Outcome,
|
||||||
|
(decimal)r.Size, (decimal)r.AvgPrice,
|
||||||
|
(decimal)r.CurrentValue, (decimal)r.PercentPnl
|
||||||
|
)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 50, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var _ = PlatformLogContext.Push(PlatformName);
|
||||||
|
_logger.LogInformation("Running trader discovery via v1/leaderboard (limit={Limit})...", limit);
|
||||||
|
var leaderboard = await _api.GetLeaderboardAsync(limit, ct: ct);
|
||||||
|
_logger.LogInformation("Discovery returned {Count} traders from leaderboard", leaderboard.Count);
|
||||||
|
|
||||||
|
return leaderboard.Select(e => new DiscoveredTrader(
|
||||||
|
e.ProxyWallet,
|
||||||
|
string.IsNullOrEmpty(e.UserName) ? e.ProxyWallet[..10] + "..." : e.UserName,
|
||||||
|
(decimal)e.Vol,
|
||||||
|
0, // trade count not in leaderboard API
|
||||||
|
0 // win rate computed later from trades
|
||||||
|
)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var _ = PlatformLogContext.Push(PlatformName);
|
||||||
|
_logger.LogDebug("Fetching market {MarketId}", platformMarketId);
|
||||||
|
var raw = await _api.GetMarketAsync(platformMarketId, ct);
|
||||||
|
if (raw == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Market {MarketId} not found", platformMarketId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Fetched market: {Question}", raw.Question);
|
||||||
|
return MapGammaMarket(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var _ = PlatformLogContext.Push(PlatformName);
|
||||||
|
int offset = 0;
|
||||||
|
if (!string.IsNullOrEmpty(cursor) && int.TryParse(cursor, out var parsed))
|
||||||
|
offset = parsed;
|
||||||
|
|
||||||
|
_logger.LogInformation("Fetching markets batch (limit={Limit}, offset={Offset}, includeClosed={Closed})", limit, offset, includeClosed);
|
||||||
|
var raw = await _api.GetMarketsAsync(limit, offset, includeClosed, ct);
|
||||||
|
_logger.LogInformation("Fetched {Count} markets from Gamma API", raw.Count);
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.Where(m => !string.IsNullOrEmpty(m.ConditionId) && !string.IsNullOrEmpty(m.ClobTokenIds))
|
||||||
|
.Select(MapGammaMarket)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var _ = PlatformLogContext.Push(PlatformName);
|
||||||
|
_logger.LogInformation("Fetching top holders for market {MarketId}", platformMarketId[..12] + "...");
|
||||||
|
var holdersGroups = await _api.GetHoldersAsync(platformMarketId, limit, ct);
|
||||||
|
|
||||||
|
// Flatten all holders across token groups, deduplicate by wallet
|
||||||
|
var uniqueHolders = holdersGroups
|
||||||
|
.SelectMany(g => g.Holders)
|
||||||
|
.GroupBy(h => h.ProxyWallet)
|
||||||
|
.Select(g =>
|
||||||
|
{
|
||||||
|
var first = g.First();
|
||||||
|
var totalAmount = g.Sum(h => h.Amount);
|
||||||
|
var displayName = !string.IsNullOrEmpty(first.Name) ? first.Name
|
||||||
|
: !string.IsNullOrEmpty(first.Pseudonym) ? first.Pseudonym
|
||||||
|
: first.ProxyWallet[..10] + "...";
|
||||||
|
|
||||||
|
return new DiscoveredTrader(
|
||||||
|
first.ProxyWallet,
|
||||||
|
displayName,
|
||||||
|
(decimal)totalAmount,
|
||||||
|
0, 0
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.OrderByDescending(d => d.Volume24h)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_logger.LogInformation("Discovered {Count} unique holders from market {MarketId}",
|
||||||
|
uniqueHolders.Count, platformMarketId[..12] + "...");
|
||||||
|
|
||||||
|
return uniqueHolders;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private Market MapGammaMarket(GammaMarketResponse raw)
|
||||||
|
{
|
||||||
|
var eventSlug = "";
|
||||||
|
if (raw.Events != null && raw.Events.Count > 0 && !string.IsNullOrEmpty(raw.Events[0].Slug))
|
||||||
|
{
|
||||||
|
eventSlug = raw.Events[0].Slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
var market = new Market
|
||||||
|
{
|
||||||
|
Platform = PlatformType.Polymarket,
|
||||||
|
PlatformMarketId = raw.ConditionId,
|
||||||
|
MarketSlug = raw.Slug,
|
||||||
|
EventSlug = eventSlug,
|
||||||
|
Description = raw.Description,
|
||||||
|
ImageUrl = raw.Image,
|
||||||
|
Question = raw.Question,
|
||||||
|
Category = raw.Category,
|
||||||
|
Volume = (decimal)raw.Volume,
|
||||||
|
Liquidity = (decimal)raw.Liquidity,
|
||||||
|
StartDate = DateTime.TryParse(raw.StartDate, out var sd) ? sd : null,
|
||||||
|
EndDate = DateTime.TryParse(raw.EndDate, out var ed) ? ed : null,
|
||||||
|
CreatedAt = DateTime.TryParse(raw.CreatedAt, out var cd) ? cd : DateTime.UtcNow,
|
||||||
|
DbCreatedAt = DateTime.UtcNow,
|
||||||
|
IsResolved = raw.Resolved || raw.Closed, // Prefer resolved flag
|
||||||
|
ResolutionOutcome = raw.ResolutionOutcome,
|
||||||
|
LastUpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse outcomes, prices, and token IDs from JSON strings
|
||||||
|
var outcomeLabels = ParseJsonStringArray(raw.Outcomes);
|
||||||
|
var outcomePrices = ParseJsonStringArray(raw.OutcomePrices);
|
||||||
|
var tokenIds = ParseJsonStringArray(raw.ClobTokenIds);
|
||||||
|
|
||||||
|
for (int i = 0; i < outcomeLabels.Count; i++)
|
||||||
|
{
|
||||||
|
decimal price = 0;
|
||||||
|
if (i < outcomePrices.Count)
|
||||||
|
decimal.TryParse(outcomePrices[i], System.Globalization.NumberStyles.Any,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out price);
|
||||||
|
|
||||||
|
string tokenId = i < tokenIds.Count ? tokenIds[i] : "";
|
||||||
|
|
||||||
|
var label = outcomeLabels[i];
|
||||||
|
if ((label.Equals("Yes", StringComparison.OrdinalIgnoreCase) || label.Equals("No", StringComparison.OrdinalIgnoreCase))
|
||||||
|
&& !string.IsNullOrEmpty(raw.GroupItemTitle))
|
||||||
|
{
|
||||||
|
label = $"{raw.GroupItemTitle} - {label}";
|
||||||
|
}
|
||||||
|
|
||||||
|
market.Outcomes.Add(new MarketOutcome
|
||||||
|
{
|
||||||
|
Label = label,
|
||||||
|
OutcomeIndex = i,
|
||||||
|
TokenId = tokenId,
|
||||||
|
CurrentPrice = price
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return market;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ParseJsonStringArray(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(json)) return [];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TradeSide MapTradeSide(PolymarketTradeResponse r)
|
||||||
|
{
|
||||||
|
// Check Action/Type field first for special operations
|
||||||
|
var typeOrAction = !string.IsNullOrEmpty(r.Action) ? r.Action
|
||||||
|
: !string.IsNullOrEmpty(r.Type) ? r.Type : "";
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(typeOrAction))
|
||||||
|
{
|
||||||
|
if (typeOrAction.Equals("SPLIT", StringComparison.OrdinalIgnoreCase)) return TradeSide.Split;
|
||||||
|
if (typeOrAction.Equals("MERGE", StringComparison.OrdinalIgnoreCase)) return TradeSide.Merge;
|
||||||
|
if (typeOrAction.Equals("REDEEM", StringComparison.OrdinalIgnoreCase)) return TradeSide.Redeem;
|
||||||
|
if (typeOrAction.Equals("ADD_LIQUIDITY", StringComparison.OrdinalIgnoreCase)) return TradeSide.AddLiquidity;
|
||||||
|
if (typeOrAction.Equals("REMOVE_LIQUIDITY", StringComparison.OrdinalIgnoreCase)) return TradeSide.RemoveLiquidity;
|
||||||
|
// Type field can also contain BUY/SELL directly
|
||||||
|
if (typeOrAction.Equals("BUY", StringComparison.OrdinalIgnoreCase)) return TradeSide.Buy;
|
||||||
|
if (typeOrAction.Equals("SELL", StringComparison.OrdinalIgnoreCase)) return TradeSide.Sell;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Side field (explicit buy/sell direction)
|
||||||
|
if (!string.IsNullOrEmpty(r.Side))
|
||||||
|
{
|
||||||
|
if (r.Side.Equals("BUY", StringComparison.OrdinalIgnoreCase)) return TradeSide.Buy;
|
||||||
|
if (r.Side.Equals("SELL", StringComparison.OrdinalIgnoreCase)) return TradeSide.Sell;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TradeSide.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
|
public class AppSettings
|
||||||
|
{
|
||||||
|
private const string FileName = "settings.json";
|
||||||
|
|
||||||
|
[Category("Webserver")]
|
||||||
|
[DisplayName("Port")]
|
||||||
|
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
|
||||||
|
[DefaultValue(5000)]
|
||||||
|
public int WebserverPort { get; set; } = 5000;
|
||||||
|
|
||||||
|
[Category("Webserver")]
|
||||||
|
[DisplayName("Database Debug")]
|
||||||
|
[Description("Wenn aktiv, werden detaillierte Verbindungsinformationen im Terminal angezeigt.")]
|
||||||
|
[DefaultValue(false)]
|
||||||
|
public bool DbConnectionDebug { get; set; } = false;
|
||||||
|
|
||||||
|
private string _dbServer = "localhost";
|
||||||
|
private string _dbName = "";
|
||||||
|
private string _dbUser = "";
|
||||||
|
private string _dbPassword = "";
|
||||||
|
|
||||||
|
[Category("Database")]
|
||||||
|
[DisplayName("Server")]
|
||||||
|
public string DbServer { get => _dbServer; set => _dbServer = string.IsNullOrWhiteSpace(value) ? _dbServer : value.Trim(); }
|
||||||
|
|
||||||
|
[Category("Database")]
|
||||||
|
[DisplayName("Database")]
|
||||||
|
public string DbName { get => _dbName; set => _dbName = string.IsNullOrWhiteSpace(value) ? _dbName : value.Trim(); }
|
||||||
|
|
||||||
|
[Category("Database")]
|
||||||
|
[DisplayName("User")]
|
||||||
|
public string DbUser { get => _dbUser; set => _dbUser = string.IsNullOrWhiteSpace(value) ? _dbUser : value.Trim(); }
|
||||||
|
|
||||||
|
[Category("Database")]
|
||||||
|
[DisplayName("Password")]
|
||||||
|
[PasswordPropertyText(true)]
|
||||||
|
public string DbPassword { get => _dbPassword; set => _dbPassword = string.IsNullOrWhiteSpace(value) ? _dbPassword : value.Trim(); }
|
||||||
|
|
||||||
|
[Browsable(false)]
|
||||||
|
public string ConnectionString
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var builder = new MySqlConnector.MySqlConnectionStringBuilder
|
||||||
|
{
|
||||||
|
Server = DbServer?.Trim(),
|
||||||
|
Database = DbName?.Trim(),
|
||||||
|
UserID = DbUser?.Trim(),
|
||||||
|
Password = DbPassword?.Trim(),
|
||||||
|
AllowPublicKeyRetrieval = true,
|
||||||
|
SslMode = MySqlConnector.MySqlSslMode.None,
|
||||||
|
Pooling = true,
|
||||||
|
MinimumPoolSize = 0,
|
||||||
|
MaximumPoolSize = 100
|
||||||
|
};
|
||||||
|
return builder.ConnectionString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AppSettings Load()
|
||||||
|
{
|
||||||
|
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName);
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
var settings = new AppSettings();
|
||||||
|
settings.Save();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(filePath);
|
||||||
|
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new AppSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName);
|
||||||
|
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||||
|
var json = JsonSerializer.Serialize(this, options);
|
||||||
|
File.WriteAllText(filePath, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
|
partial class MainForm
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing && (components != null))
|
||||||
|
{
|
||||||
|
components.Dispose();
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Windows Form Designer generated code
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
toolStrip1 = new ToolStrip();
|
||||||
|
btn_serverstart = new ToolStripButton();
|
||||||
|
btn_localWebserver = new ToolStripButton();
|
||||||
|
statusStrip1 = new StatusStrip();
|
||||||
|
tabControl1 = new TabControl();
|
||||||
|
tabPage_terminal = new TabPage();
|
||||||
|
rtb_terminal = new RichTextBox();
|
||||||
|
tabPage2 = new TabPage();
|
||||||
|
pg_settings = new PropertyGrid();
|
||||||
|
menuStrip1 = new MenuStrip();
|
||||||
|
filesToolStripMenuItem = new ToolStripMenuItem();
|
||||||
|
editToolStripMenuItem = new ToolStripMenuItem();
|
||||||
|
btn_logfolder = new ToolStripMenuItem();
|
||||||
|
btn_openbrowser = new ToolStripMenuItem();
|
||||||
|
developmentToolStripMenuItem = new ToolStripMenuItem();
|
||||||
|
btn_dbReset = new ToolStripMenuItem();
|
||||||
|
btn_syncmarkets = new ToolStripMenuItem();
|
||||||
|
label_apiRatelimit = new ToolStripStatusLabel();
|
||||||
|
label_buildVersion = new ToolStripStatusLabel();
|
||||||
|
toolStrip1.SuspendLayout();
|
||||||
|
statusStrip1.SuspendLayout();
|
||||||
|
tabControl1.SuspendLayout();
|
||||||
|
tabPage_terminal.SuspendLayout();
|
||||||
|
tabPage2.SuspendLayout();
|
||||||
|
menuStrip1.SuspendLayout();
|
||||||
|
SuspendLayout();
|
||||||
|
//
|
||||||
|
// toolStrip1
|
||||||
|
//
|
||||||
|
toolStrip1.ImageScalingSize = new Size(24, 24);
|
||||||
|
toolStrip1.Items.AddRange(new ToolStripItem[] { btn_serverstart, btn_localWebserver });
|
||||||
|
toolStrip1.Location = new Point(0, 33);
|
||||||
|
toolStrip1.Name = "toolStrip1";
|
||||||
|
toolStrip1.Size = new Size(1864, 34);
|
||||||
|
toolStrip1.TabIndex = 0;
|
||||||
|
//
|
||||||
|
// btn_serverstart
|
||||||
|
//
|
||||||
|
btn_serverstart.ImageTransparentColor = Color.Magenta;
|
||||||
|
btn_serverstart.Name = "btn_serverstart";
|
||||||
|
btn_serverstart.Size = new Size(127, 29);
|
||||||
|
btn_serverstart.Text = "▶ Start Server";
|
||||||
|
//
|
||||||
|
// btn_localWebserver
|
||||||
|
//
|
||||||
|
btn_localWebserver.ImageTransparentColor = Color.Magenta;
|
||||||
|
btn_localWebserver.Name = "btn_localWebserver";
|
||||||
|
btn_localWebserver.Size = new Size(161, 29);
|
||||||
|
btn_localWebserver.Text = "▶ Start Webserver";
|
||||||
|
//
|
||||||
|
// statusStrip1
|
||||||
|
//
|
||||||
|
statusStrip1.ImageScalingSize = new Size(24, 24);
|
||||||
|
statusStrip1.Items.AddRange(new ToolStripItem[] { label_apiRatelimit, label_buildVersion });
|
||||||
|
statusStrip1.Location = new Point(0, 1000);
|
||||||
|
statusStrip1.Name = "statusStrip1";
|
||||||
|
statusStrip1.Size = new Size(1864, 32);
|
||||||
|
statusStrip1.TabIndex = 1;
|
||||||
|
//
|
||||||
|
// tabControl1
|
||||||
|
//
|
||||||
|
tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||||||
|
tabControl1.Controls.Add(tabPage_terminal);
|
||||||
|
tabControl1.Controls.Add(tabPage2);
|
||||||
|
tabControl1.Location = new Point(0, 61);
|
||||||
|
tabControl1.Name = "tabControl1";
|
||||||
|
tabControl1.SelectedIndex = 0;
|
||||||
|
tabControl1.Size = new Size(1864, 946);
|
||||||
|
tabControl1.TabIndex = 2;
|
||||||
|
//
|
||||||
|
// tabPage_terminal
|
||||||
|
//
|
||||||
|
tabPage_terminal.Controls.Add(rtb_terminal);
|
||||||
|
tabPage_terminal.Location = new Point(4, 34);
|
||||||
|
tabPage_terminal.Name = "tabPage_terminal";
|
||||||
|
tabPage_terminal.Padding = new Padding(3);
|
||||||
|
tabPage_terminal.Size = new Size(1856, 908);
|
||||||
|
tabPage_terminal.TabIndex = 0;
|
||||||
|
tabPage_terminal.Text = "Terminal";
|
||||||
|
tabPage_terminal.UseVisualStyleBackColor = true;
|
||||||
|
//
|
||||||
|
// rtb_terminal
|
||||||
|
//
|
||||||
|
rtb_terminal.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||||||
|
rtb_terminal.Location = new Point(3, 6);
|
||||||
|
rtb_terminal.Name = "rtb_terminal";
|
||||||
|
rtb_terminal.Size = new Size(1847, 896);
|
||||||
|
rtb_terminal.TabIndex = 0;
|
||||||
|
rtb_terminal.Text = "";
|
||||||
|
//
|
||||||
|
// tabPage2
|
||||||
|
//
|
||||||
|
tabPage2.Controls.Add(pg_settings);
|
||||||
|
tabPage2.Location = new Point(4, 34);
|
||||||
|
tabPage2.Name = "tabPage2";
|
||||||
|
tabPage2.Padding = new Padding(3);
|
||||||
|
tabPage2.Size = new Size(1856, 908);
|
||||||
|
tabPage2.TabIndex = 1;
|
||||||
|
tabPage2.Text = "Settings";
|
||||||
|
tabPage2.UseVisualStyleBackColor = true;
|
||||||
|
//
|
||||||
|
// pg_settings
|
||||||
|
//
|
||||||
|
pg_settings.Location = new Point(3, 6);
|
||||||
|
pg_settings.Name = "pg_settings";
|
||||||
|
pg_settings.Size = new Size(1850, 896);
|
||||||
|
pg_settings.TabIndex = 0;
|
||||||
|
//
|
||||||
|
// menuStrip1
|
||||||
|
//
|
||||||
|
menuStrip1.ImageScalingSize = new Size(24, 24);
|
||||||
|
menuStrip1.Items.AddRange(new ToolStripItem[] { filesToolStripMenuItem, editToolStripMenuItem, developmentToolStripMenuItem });
|
||||||
|
menuStrip1.Location = new Point(0, 0);
|
||||||
|
menuStrip1.Name = "menuStrip1";
|
||||||
|
menuStrip1.Size = new Size(1864, 33);
|
||||||
|
menuStrip1.TabIndex = 3;
|
||||||
|
//
|
||||||
|
// filesToolStripMenuItem
|
||||||
|
//
|
||||||
|
filesToolStripMenuItem.Name = "filesToolStripMenuItem";
|
||||||
|
filesToolStripMenuItem.Size = new Size(62, 29);
|
||||||
|
filesToolStripMenuItem.Text = "Files";
|
||||||
|
//
|
||||||
|
// editToolStripMenuItem
|
||||||
|
//
|
||||||
|
editToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_logfolder, btn_openbrowser });
|
||||||
|
editToolStripMenuItem.Name = "editToolStripMenuItem";
|
||||||
|
editToolStripMenuItem.Size = new Size(58, 29);
|
||||||
|
editToolStripMenuItem.Text = "Edit";
|
||||||
|
//
|
||||||
|
// btn_logfolder
|
||||||
|
//
|
||||||
|
btn_logfolder.Name = "btn_logfolder";
|
||||||
|
btn_logfolder.Text = "Show Logfolder";
|
||||||
|
btn_logfolder.Click += btn_logfolder_Click;
|
||||||
|
//
|
||||||
|
// btn_openbrowser
|
||||||
|
//
|
||||||
|
btn_openbrowser.Name = "btn_openbrowser";
|
||||||
|
btn_openbrowser.Text = "Show Local WebUI";
|
||||||
|
btn_openbrowser.Click += btn_openbrowser_Click;
|
||||||
|
//
|
||||||
|
// developmentToolStripMenuItem
|
||||||
|
//
|
||||||
|
developmentToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_dbReset, btn_syncmarkets });
|
||||||
|
developmentToolStripMenuItem.Name = "developmentToolStripMenuItem";
|
||||||
|
developmentToolStripMenuItem.Size = new Size(135, 29);
|
||||||
|
developmentToolStripMenuItem.Text = "Development";
|
||||||
|
//
|
||||||
|
// btn_dbReset
|
||||||
|
//
|
||||||
|
btn_dbReset.Name = "btn_dbReset";
|
||||||
|
btn_dbReset.Text = "reset TradesDB";
|
||||||
|
//
|
||||||
|
// btn_syncmarkets
|
||||||
|
//
|
||||||
|
btn_syncmarkets.Name = "btn_syncmarkets";
|
||||||
|
btn_syncmarkets.Text = "Sync Marketsa";
|
||||||
|
btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
|
||||||
|
//
|
||||||
|
// label_apiRatelimit
|
||||||
|
//
|
||||||
|
label_apiRatelimit.Name = "label_apiRatelimit";
|
||||||
|
label_apiRatelimit.Size = new Size(1670, 25);
|
||||||
|
label_apiRatelimit.Spring = true;
|
||||||
|
label_apiRatelimit.Text = "API: OK";
|
||||||
|
label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft;
|
||||||
|
//
|
||||||
|
// label_buildVersion
|
||||||
|
//
|
||||||
|
label_buildVersion.Name = "label_buildVersion";
|
||||||
|
label_buildVersion.Size = new Size(179, 25);
|
||||||
|
label_buildVersion.Text = "Build: -";
|
||||||
|
label_buildVersion.TextAlign = ContentAlignment.MiddleRight;
|
||||||
|
//
|
||||||
|
// MainForm
|
||||||
|
//
|
||||||
|
AutoScaleDimensions = new SizeF(10F, 25F);
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(1864, 1032);
|
||||||
|
Controls.Add(tabControl1);
|
||||||
|
Controls.Add(statusStrip1);
|
||||||
|
Controls.Add(toolStrip1);
|
||||||
|
Controls.Add(menuStrip1);
|
||||||
|
MainMenuStrip = menuStrip1;
|
||||||
|
Name = "MainForm";
|
||||||
|
Text = "Predictalytics";
|
||||||
|
toolStrip1.ResumeLayout(false);
|
||||||
|
toolStrip1.PerformLayout();
|
||||||
|
statusStrip1.ResumeLayout(false);
|
||||||
|
statusStrip1.PerformLayout();
|
||||||
|
tabControl1.ResumeLayout(false);
|
||||||
|
tabPage_terminal.ResumeLayout(false);
|
||||||
|
tabPage2.ResumeLayout(false);
|
||||||
|
menuStrip1.ResumeLayout(false);
|
||||||
|
menuStrip1.PerformLayout();
|
||||||
|
ResumeLayout(false);
|
||||||
|
PerformLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
private ToolStrip toolStrip1;
|
||||||
|
private ToolStripButton btn_serverstart;
|
||||||
|
private ToolStripButton btn_localWebserver;
|
||||||
|
private StatusStrip statusStrip1;
|
||||||
|
private TabControl tabControl1;
|
||||||
|
private TabPage tabPage_terminal;
|
||||||
|
private RichTextBox rtb_terminal;
|
||||||
|
private TabPage tabPage2;
|
||||||
|
private MenuStrip menuStrip1;
|
||||||
|
private ToolStripMenuItem filesToolStripMenuItem;
|
||||||
|
private ToolStripMenuItem editToolStripMenuItem;
|
||||||
|
private ToolStripMenuItem btn_logfolder;
|
||||||
|
private ToolStripMenuItem btn_openbrowser;
|
||||||
|
private ToolStripMenuItem developmentToolStripMenuItem;
|
||||||
|
private ToolStripMenuItem btn_dbReset;
|
||||||
|
private ToolStripMenuItem btn_syncmarkets;
|
||||||
|
private PropertyGrid pg_settings;
|
||||||
|
private ToolStripStatusLabel label_apiRatelimit;
|
||||||
|
private ToolStripStatusLabel label_buildVersion;
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Predictalytics.WinFormsHost.Services;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
|
public partial class MainForm : Form
|
||||||
|
{
|
||||||
|
private EmbeddedWebServer? _webServer;
|
||||||
|
private CancellationTokenSource? _workerCts;
|
||||||
|
private bool _workerRunning;
|
||||||
|
private bool _webServerRunning;
|
||||||
|
private AppSettings _settings = null!;
|
||||||
|
|
||||||
|
/// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary>
|
||||||
|
public RichTextBox Terminal => rtb_terminal;
|
||||||
|
|
||||||
|
public MainForm()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
this.Text = "Predictalytics Analytics — Backend Server";
|
||||||
|
rtb_terminal.BackColor = System.Drawing.Color.FromArgb(15, 15, 20);
|
||||||
|
rtb_terminal.ForeColor = System.Drawing.Color.FromArgb(180, 180, 180);
|
||||||
|
rtb_terminal.Font = new Font("Cascadia Code", 9.5f, FontStyle.Regular);
|
||||||
|
rtb_terminal.ReadOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called after Serilog is configured. Initializes the embedded web server.
|
||||||
|
/// </summary>
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
_settings = AppSettings.Load();
|
||||||
|
pg_settings.SelectedObject = _settings;
|
||||||
|
pg_settings.PropertyValueChanged += (s, e) => {
|
||||||
|
_settings.Save();
|
||||||
|
if (_webServer != null)
|
||||||
|
{
|
||||||
|
_webServer.ConnectionString = _settings.ConnectionString;
|
||||||
|
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_webServer = new EmbeddedWebServer();
|
||||||
|
_webServer.ConnectionString = _settings.ConnectionString;
|
||||||
|
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
|
||||||
|
|
||||||
|
// Build Version (Date of compilation/file creation)
|
||||||
|
try {
|
||||||
|
var buildDate = new FileInfo(this.GetType().Assembly.Location).LastWriteTime;
|
||||||
|
label_buildVersion.Text = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
|
||||||
|
} catch {
|
||||||
|
label_buildVersion.Text = "Build: Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateStatusBar();
|
||||||
|
|
||||||
|
// Wire up button events
|
||||||
|
btn_serverstart.Click += Btn_serverstart_Click;
|
||||||
|
btn_localWebserver.Click += Btn_localWebserver_Click;
|
||||||
|
btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
|
||||||
|
|
||||||
|
Log.Information("MainForm initialized. Ready.");
|
||||||
|
Log.Information("Press 'Start Server' to begin polling & discovery.");
|
||||||
|
Log.Information("Press 'Start Local Webserver' to launch the WebUI on http://localhost:{Port}", _settings.WebserverPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Btn_serverstart_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (!_workerRunning)
|
||||||
|
{
|
||||||
|
// Start workers
|
||||||
|
_workerCts = new CancellationTokenSource();
|
||||||
|
_workerRunning = true;
|
||||||
|
btn_serverstart.Text = "⏹ Stop Server";
|
||||||
|
Log.Information("🚀 Starting background workers...");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_webServer!.ConnectionString = _settings.ConnectionString;
|
||||||
|
_webServer!.DbConnectionDebug = _settings.DbConnectionDebug;
|
||||||
|
await _webServer!.StartWorkersAsync(_workerCts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (Exception ex) { Log.Error(ex, "Worker error"); }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Stop workers
|
||||||
|
Log.Information("⏹ Stopping background workers...");
|
||||||
|
_workerCts?.Cancel();
|
||||||
|
_workerRunning = false;
|
||||||
|
btn_serverstart.Text = "▶ Start Server";
|
||||||
|
Log.Information("Workers stopped.");
|
||||||
|
}
|
||||||
|
UpdateStatusBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Btn_localWebserver_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (!_webServerRunning)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Log.Information("🌐 Starting embedded Kestrel webserver on http://localhost:{Port}...", _settings.WebserverPort);
|
||||||
|
await _webServer!.StartWebServerAsync(_settings.WebserverPort);
|
||||||
|
_webServerRunning = true;
|
||||||
|
btn_localWebserver.Text = "⏹ Stop Webserver";
|
||||||
|
Log.Information("✅ WebUI available at http://localhost:{Port}", _settings.WebserverPort);
|
||||||
|
Log.Information("📄 Swagger API docs at http://localhost:{Port}/swagger", _settings.WebserverPort);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to start webserver");
|
||||||
|
_webServerRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Information("⏹ Stopping webserver...");
|
||||||
|
await _webServer!.StopWebServerAsync();
|
||||||
|
_webServerRunning = false;
|
||||||
|
btn_localWebserver.Text = "▶ Start Webserver";
|
||||||
|
Log.Information("Webserver stopped.");
|
||||||
|
}
|
||||||
|
UpdateStatusBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatusBar()
|
||||||
|
{
|
||||||
|
var workerStatus = _workerRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
|
||||||
|
var serverStatus = _webServerRunning ? $"[RUNNING] Webserver :{_settings.WebserverPort}" : "[STOPPED] Webserver";
|
||||||
|
this.Text = $"Predictalytics Analytics — {workerStatus} | {serverStatus}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||||
|
{
|
||||||
|
_workerCts?.Cancel();
|
||||||
|
_webServer?.StopWebServerAsync().GetAwaiter().GetResult();
|
||||||
|
base.OnFormClosing(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void btn_openbrowser_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start("explorer.exe", $"\"http://localhost:{_settings.WebserverPort}\"");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "Fehler beim Öffnen des Browsers");
|
||||||
|
MessageBox.Show("Browser konnte nicht gestartet werden.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void btn_logfolder_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs");
|
||||||
|
if (Directory.Exists(logPath))
|
||||||
|
System.Diagnostics.Process.Start("explorer.exe", logPath);
|
||||||
|
else
|
||||||
|
System.Diagnostics.Process.Start("explorer.exe", Environment.CurrentDirectory);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "Fehler beim Öffnen des Log-Ordners");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void syncMarketsaToolStripMenuItem_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_workerRunning)
|
||||||
|
{
|
||||||
|
MessageBox.Show("Market sync cannot be started while background workers are running.",
|
||||||
|
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
btn_syncmarkets.Enabled = false;
|
||||||
|
Log.Information("Manual market sync triggered...");
|
||||||
|
|
||||||
|
// Use a temporary CTS for this operation
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await _webServer!.RunSingleMarketSyncAsync(cts.Token);
|
||||||
|
|
||||||
|
Log.Information("Manual market sync completed successfully.");
|
||||||
|
MessageBox.Show("Market sync completed.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Manual market sync failed");
|
||||||
|
MessageBox.Show($"Market sync failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
btn_syncmarkets.Enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<!--
|
||||||
|
Microsoft ResX Schema
|
||||||
|
|
||||||
|
Version 2.0
|
||||||
|
|
||||||
|
The primary goals of this format is to allow a simple XML format
|
||||||
|
that is mostly human readable. The generation and parsing of the
|
||||||
|
various data types are done through the TypeConverter classes
|
||||||
|
associated with the data types.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
... ado.net/XML headers & schema ...
|
||||||
|
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||||
|
<resheader name="version">2.0</resheader>
|
||||||
|
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||||
|
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||||
|
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||||
|
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||||
|
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||||
|
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||||
|
</data>
|
||||||
|
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||||
|
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||||
|
<comment>This is a comment</comment>
|
||||||
|
</data>
|
||||||
|
|
||||||
|
There are any number of "resheader" rows that contain simple
|
||||||
|
name/value pairs.
|
||||||
|
|
||||||
|
Each data row contains a name, and value. The row also contains a
|
||||||
|
type or mimetype. Type corresponds to a .NET class that support
|
||||||
|
text/value conversion through the TypeConverter architecture.
|
||||||
|
Classes that don't support this are serialized and stored with the
|
||||||
|
mimetype set.
|
||||||
|
|
||||||
|
The mimetype is used for serialized objects, and tells the
|
||||||
|
ResXResourceReader how to depersist the object. This is currently not
|
||||||
|
extensible. For a given mimetype the value must be set accordingly:
|
||||||
|
|
||||||
|
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||||
|
that the ResXResourceWriter will generate, however the reader can
|
||||||
|
read any of the formats listed below.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.binary.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.soap.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||||
|
value : The object must be serialized into a byte array
|
||||||
|
: using a System.ComponentModel.TypeConverter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
-->
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||||
|
<value>17, 17</value>
|
||||||
|
</metadata>
|
||||||
|
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||||
|
<value>162, 17</value>
|
||||||
|
</metadata>
|
||||||
|
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||||
|
<value>322, 17</value>
|
||||||
|
</metadata>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<RootNamespace>Predictalytics.WinFormsHost</RootNamespace>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
|
<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
|
||||||
|
<ApplicationVisualStyles>true</ApplicationVisualStyles>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||||
|
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
|
||||||
|
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
|
||||||
|
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="CleanupLocalization" AfterTargets="Build">
|
||||||
|
<ItemGroup>
|
||||||
|
<LanguageFolders Include="$(TargetDir)cs;$(TargetDir)de;$(TargetDir)es;$(TargetDir)fr;$(TargetDir)it;$(TargetDir)ja;$(TargetDir)ko;$(TargetDir)pl;$(TargetDir)pt-BR;$(TargetDir)ru;$(TargetDir)tr;$(TargetDir)zh-Hans;$(TargetDir)zh-Hant" />
|
||||||
|
</ItemGroup>
|
||||||
|
<RemoveDir Directories="@(LanguageFolders)" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using Predictalytics.Infrastructure.Logging;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
static void Main()
|
||||||
|
{
|
||||||
|
ApplicationConfiguration.Initialize();
|
||||||
|
|
||||||
|
var mainForm = new MainForm();
|
||||||
|
var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm);
|
||||||
|
|
||||||
|
// ─── Output template matching terminal format ───
|
||||||
|
const string textTemplate =
|
||||||
|
"[{Timestamp:yyyy-MM-dd HH:mm:ss}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
|
||||||
|
|
||||||
|
const string simpleTemplate =
|
||||||
|
"[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}";
|
||||||
|
|
||||||
|
// ─── Log directory ───
|
||||||
|
var logBaseDir = Path.Combine(AppContext.BaseDirectory, "logs");
|
||||||
|
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Information()
|
||||||
|
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
|
||||||
|
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||||
|
.MinimumLevel.Override("Microsoft.Hosting", LogEventLevel.Warning)
|
||||||
|
.Enrich.FromLogContext()
|
||||||
|
|
||||||
|
// ── Console (simple) ──
|
||||||
|
.WriteTo.Console(outputTemplate: simpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning)
|
||||||
|
|
||||||
|
// ── RichTextBox Terminal ──
|
||||||
|
.WriteTo.Sink(new RichTextBoxSink(rtbWriteAction), restrictedToMinimumLevel: LogEventLevel.Warning)
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
// FILE SINKS — By Level
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ALL levels — complete log (daily rotation)
|
||||||
|
.WriteTo.File(
|
||||||
|
Path.Combine(logBaseDir, "all", "all-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
outputTemplate: textTemplate,
|
||||||
|
retainedFileCountLimit: 30,
|
||||||
|
fileSizeLimitBytes: 50_000_000,
|
||||||
|
shared: true)
|
||||||
|
|
||||||
|
// INFO only
|
||||||
|
.WriteTo.Logger(lc => lc
|
||||||
|
.Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Information)
|
||||||
|
.WriteTo.File(
|
||||||
|
Path.Combine(logBaseDir, "info", "info-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
outputTemplate: textTemplate,
|
||||||
|
retainedFileCountLimit: 14,
|
||||||
|
shared: true))
|
||||||
|
|
||||||
|
// WARNING only
|
||||||
|
.WriteTo.Logger(lc => lc
|
||||||
|
.Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Warning)
|
||||||
|
.WriteTo.File(
|
||||||
|
Path.Combine(logBaseDir, "warning", "warning-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
outputTemplate: textTemplate,
|
||||||
|
retainedFileCountLimit: 30,
|
||||||
|
shared: true))
|
||||||
|
|
||||||
|
// ERROR + FATAL
|
||||||
|
.WriteTo.Logger(lc => lc
|
||||||
|
.Filter.ByIncludingOnly(e => e.Level >= LogEventLevel.Error)
|
||||||
|
.WriteTo.File(
|
||||||
|
Path.Combine(logBaseDir, "error", "error-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
outputTemplate: textTemplate,
|
||||||
|
retainedFileCountLimit: 60,
|
||||||
|
shared: true))
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
// FILE SINKS — By Platform
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Polymarket
|
||||||
|
.WriteTo.Logger(lc => lc
|
||||||
|
.Filter.ByIncludingOnly(e =>
|
||||||
|
e.Properties.ContainsKey("Platform") &&
|
||||||
|
e.Properties["Platform"].ToString().Contains("Polymarket"))
|
||||||
|
.WriteTo.File(
|
||||||
|
Path.Combine(logBaseDir, "platforms", "polymarket-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
outputTemplate: textTemplate,
|
||||||
|
retainedFileCountLimit: 30,
|
||||||
|
shared: true))
|
||||||
|
|
||||||
|
// Limitless
|
||||||
|
.WriteTo.Logger(lc => lc
|
||||||
|
.Filter.ByIncludingOnly(e =>
|
||||||
|
e.Properties.ContainsKey("Platform") &&
|
||||||
|
e.Properties["Platform"].ToString().Contains("Limitless"))
|
||||||
|
.WriteTo.File(
|
||||||
|
Path.Combine(logBaseDir, "platforms", "limitless-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
outputTemplate: textTemplate,
|
||||||
|
retainedFileCountLimit: 30,
|
||||||
|
shared: true))
|
||||||
|
|
||||||
|
// Azuro
|
||||||
|
.WriteTo.Logger(lc => lc
|
||||||
|
.Filter.ByIncludingOnly(e =>
|
||||||
|
e.Properties.ContainsKey("Platform") &&
|
||||||
|
e.Properties["Platform"].ToString().Contains("Azuro"))
|
||||||
|
.WriteTo.File(
|
||||||
|
Path.Combine(logBaseDir, "platforms", "azuro-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
outputTemplate: textTemplate,
|
||||||
|
retainedFileCountLimit: 30,
|
||||||
|
shared: true))
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
// FILE SINK — Worker / Discovery / Scoring
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
.WriteTo.Logger(lc => lc
|
||||||
|
.Filter.ByIncludingOnly(e =>
|
||||||
|
e.Properties.ContainsKey("SourceContext") &&
|
||||||
|
e.Properties["SourceContext"].ToString().Contains("Worker"))
|
||||||
|
.WriteTo.File(
|
||||||
|
Path.Combine(logBaseDir, "workers", "workers-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
outputTemplate: textTemplate,
|
||||||
|
retainedFileCountLimit: 14,
|
||||||
|
shared: true))
|
||||||
|
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
Log.Warning("══════════════════════════════════════════════════════");
|
||||||
|
Log.Warning(" 🚀 Predictalytics v1.0 — Data retrieval started!");
|
||||||
|
Log.Warning(" 📊 First platform report in 5 minutes.");
|
||||||
|
Log.Warning("══════════════════════════════════════════════════════");
|
||||||
|
|
||||||
|
mainForm.Initialize();
|
||||||
|
System.Windows.Forms.Application.Run(mainForm);
|
||||||
|
|
||||||
|
Log.Information("Application shutting down.");
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"Predictalytics.WinFormsHost": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"applicationUrl": "https://localhost:62271;http://localhost:62272"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Predictalytics.Api.Endpoints;
|
||||||
|
using Predictalytics.Worker;
|
||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages the lifecycle of the embedded Kestrel web server and background workers.
|
||||||
|
/// </summary>
|
||||||
|
public class EmbeddedWebServer
|
||||||
|
{
|
||||||
|
private WebApplication? _app;
|
||||||
|
private Task? _runTask;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
public string? ConnectionString { get; set; }
|
||||||
|
public bool DbConnectionDebug { get; set; }
|
||||||
|
|
||||||
|
public async Task StartWebServerAsync(int port = 5000)
|
||||||
|
{
|
||||||
|
lock (_lock) { if (_app != null) return; }
|
||||||
|
|
||||||
|
await Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseUrls($"http://localhost:{port}");
|
||||||
|
|
||||||
|
string? effectiveConnString = ConnectionString;
|
||||||
|
if (!string.IsNullOrEmpty(effectiveConnString) && (effectiveConnString.Contains("Database=;") || effectiveConnString.Contains("Database= ")))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Connection string contains an empty 'Database' value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
|
||||||
|
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
|
||||||
|
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
|
||||||
|
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
|
||||||
|
builder.Host.UseSerilog();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
app.UseCors();
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
|
||||||
|
var wwwrootPath = FindWwwrootPath();
|
||||||
|
if (wwwrootPath != null)
|
||||||
|
{
|
||||||
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
{
|
||||||
|
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(wwwrootPath)
|
||||||
|
});
|
||||||
|
app.MapGet("/", () => Results.File(
|
||||||
|
Path.Combine(wwwrootPath, "index.html"), "text/html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.MapDashboardEndpoints();
|
||||||
|
app.MapTraderEndpoints();
|
||||||
|
app.MapMarketEndpoints();
|
||||||
|
app.MapAlertEndpoints();
|
||||||
|
app.MapSearchEndpoints();
|
||||||
|
app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
|
||||||
|
|
||||||
|
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug);
|
||||||
|
|
||||||
|
lock (_lock) { _app = app; }
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_runTask = app.RunAsync(_cts.Token);
|
||||||
|
|
||||||
|
Log.Information("Kestrel webserver started on port {Port}", port);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to start embedded web server");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopWebServerAsync()
|
||||||
|
{
|
||||||
|
WebApplication? app;
|
||||||
|
lock (_lock) { app = _app; _app = null; }
|
||||||
|
|
||||||
|
if (app != null)
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
try { await app.StopAsync(TimeSpan.FromSeconds(5)); }
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
await (app as IAsyncDisposable).DisposeAsync();
|
||||||
|
Log.Information("Kestrel webserver stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartWorkersAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Log.Information("Starting workers (no web server)...");
|
||||||
|
|
||||||
|
var host = Host.CreateDefaultBuilder()
|
||||||
|
.UseSerilog()
|
||||||
|
.ConfigureServices((ctx, services) =>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
services.AddWorkerServices();
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(host.Services, DbConnectionDebug);
|
||||||
|
await host.RunAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs a one-time full market sync for all supported platforms.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RunSingleMarketSyncAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Log.Information("🏛️ Starting manual full market sync...");
|
||||||
|
|
||||||
|
var host = Host.CreateDefaultBuilder()
|
||||||
|
.UseSerilog()
|
||||||
|
.ConfigureServices((ctx, services) =>
|
||||||
|
{
|
||||||
|
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug);
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var rateLimiter = host.Services.GetRequiredService<IRateLimiter>();
|
||||||
|
var platformProviders = host.Services.GetRequiredService<IEnumerable<IPlatformProvider>>();
|
||||||
|
|
||||||
|
foreach (var p in platformProviders.Where(x => x.IsImplemented))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var includeClosed in new[] { true, false })
|
||||||
|
{
|
||||||
|
Log.Warning("[{Platform}] Starting manual market sync (includeClosed={Closed})...", p.PlatformName, includeClosed);
|
||||||
|
int totalSynced = 0;
|
||||||
|
int offset = 0;
|
||||||
|
const int batchSize = 1000;
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create a fresh scope per batch to keep the DbContext change tracker small
|
||||||
|
using var scope = host.Services.CreateScope();
|
||||||
|
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
|
||||||
|
// We need to get the provider from the scope to ensure its dependencies (like DbContext) are correct if injected
|
||||||
|
var provider = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>()
|
||||||
|
.First(x => x.Platform == p.Platform);
|
||||||
|
|
||||||
|
await rateLimiter.WaitAsync(provider.Platform, ct);
|
||||||
|
var markets = await provider.GetMarketsAsync(batchSize, offset.ToString(), includeClosed, ct);
|
||||||
|
|
||||||
|
if (markets == null || markets.Count == 0)
|
||||||
|
{
|
||||||
|
Log.Warning("[{Platform}] No more markets found at offset {Offset} (includeClosed={Closed}). Ending pass.", provider.PlatformName, offset, includeClosed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await marketRepo.AddOrUpdateRangeAsync(markets, ct);
|
||||||
|
|
||||||
|
totalSynced += markets.Count;
|
||||||
|
offset += batchSize;
|
||||||
|
|
||||||
|
if (totalSynced % 500 == 0 || markets.Count < batchSize)
|
||||||
|
Log.Information("[{Platform}] Synced {Total} markets so far (offset={Offset}, includeClosed={Closed})...", provider.PlatformName, totalSynced, offset, includeClosed);
|
||||||
|
|
||||||
|
if (markets.Count < batchSize)
|
||||||
|
{
|
||||||
|
Log.Warning("[{Platform}] Batch was smaller than limit ({Count}/{Limit}), assuming end of list.", provider.PlatformName, markets.Count, batchSize);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Error processing batch at offset {Offset} for {Platform}", offset, p.PlatformName);
|
||||||
|
offset += batchSize;
|
||||||
|
if (offset > 50000) break; // Safety break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Warning("✅ [{Platform}] Sync pass complete (includeClosed={Closed}). {Synced} markets synced.", p.PlatformName, includeClosed, totalSynced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Error during manual sync for {Platform}", p.PlatformName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindWwwrootPath()
|
||||||
|
{
|
||||||
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
// Try to find the src root by traversing up from the build output
|
||||||
|
// Typical: src/Predictalytics.WinFormsHost/bin/Debug/net8.0-windows/
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(baseDir, "wwwroot"),
|
||||||
|
// From bin/Debug/net8.0-windows/ up to src/, then into Api/wwwroot
|
||||||
|
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "Predictalytics.Api", "wwwroot")),
|
||||||
|
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "src", "Predictalytics.Api", "wwwroot")),
|
||||||
|
// Absolute fallback for this specific workspace
|
||||||
|
@"j:\Softwareprojekte\Predictalytics\Predictalytics\src\Predictalytics.Api\wwwroot"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var path in candidates)
|
||||||
|
{
|
||||||
|
var fullPath = Path.GetFullPath(path);
|
||||||
|
if (Directory.Exists(fullPath))
|
||||||
|
{
|
||||||
|
Log.Information("Found wwwroot at: {Path}", fullPath);
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Warning("wwwroot directory not found! Searched: {Paths}", string.Join(", ", candidates));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a thread-safe write action for the RichTextBox terminal.
|
||||||
|
/// </summary>
|
||||||
|
public static class TerminalHelper
|
||||||
|
{
|
||||||
|
public static Action<string, LogEventLevel> CreateWriteAction(RichTextBox rtb, Control owner)
|
||||||
|
{
|
||||||
|
int lineCount = 0;
|
||||||
|
const int maxLines = 500;
|
||||||
|
|
||||||
|
return (message, level) =>
|
||||||
|
{
|
||||||
|
if (owner.IsDisposed || rtb.IsDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
owner.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
if (rtb.IsDisposed) return;
|
||||||
|
|
||||||
|
lineCount++;
|
||||||
|
if (lineCount > maxLines)
|
||||||
|
{
|
||||||
|
rtb.Clear();
|
||||||
|
lineCount = 0;
|
||||||
|
rtb.AppendText("[Terminal cleared — log continues]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
var color = level switch
|
||||||
|
{
|
||||||
|
LogEventLevel.Error or LogEventLevel.Fatal => System.Drawing.Color.FromArgb(255, 82, 82),
|
||||||
|
LogEventLevel.Warning => System.Drawing.Color.FromArgb(255, 193, 7),
|
||||||
|
LogEventLevel.Debug => System.Drawing.Color.FromArgb(158, 158, 158),
|
||||||
|
_ => System.Drawing.Color.FromArgb(76, 175, 80)
|
||||||
|
};
|
||||||
|
|
||||||
|
rtb.SelectionStart = rtb.TextLength;
|
||||||
|
rtb.SelectionLength = 0;
|
||||||
|
rtb.SelectionColor = color;
|
||||||
|
rtb.AppendText(message);
|
||||||
|
rtb.SelectionColor = rtb.ForeColor;
|
||||||
|
rtb.ScrollToCaret();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { /* UI thread shutting down */ }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="Predictalytics.app"/>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Server=localhost;Database=Predictalytics_dev;User=root;Password="
|
||||||
|
},
|
||||||
|
"WebServer": {
|
||||||
|
"Port": 5000
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Predictalytics.Worker.Services;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Predictalytics.Worker;
|
||||||
|
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddWorkerServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddHostedService<MarketSyncWorker>();
|
||||||
|
services.AddHostedService<PollingWorker>();
|
||||||
|
services.AddHostedService<TradeHistoryWorker>();
|
||||||
|
services.AddHostedService<TradeReconciliationWorker>();
|
||||||
|
services.AddHostedService<DiscoveryWorker>();
|
||||||
|
services.AddHostedService<TopHolderDiscoveryWorker>();
|
||||||
|
services.AddHostedService<MarketHistoryWorker>();
|
||||||
|
services.AddHostedService<ReportingWorker>();
|
||||||
|
services.AddHostedService<TraderCleanupWorker>();
|
||||||
|
services.AddHostedService<TraderAnalyticsWorker>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>Predictalytics.Worker</RootNamespace>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Predictalytics.Application\Predictalytics.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Worker.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that periodically discovers new notable traders on platforms.
|
||||||
|
/// </summary>
|
||||||
|
public class DiscoveryWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly IPlatformStatisticsService _statsService;
|
||||||
|
private readonly ILogger<DiscoveryWorker> _logger;
|
||||||
|
|
||||||
|
public DiscoveryWorker(IServiceProvider services, ILogger<DiscoveryWorker> logger, IPlatformStatisticsService statsService)
|
||||||
|
{ _services = services; _logger = logger; _statsService = statsService; }
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🔍 DiscoveryWorker started");
|
||||||
|
await Task.Delay(10000, stoppingToken); // Delay to let other services initialize
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var discovery = scope.ServiceProvider.GetRequiredService<IDiscoveryService>();
|
||||||
|
|
||||||
|
// Run discovery for all implemented platforms
|
||||||
|
foreach (var platform in new[] { PlatformType.Polymarket, PlatformType.Limitless })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
_logger.LogWarning("[{Platform}] Starting discovery scan...", platform);
|
||||||
|
var discovered = await discovery.RunDiscoveryAsync(platform, stoppingToken);
|
||||||
|
_statsService.TrackTraderDiscovery(platform, discovered.Count);
|
||||||
|
_logger.LogWarning("[{Platform}] Discovery found {Count} traders", platform, discovered.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error in discovery for platform {Platform}", platform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (Exception ex) { _logger.LogError(ex, "DiscoveryWorker error"); }
|
||||||
|
|
||||||
|
// Run discovery every 5 minutes
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("🔍 DiscoveryWorker stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Predictalytics.Infrastructure.Logging;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Worker.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that periodically fetches recent trades for active markets
|
||||||
|
/// to discover new unknown traders. Updates markets starting with the oldest refreshed.
|
||||||
|
/// </summary>
|
||||||
|
public class MarketHistoryWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<MarketHistoryWorker> _logger;
|
||||||
|
|
||||||
|
private const int CooldownHours = 6;
|
||||||
|
private const int MarketsPerCycle = 10;
|
||||||
|
private const int TradesPerFetch = 100;
|
||||||
|
|
||||||
|
public MarketHistoryWorker(IServiceProvider services, ILogger<MarketHistoryWorker> logger)
|
||||||
|
{ _services = services; _logger = logger; }
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🕵️ MarketHistoryWorker started (cooldown: {Hours}h)", CooldownHours);
|
||||||
|
await Task.Delay(25000, stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
|
||||||
|
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
|
||||||
|
var providers = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
|
||||||
|
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
|
||||||
|
|
||||||
|
var markets = await marketRepo.GetMarketsDueForTradeUpdateAsync(CooldownHours, MarketsPerCycle, stoppingToken);
|
||||||
|
|
||||||
|
if (markets.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("🕵️ No markets due for trade history update. Sleeping.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🕵️ Processing {Count} markets for trade history sync", markets.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalDiscovered = 0;
|
||||||
|
|
||||||
|
foreach (var market in markets)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
var provider = providers.FirstOrDefault(p => p.Platform == market.Platform && p.IsImplemented);
|
||||||
|
if (provider == null) continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
|
||||||
|
await rateLimiter.WaitAsync(market.Platform, stoppingToken);
|
||||||
|
|
||||||
|
var trades = await provider.GetMarketTradesAsync(market.PlatformMarketId, TradesPerFetch, stoppingToken);
|
||||||
|
|
||||||
|
// Extract unique wallet addresses from TransientWallet [NotMapped].
|
||||||
|
// Providers set this during in-memory mapping; it is NOT stored in the DB.
|
||||||
|
var wallets = trades
|
||||||
|
.Select(t => t.TransientWallet)
|
||||||
|
.Where(w => !string.IsNullOrWhiteSpace(w))
|
||||||
|
.Select(w => w!)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var wallet in wallets)
|
||||||
|
{
|
||||||
|
var existing = await traderRepo.GetByPlatformIdAsync(
|
||||||
|
market.Platform, wallet, stoppingToken);
|
||||||
|
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
var trader = new Domain.Entities.Trader
|
||||||
|
{
|
||||||
|
Platform = market.Platform,
|
||||||
|
PlatformUserId = wallet,
|
||||||
|
DisplayName = wallet.Length > 8 ? wallet[..8] + "..." : wallet, // Will be updated later
|
||||||
|
IsAutoDiscovered = true,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await traderRepo.AddAsync(trader, stoppingToken);
|
||||||
|
totalDiscovered++;
|
||||||
|
|
||||||
|
_logger.LogInformation("[{Platform}] Discovered trader ({Wallet}) from market trades: {Market}",
|
||||||
|
provider.PlatformName, wallet[..10] + "...",
|
||||||
|
market.Question.Length > 60 ? market.Question[..60] + "..." : market.Question);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update timestamps
|
||||||
|
market.LastTradesUpdatedAt = DateTime.UtcNow;
|
||||||
|
await marketRepo.UpdateAsync(market, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error syncing trade history for market {Market} on {Platform}",
|
||||||
|
market.PlatformMarketId, market.Platform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalDiscovered > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✅ Market history cycle complete. {Count} new traders discovered.", totalDiscovered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (Exception ex) { _logger.LogError(ex, "MarketHistoryWorker error"); }
|
||||||
|
|
||||||
|
// Run every 2 minutes
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("🕵️ MarketHistoryWorker stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Predictalytics.Infrastructure.Logging;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Predictalytics.Worker.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that periodically syncs market master data (including outcomes/token IDs)
|
||||||
|
/// from the Gamma API. This builds the lookup table needed to resolve trades to markets.
|
||||||
|
/// </summary>
|
||||||
|
public class MarketSyncWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private static DateTime _lastDbError = DateTime.MinValue;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly IPlatformStatisticsService _statsService;
|
||||||
|
private readonly ILogger<MarketSyncWorker> _logger;
|
||||||
|
|
||||||
|
public MarketSyncWorker(IServiceProvider services, ILogger<MarketSyncWorker> logger, IPlatformStatisticsService statsService)
|
||||||
|
{ _services = services; _logger = logger; _statsService = statsService; }
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🏛️ MarketSyncWorker started");
|
||||||
|
await Task.Delay(5000, stoppingToken); // Let DB initialize
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rateLimiter = _services.GetRequiredService<IRateLimiter>();
|
||||||
|
var platformProviders = _services.GetRequiredService<IEnumerable<IPlatformProvider>>();
|
||||||
|
|
||||||
|
foreach (var p in platformProviders.Where(x => x.IsImplemented))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
using var platformCtx = PlatformLogContext.Push(p.PlatformName);
|
||||||
|
int cycleTotalSynced = 0;
|
||||||
|
foreach (var includeClosed in new[] { false, true })
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[{Platform}] Syncing markets (includeClosed={Closed})...", p.PlatformName, includeClosed);
|
||||||
|
|
||||||
|
int passSynced = 0;
|
||||||
|
int offset = 0;
|
||||||
|
const int batchSize = 1000;
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Fresh scope per batch
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
|
||||||
|
var provider = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>()
|
||||||
|
.First(x => x.Platform == p.Platform);
|
||||||
|
|
||||||
|
await rateLimiter.WaitAsync(provider.Platform, stoppingToken);
|
||||||
|
var markets = await provider.GetMarketsAsync(batchSize, offset.ToString(), includeClosed, stoppingToken);
|
||||||
|
|
||||||
|
if (markets.Count == 0) break;
|
||||||
|
|
||||||
|
await marketRepo.AddOrUpdateRangeAsync(markets, stoppingToken);
|
||||||
|
|
||||||
|
passSynced += markets.Count;
|
||||||
|
cycleTotalSynced += markets.Count;
|
||||||
|
_statsService.TrackMarketSync(provider.Platform, markets.Count);
|
||||||
|
offset += batchSize;
|
||||||
|
|
||||||
|
if (passSynced % 500 == 0)
|
||||||
|
_logger.LogWarning("[{Platform}] Synced {Total} markets so far (includeClosed={Closed})...", p.PlatformName, passSynced, includeClosed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need a temporary scope for stats
|
||||||
|
using (var scope = _services.CreateScope())
|
||||||
|
{
|
||||||
|
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
|
||||||
|
var dbCount = await marketRepo.GetCountAsync(stoppingToken);
|
||||||
|
_logger.LogWarning("✅ [{Platform}] Market sync complete. {Synced} synced this cycle, {Total} total in DB",
|
||||||
|
p.PlatformName, cycleTotalSynced, dbCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error syncing markets for platform {Platform}", p.PlatformName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (Exception ex) when (ex.ToString().Contains("MySqlException") || ex.ToString().Contains("Connection"))
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow - _lastDbError > TimeSpan.FromMinutes(10))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("⚠️ Database connection lost in MarketSyncWorker. Retrying in 30m. (Error: {Message})", ex.Message);
|
||||||
|
_lastDbError = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _logger.LogError(ex, "MarketSyncWorker error"); }
|
||||||
|
|
||||||
|
// Run every 30 minutes
|
||||||
|
_logger.LogInformation("🏛️ Next market sync in 30 minutes.");
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("🏛️ MarketSyncWorker stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user