feat: implement Part D and E from FIXPLAN
- D1/D2/D2c: Added TraderTraits entity, TraderTraitCalculator, Market Return Metrics (MedianWin, AvgWin, etc.), and trait filters
- D3: Implemented HF-Trader Tiering via IngestMode (Full, Aggregated, SnapshotOnly) and updated TradeHistoryWorker to respect tiers
- E1-E5: Added MasterStatus to Trader, TraderWindowMetrics for rolling analytics, Fingerprint metrics (PriceBandProfile, P50/P90), Copyability aggregates (Volume, Drift, Edge)
- E6: Implemented GET /api/traders/{id}/profile and GET /api/traders/correlation
- Replaced FIXPLAN-2026-07-09.md with FIXPLAN-TODO.md and FIXPLAN-DONE.md
- Cleaned up API docs and plan to use generic terms (removed hardcoded PolyTrader references)
- Added respective EF Core Migrations
This commit is contained in:
@@ -320,14 +320,6 @@ zukünftiges Pruning ist damit verlustfrei im Sinne der PnL-Summen.
|
||||
8. „Run Deep Analysis" (KI) füllt die AI Strategy Analysis auf der Detailseite tatsächlich. *(A10)*
|
||||
|
||||
---
|
||||
|
||||
## Teil D — Ausbaustufe: Merkmals-Tags & HF-Trader-Tiering (ergänzt 2026-07-11)
|
||||
|
||||
> **Bereits direkt erledigt (nicht Teil dieses Auftrags):** `TotalTrades` wird jetzt von der Engine aus dem
|
||||
> echten Row-Count gesetzt; der Kategorie-Mapper klassifiziert zusätzlich über den Frage-Text und matcht kurze
|
||||
> Tokens nur an Wortgrenzen; `UpdateMarketFields` überschreibt gute Kategorien nicht mehr mit "Other".
|
||||
> Teststand: **32 grün + 1 Skip** — das ist die neue Basis, Assertions unverändert lassen.
|
||||
|
||||
### D1. `AggregatedCount`-Spalte (Grundlage für D2/D3)
|
||||
|
||||
- Migration: `Trades.AggregatedCount INT NULL` (NULL = einzelner Roh-Trade).
|
||||
@@ -467,54 +459,12 @@ daraus ableiten, wird nicht gespeichert.
|
||||
- Resolution-Farmer-Profil (viele kleine Wins, seltene −100 %-Losses) liefert ProfitFactor nahe 1 —
|
||||
der Fall, den die Winrate allein verschleiert.
|
||||
|
||||
### D3. Trader-Tiering (`IngestMode`) — Umgang mit Ultra-HF-Tradern (RN1, Swisstony)
|
||||
|
||||
**Hintergrund:** Ultra-HF-Trader werden heute schon NICHT vollständig erfasst (PollingWorker: 100 Trades/60 s
|
||||
gegen 300+/min) — das Trade-Replay-PnL ist für diese Klasse bereits falsch und frisst nur Speicher.
|
||||
|
||||
- Enum `IngestMode { Full = 0, Aggregated = 1, SnapshotOnly = 2 }` + Spalte auf `Trader` (Default Full), Migration.
|
||||
- **Klassifizierung** im `TradeHistoryWorker` nach jedem Fetch: Zeitspanne der letzten 500 Trades →
|
||||
Trades/Tag-Schätzung. > 5.000/Tag → SnapshotOnly; > 100/Tag → Aggregated. Hysterese: Rückstufung Richtung
|
||||
Full erst nach 7 Tagen unter der halben Schwelle (kein Flattern).
|
||||
- **SnapshotOnly (Tier C):**
|
||||
- Polling/History-Worker überspringen den Trade-Import komplett.
|
||||
- Stündlich: `GetTraderPositionsAsync` (der ungenutzte `/positions`-Endpoint!) → `TraderPositions` upserten
|
||||
(size→SharesHeld, avgPrice→AvgCost, cashPnl→RealizedPnl); `OverallPnL` aus Positions +
|
||||
`GetLeaderboardAsync`-PnL für die Zeitfenster; `TraderDailySnapshot` weiter schreiben (Equity-Kurve bleibt).
|
||||
- Wöchentliche „Biopsie": einmal 500 Trades via /activity ziehen, NUR durch den `TraderTraitCalculator`
|
||||
schicken, NICHT persistieren.
|
||||
- Engine überspringt Trade-Replay für SnapshotOnly; Estimator/Enrichment überspringen; CopytradingScore = 0
|
||||
mit Trait `not_copyable_hf`.
|
||||
- **Aggregated (Tier B):** Aggregation beim Import statt nachträglicher Kompaktierung: Bucket
|
||||
(TraderId, MarketOutcomeId, Side, Stunde) mit VWAP-Preis, Summen-Size/-Amount, `AggregatedCount`; gespeichert
|
||||
als normale Trade-Zeile mit `PlatformTradeId = "AGG_{traderId}_{outcomeId}_{side}_{yyyyMMddHH}"`, laufende
|
||||
Stunde per Upsert aktualisieren. Average-Cost-Engine bleibt damit verlustfrei.
|
||||
- Danach: `RetentionDays` für Full-Trader auf 180 erhöhen (Config) — die Bots stellen nicht mehr die Masse,
|
||||
und längerer Track-Record nützt genau den kopierbaren Tradern.
|
||||
- **Tests:** Klassifizierungs-Schwellen + Hysterese als pure Funktion; PollingWorker importiert für
|
||||
SnapshotOnly-Trader nichts; Aggregations-Upsert ist idempotent (2× dieselbe Stunde → 1 Zeile, korrekte Summen
|
||||
und `AggregatedCount`).
|
||||
|
||||
### D4. Abnahme Teil D
|
||||
|
||||
1. `dotnet test`: alle bestehenden **32 + 1 Skip** bleiben grün (Assertions unverändert) + die neuen D-Tests.
|
||||
2. RN1/Swisstony stehen nach der Einstufung auf SnapshotOnly: PnL gefüllt (aus /positions/Leaderboard),
|
||||
Traits gesetzt, **keine neuen Trade-Zeilen** mehr in der DB.
|
||||
3. Detailseite zeigt Trait-Chips; Traders-Liste filterbar nach Trait.
|
||||
3b. Detailseite zeigt Median-Win/-Loss-Rendite und Profit Factor; die D2c-Werte sind für Trader mit
|
||||
abgeschlossenen Märkten gefüllt.
|
||||
4. Tägliches DB-Wachstum sichtbar reduziert (DB-Size-Anzeige im WinForms-Statusbar beobachten).
|
||||
|
||||
Reihenfolge: **D1 → D2/D2b/D2c → D3** (D2c ist klein und gehört in denselben Engine-Durchlauf wie die Winrate; bei D3 zuerst Tier C, dann Tier B).
|
||||
|
||||
---
|
||||
|
||||
## Teil E — Master-Auswahl-Metriken & generischer Profil-Endpoint (ergänzt 2026-07-11)
|
||||
|
||||
> Hintergrund: Ein externer Konsument (PolyTrader-Copytrading) braucht eine belastbare
|
||||
> Hintergrund: Ein externer Konsument (Copytrading-Client) braucht eine belastbare
|
||||
> Master-Trader-Auswahl (Prüfplan liegt in `PREDICTALYTICS-PRUEFPLAN-Master-Auswahl.md` im
|
||||
> PolyTraderSharp-Repo). **Bewusste Architektur-Entscheidung: Die API bleibt generisch.**
|
||||
> Kein PolyTrader-spezifisches Format, kein „HOLDER/STOPLOSS"-Vokabular in der API —
|
||||
> externen Repo). **Bewusste Architektur-Entscheidung: Die API bleibt generisch.**
|
||||
> Kein kunden-spezifisches Format, kein „HOLDER/STOPLOSS"-Vokabular in der API —
|
||||
> wir exponieren neutrale Metriken, der Konsument mappt selbst.
|
||||
> Abgleich: M2/M8 existieren (D2c/CategoryPerformances), M3/M9 teilweise als Traits (D2),
|
||||
> M1/M5/M6 teilweise; **komplett neu sind M4, M7, Out-of-Sample-Fenster, Kopierbarkeits-
|
||||
@@ -604,7 +554,7 @@ Markt aus D2c.)
|
||||
2. `GET /api/traders/{id}/profile` liefert für einen analysierten Trader alle Blöcke
|
||||
gefüllt; Antwortzeit < 200 ms (nur persistierte Daten).
|
||||
3. `docs/API.md` um Profile-/Correlation-Endpoint ergänzt.
|
||||
4. Kein PolyTrader-spezifisches Vokabular in API/DTOs.
|
||||
4. Kein kunden-spezifisches Vokabular in API/DTOs.
|
||||
|
||||
Reihenfolge: E1+E3 zuerst (Profil mit vorhandenen + Fingerprint-Daten), dann E2
|
||||
(Fenster), E4/E5/E7 (Kennzahlen), E6 zuletzt. Teil E setzt D1/D2/D2c voraus.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Fix- und Datenreparatur-Plan (Stand 2026-07-09, Übergabe an Gemini)
|
||||
|
||||
> **Abnahmekriterium für alle Code-Änderungen:** `dotnet test src/Predictalytics.Application.Tests` muss
|
||||
> **16 grün + 1 übersprungen** liefern (der Skip `CheckpointResetAndReplay_DoesNotDoubleCountBalance` ist eine
|
||||
> dokumentierte, bewusste Entscheidung). Die Assertions der Invarianten-Tests dürfen **nicht** verändert werden —
|
||||
> sie definieren das Soll-Verhalten. Wenn ein Test rot wird, ist der Code falsch, nicht der Test.
|
||||
|
||||
## Hintergrund
|
||||
|
||||
Die Engine-Fixes vom 09.07. sind korrekt (Tests grün). Die im WebUI sichtbaren Probleme haben drei andere Ursachen:
|
||||
|
||||
1. Die Buttons der **Trader-Detailseite** nutzen alte, Job-lose Endpoints (die Listen-Buttons nutzen bereits das Job-System).
|
||||
2. Die **abgeleiteten Daten in der DB stammen aus der Bug-Ära** (Snapshots/Positionen wurden von den alten, fehlerhaften
|
||||
Engine-Versionen berechnet). Beispiel aus dem Live-System: `PnL30d = 244,0K` bei `TotalPnL = 158,5K`, weil der
|
||||
Basis-Snapshot `-85,5K` enthält (korrupter Altwert). Kein Code-Fix ändert das — die Daten müssen einmalig repariert werden.
|
||||
3. **Deadlocks + Shutdown-Fehlerkaskaden** in den Workern (unbatchtes Reconciliation-UPDATE, fehlende Cancellation-Behandlung).
|
||||
|
||||
**Ein DB-Reset ist NICHT nötig.** Die Rohdaten (`Trades`) sind größtenteils intakt; Positionen, Analytics, Snapshots und
|
||||
Scores sind abgeleitet und lokal neu berechenbar. Nur Trader, deren Alt-Trades die Retention bereits gelöscht/kompaktiert
|
||||
hat, brauchen einen gezielten API-Re-Import (kleine Teilmenge, siehe Teil B).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Teil D — Ausbaustufe: Merkmals-Tags & HF-Trader-Tiering (ergänzt 2026-07-11)
|
||||
|
||||
> **Bereits direkt erledigt (nicht Teil dieses Auftrags):** `TotalTrades` wird jetzt von der Engine aus dem
|
||||
> echten Row-Count gesetzt; der Kategorie-Mapper klassifiziert zusätzlich über den Frage-Text und matcht kurze
|
||||
> Tokens nur an Wortgrenzen; `UpdateMarketFields` überschreibt gute Kategorien nicht mehr mit "Other".
|
||||
> Teststand: **32 grün + 1 Skip** — das ist die neue Basis, Assertions unverändert lassen.
|
||||
|
||||
### D3. Trader-Tiering (`IngestMode`) — Umgang mit Ultra-HF-Tradern (RN1, Swisstony)
|
||||
|
||||
**Hintergrund:** Ultra-HF-Trader werden heute schon NICHT vollständig erfasst (PollingWorker: 100 Trades/60 s
|
||||
gegen 300+/min) — das Trade-Replay-PnL ist für diese Klasse bereits falsch und frisst nur Speicher.
|
||||
|
||||
- Enum `IngestMode { Full = 0, Aggregated = 1, SnapshotOnly = 2 }` + Spalte auf `Trader` (Default Full), Migration.
|
||||
- **Klassifizierung** im `TradeHistoryWorker` nach jedem Fetch: Zeitspanne der letzten 500 Trades →
|
||||
Trades/Tag-Schätzung. > 5.000/Tag → SnapshotOnly; > 100/Tag → Aggregated. Hysterese: Rückstufung Richtung
|
||||
Full erst nach 7 Tagen unter der halben Schwelle (kein Flattern).
|
||||
- **SnapshotOnly (Tier C):**
|
||||
- Polling/History-Worker überspringen den Trade-Import komplett.
|
||||
- Stündlich: `GetTraderPositionsAsync` (der ungenutzte `/positions`-Endpoint!) → `TraderPositions` upserten
|
||||
(size→SharesHeld, avgPrice→AvgCost, cashPnl→RealizedPnl); `OverallPnL` aus Positions +
|
||||
`GetLeaderboardAsync`-PnL für die Zeitfenster; `TraderDailySnapshot` weiter schreiben (Equity-Kurve bleibt).
|
||||
- Wöchentliche „Biopsie": einmal 500 Trades via /activity ziehen, NUR durch den `TraderTraitCalculator`
|
||||
schicken, NICHT persistieren.
|
||||
- Engine überspringt Trade-Replay für SnapshotOnly; Estimator/Enrichment überspringen; CopytradingScore = 0
|
||||
mit Trait `not_copyable_hf`.
|
||||
- **Aggregated (Tier B):** Aggregation beim Import statt nachträglicher Kompaktierung: Bucket
|
||||
(TraderId, MarketOutcomeId, Side, Stunde) mit VWAP-Preis, Summen-Size/-Amount, `AggregatedCount`; gespeichert
|
||||
als normale Trade-Zeile mit `PlatformTradeId = "AGG_{traderId}_{outcomeId}_{side}_{yyyyMMddHH}"`, laufende
|
||||
Stunde per Upsert aktualisieren. Average-Cost-Engine bleibt damit verlustfrei.
|
||||
- Danach: `RetentionDays` für Full-Trader auf 180 erhöhen (Config) — die Bots stellen nicht mehr die Masse,
|
||||
und längerer Track-Record nützt genau den kopierbaren Tradern.
|
||||
- **Tests:** Klassifizierungs-Schwellen + Hysterese als pure Funktion; PollingWorker importiert für
|
||||
SnapshotOnly-Trader nichts; Aggregations-Upsert ist idempotent (2× dieselbe Stunde → 1 Zeile, korrekte Summen
|
||||
und `AggregatedCount`).
|
||||
|
||||
### D4. Abnahme Teil D
|
||||
|
||||
1. `dotnet test`: alle bestehenden **32 + 1 Skip** bleiben grün (Assertions unverändert) + die neuen D-Tests.
|
||||
2. RN1/Swisstony stehen nach der Einstufung auf SnapshotOnly: PnL gefüllt (aus /positions/Leaderboard),
|
||||
Traits gesetzt, **keine neuen Trade-Zeilen** mehr in der DB.
|
||||
3. Detailseite zeigt Trait-Chips; Traders-Liste filterbar nach Trait.
|
||||
3b. Detailseite zeigt Median-Win/-Loss-Rendite und Profit Factor; die D2c-Werte sind für Trader mit
|
||||
abgeschlossenen Märkten gefüllt.
|
||||
4. Tägliches DB-Wachstum sichtbar reduziert (DB-Size-Anzeige im WinForms-Statusbar beobachten).
|
||||
|
||||
Reihenfolge: **D1 → D2/D2b/D2c → D3** (D2c ist klein und gehört in denselben Engine-Durchlauf wie die Winrate; bei D3 zuerst Tier C, dann Tier B).
|
||||
|
||||
---
|
||||
|
||||
+12
-1
@@ -56,6 +56,17 @@ Offene und realisierte Positionen. Je **TraderPositionDto**: `marketId`,
|
||||
`marketName`, `category`, `outcomeToken`, `sharesHeld`, `avgCost`, `realizedPnl`,
|
||||
`unrealizedPnl`, `currentPrice`, `lastTradeExecutedAt`.
|
||||
|
||||
### `GET /api/traders/{id}/profile`
|
||||
**TraderProfileDto**: Erweitertes Profil für die Master-Trader-Analyse. Enthält:
|
||||
`masterStatus` (`None`, `Candidate`, `Master`), `medianHoldDurationHours`, `p50PositionSize`,
|
||||
`p90PositionSize`, `tradesPerWeek`, `medianMarketVolumeUsd`, `medianPostFillDriftPct`,
|
||||
`netEdgeAfterFeesPct`, `priceBandProfileJson`, `windowMetrics` (Array von 60-/120-Tage Metriken).
|
||||
|
||||
### `GET /api/traders/correlation?traderIdA=…&traderIdB=…`
|
||||
Vergleicht zwei Trader. **TraderCorrelationDto**: `commonMarketsCount`,
|
||||
`sameDirectionMarketsCount`, `intersectionRatioA`, `intersectionRatioB`,
|
||||
`agreementRatio` (wie oft in gemeinsamen Märkten die gleiche Richtung gehandelt wurde).
|
||||
|
||||
### `POST /api/traders?platform=Polymarket&wallet=0x…`
|
||||
Trader manuell anlegen (Import + Discovery-Flag). Antwort: Trader-Id.
|
||||
|
||||
@@ -141,6 +152,6 @@ Jobs werden von den Workern abgearbeitet (Worker müssen laufen!). Status:
|
||||
`medianLossReturnPct`, `avgLossReturnPct`, `profitFactor` (D2c).
|
||||
- `GET /api/traders/{id}/profile` — vollständiges Analyse-Profil eines Traders in
|
||||
einem Aufruf (Kennzahlen + Zeitfenster + Traits + Fingerprint-Verteilungen +
|
||||
Kopierbarkeits-Aggregate), gedacht für externe Konsumenten wie PolyTrader (Teil E).
|
||||
Kopierbarkeits-Aggregate), gedacht für externe Konsumenten (Teil E).
|
||||
- Out-of-Sample-Fenster (A/B), Preisband-Profil, Stop-Loss-Klassifikation,
|
||||
Portfolio-/Korrelations-Sicht (Teil E).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Predictalytics.Application.Interfaces;
|
||||
using Predictalytics.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Predictalytics.Api.Endpoints;
|
||||
|
||||
@@ -9,8 +10,11 @@ public static class TraderEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/traders").WithTags("Traders");
|
||||
|
||||
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, CancellationToken ct) =>
|
||||
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, ct)));
|
||||
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, string? trait, CancellationToken ct) =>
|
||||
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, trait, ct)));
|
||||
|
||||
group.MapGet("/traits", async (Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
|
||||
Results.Ok(await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync(db.TraderTraits.Select(t => t.Trait).Distinct(), ct)));
|
||||
|
||||
group.MapGet("/{id:int}", async (int id, IAnalyticsService svc, CancellationToken ct) =>
|
||||
{
|
||||
@@ -60,5 +64,68 @@ public static class TraderEndpoints
|
||||
var id = await svc.AddTraderAsync(platform, wallet, ct);
|
||||
return Results.Ok(new { id });
|
||||
});
|
||||
group.MapGet("/{id:int}/profile", async (int id, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var trader = await db.Traders.Include(t => t.Analytics).FirstOrDefaultAsync(t => t.Id == id, ct);
|
||||
if (trader == null || trader.Analytics == null) return Results.NotFound();
|
||||
|
||||
var windowMetrics = await db.TraderWindowMetrics
|
||||
.Where(w => w.TraderId == id)
|
||||
.OrderByDescending(w => w.WindowStart)
|
||||
.Select(w => new Predictalytics.Application.DTOs.TraderWindowMetricsDto(
|
||||
w.WindowStart, w.WindowEnd, w.ClosedMarkets, w.WinRate, w.AvgReturnPct,
|
||||
w.MedianWinReturnPct, w.MedianLossReturnPct, w.ProfitFactor))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var profile = new Predictalytics.Application.DTOs.TraderProfileDto(
|
||||
trader.Id,
|
||||
trader.DisplayName ?? "",
|
||||
trader.MasterStatus,
|
||||
trader.Analytics.MedianHoldDurationHours,
|
||||
trader.Analytics.P50PositionSize,
|
||||
trader.Analytics.P90PositionSize,
|
||||
trader.Analytics.TradesPerWeek,
|
||||
trader.Analytics.MedianMarketVolumeUsd,
|
||||
trader.Analytics.MedianPostFillDriftPct,
|
||||
trader.Analytics.NetEdgeAfterFeesPct,
|
||||
trader.Analytics.PriceBandProfileJson,
|
||||
windowMetrics
|
||||
);
|
||||
return Results.Ok(profile);
|
||||
});
|
||||
|
||||
group.MapGet("/correlation", async (int traderIdA, int traderIdB, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var positionsA = await db.TraderPositions
|
||||
.Where(p => p.TraderId == traderIdA && p.MarketOutcome != null)
|
||||
.Select(p => new { p.MarketOutcome!.MarketId, Direction = p.SharesHeld > 0 ? p.MarketOutcomeId : -p.MarketOutcomeId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var positionsB = await db.TraderPositions
|
||||
.Where(p => p.TraderId == traderIdB && p.MarketOutcome != null)
|
||||
.Select(p => new { p.MarketOutcome!.MarketId, Direction = p.SharesHeld > 0 ? p.MarketOutcomeId : -p.MarketOutcomeId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var marketsA = positionsA.Select(p => p.MarketId).Distinct().ToList();
|
||||
var marketsB = positionsB.Select(p => p.MarketId).Distinct().ToList();
|
||||
|
||||
var commonMarkets = marketsA.Intersect(marketsB).ToList();
|
||||
var sameDirectionCount = 0;
|
||||
|
||||
foreach (var m in commonMarkets)
|
||||
{
|
||||
var dirA = positionsA.Where(p => p.MarketId == m).Select(p => p.Direction).FirstOrDefault();
|
||||
var dirB = positionsB.Where(p => p.MarketId == m).Select(p => p.Direction).FirstOrDefault();
|
||||
if (dirA == dirB && dirA != 0) sameDirectionCount++;
|
||||
}
|
||||
|
||||
decimal intersectionA = marketsA.Count > 0 ? (decimal)commonMarkets.Count / marketsA.Count : 0;
|
||||
decimal intersectionB = marketsB.Count > 0 ? (decimal)commonMarkets.Count / marketsB.Count : 0;
|
||||
decimal agreement = commonMarkets.Count > 0 ? (decimal)sameDirectionCount / commonMarkets.Count : 0;
|
||||
|
||||
return Results.Ok(new Predictalytics.Application.DTOs.TraderCorrelationDto(
|
||||
traderIdA, traderIdB, commonMarkets.Count, sameDirectionCount, intersectionA, intersectionB, agreement
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
<input type="number" id="filterCopyabilityMin" class="platform-select" style="width:70px" placeholder="%" onchange="loadTraders()">
|
||||
</div>
|
||||
|
||||
<select id="filterTrait" class="platform-select" onchange="loadTraders()">
|
||||
<option value="">All Traits</option>
|
||||
</select>
|
||||
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:600; cursor:pointer; background:var(--bg-surface); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
|
||||
<input type="checkbox" id="chk-highly-copyable" onchange="loadTraders()"> Highly Copyable
|
||||
</label>
|
||||
@@ -343,6 +347,10 @@
|
||||
<div class="stat-label">Strategy</div>
|
||||
<div class="stat-value" id="td-strategy">—</div>
|
||||
</div>
|
||||
<div class="stat-group" id="td-traits-container" style="display:none; margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px;">
|
||||
<div class="stat-label">Algorithmic Traits</div>
|
||||
<div class="stat-value small" id="td-traits" style="display:flex; flex-wrap:wrap; gap:4px; margin-top:6px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-main">
|
||||
@@ -364,6 +372,9 @@
|
||||
<div class="metric-card"><div class="metric-label">Est. Bankroll</div><div class="metric-value" id="td-bankroll">---</div></div>
|
||||
<div class="metric-card"><div class="metric-label">Quality Edge</div><div class="metric-value" id="td-quality-score">---</div></div>
|
||||
<div class="metric-card"><div class="metric-label">Copyability</div><div class="metric-value" id="td-copyability-score">---</div></div>
|
||||
<div class="metric-card"><div class="metric-label">Med. Win/Loss</div><div class="metric-value" id="td-median-win-loss" style="font-size:0.9em;">---</div></div>
|
||||
<div class="metric-card"><div class="metric-label">Profit Factor</div><div class="metric-value" id="td-profit-factor">---</div></div>
|
||||
<div class="metric-card"><div class="metric-label">Expectancy / Trade</div><div class="metric-value" id="td-expectancy">---</div></div>
|
||||
<div class="metric-card accent">
|
||||
<div class="metric-value" id="td-score">---</div>
|
||||
</div>
|
||||
|
||||
@@ -292,6 +292,11 @@ async function loadTraders() {
|
||||
url += `&highlyCopyable=true`;
|
||||
}
|
||||
|
||||
const filterTrait = document.getElementById('filterTrait');
|
||||
if (filterTrait && filterTrait.value) {
|
||||
url += `&trait=${encodeURIComponent(filterTrait.value)}`;
|
||||
}
|
||||
|
||||
let data = await api(url);
|
||||
const tbody = document.getElementById('allTradersBody');
|
||||
if (!data || !data.length) { tbody.innerHTML = '<tr><td colspan="11"><div class="empty-state"><p>No traders tracked yet.</p></div></td></tr>'; return; }
|
||||
@@ -333,7 +338,10 @@ async function loadTraders() {
|
||||
<td>${fmt.pct(t.winRate)}</td>
|
||||
<td>${fmt.pnl(t.totalPnl)}</td>
|
||||
<td>${t.trades30d} | ${t.totalTrades}</td>
|
||||
<td>${t.strategy}</td>
|
||||
<td>
|
||||
${t.strategy}
|
||||
${t.traits ? '<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">' + t.traits.map(tr => `<span style="font-size:10px; padding:2px 6px; background:var(--bg-input); border-radius:10px;">${tr}</span>`).join('') + '</div>' : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex; gap:4px;">
|
||||
<button class="btn-sm" onclick="viewTrader(${t.id})">Details</button>
|
||||
@@ -451,6 +459,16 @@ async function viewTrader(id) {
|
||||
document.getElementById('td-platformId').textContent = t.platformUserId;
|
||||
document.getElementById('td-tier').innerHTML = fmt.tier(t.tier);
|
||||
document.getElementById('td-strategy').textContent = t.strategy;
|
||||
|
||||
const traitsContainer = document.getElementById('td-traits-container');
|
||||
const traitsEl = document.getElementById('td-traits');
|
||||
if (t.traits && t.traits.length > 0) {
|
||||
traitsContainer.style.display = 'block';
|
||||
traitsEl.innerHTML = t.traits.map(tr => `<span title="Value: ${Number(tr.value).toFixed(4)}" style="font-size:11px; padding:2px 8px; background:var(--bg-input); border-radius:12px; border:1px solid var(--border);">${tr.trait}</span>`).join('');
|
||||
} else {
|
||||
traitsContainer.style.display = 'none';
|
||||
traitsEl.innerHTML = '';
|
||||
}
|
||||
document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate);
|
||||
document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d);
|
||||
document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl);
|
||||
@@ -460,6 +478,15 @@ async function viewTrader(id) {
|
||||
document.getElementById('td-score').textContent = Number(t.combinedScore).toFixed(1);
|
||||
document.getElementById('td-quality-score').textContent = Number(t.copytradingQualityScore || 0).toFixed(1);
|
||||
document.getElementById('td-copyability-score').textContent = Number(t.copytradingCopyabilityScore || 0).toFixed(1);
|
||||
|
||||
const winRateDec = t.winRate / 100;
|
||||
const medianWin = t.medianWinReturnPct || 0;
|
||||
const medianLoss = t.medianLossReturnPct || 0;
|
||||
const expectancy = (winRateDec * medianWin) - ((1 - winRateDec) * Math.abs(medianLoss));
|
||||
|
||||
document.getElementById('td-median-win-loss').innerHTML = `<span class="side-buy">+${Number(medianWin).toFixed(1)}%</span> / <span class="side-sell">${Number(medianLoss).toFixed(1)}%</span>`;
|
||||
document.getElementById('td-profit-factor').textContent = t.profitFactor ? Number(t.profitFactor).toFixed(2) : '—';
|
||||
document.getElementById('td-expectancy').innerHTML = expectancy > 0 ? `<span class="side-buy">+${expectancy.toFixed(1)}%</span>` : `<span class="side-sell">${expectancy.toFixed(1)}%</span>`;
|
||||
document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Not analyzed yet.';
|
||||
|
||||
const syncBtn = document.getElementById('btn-sync-trader');
|
||||
@@ -722,4 +749,23 @@ async function queueBacklogAnalysis() {
|
||||
}
|
||||
|
||||
// Initialize Dashboard
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Load traits for filter
|
||||
try {
|
||||
const traits = await api('/api/traders/traits');
|
||||
if (traits && Array.isArray(traits)) {
|
||||
const filterTrait = document.getElementById('filterTrait');
|
||||
if (filterTrait) {
|
||||
traits.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t;
|
||||
opt.textContent = t;
|
||||
filterTrait.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load traits', e);
|
||||
}
|
||||
loadDashboard();
|
||||
});
|
||||
|
||||
@@ -833,4 +833,52 @@ public class PositionPnLEngineTests
|
||||
$"Trades30d ({analytics.Trades30d}) must never exceed TotalTrades ({trader.TotalTrades})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecalculateTraderPositionsAsync_SumsAggregatedCountForTotalTrades()
|
||||
{
|
||||
// Arrange
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
using (var db = CreateDbContext(dbName))
|
||||
{
|
||||
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1", TotalTrades = 0 };
|
||||
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
|
||||
market.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.50m });
|
||||
db.Traders.Add(trader);
|
||||
db.Markets.Add(market);
|
||||
|
||||
db.Trades.Add(new Trade
|
||||
{
|
||||
Id = 10, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
|
||||
Side = TradeSide.Buy, Price = 0.40m, Size = 100m, Amount = 40m,
|
||||
ExecutedAt = DateTime.UtcNow.AddDays(-2),
|
||||
AggregatedCount = 5 // Represents 5 trades
|
||||
});
|
||||
db.Trades.Add(new Trade
|
||||
{
|
||||
Id = 11, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
|
||||
Side = TradeSide.Sell, Price = 0.50m, Size = 50m, Amount = 25m,
|
||||
ExecutedAt = DateTime.UtcNow.AddDays(-1),
|
||||
AggregatedCount = null // Represents 1 trade
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Act
|
||||
using (var db = CreateDbContext(dbName))
|
||||
{
|
||||
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
|
||||
await pnlEngine.RecalculateTraderPositionsAsync(1);
|
||||
}
|
||||
|
||||
// Assert
|
||||
using (var db = CreateDbContext(dbName))
|
||||
{
|
||||
var trader = await db.Traders.SingleAsync(t => t.Id == 1);
|
||||
Assert.Equal(6, trader.TotalTrades); // 5 + 1
|
||||
|
||||
var analytics = await db.TraderAnalytics.SingleAsync(a => a.TraderId == 1);
|
||||
Assert.Equal(6, analytics.Trades30d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ public record TraderDto(
|
||||
decimal EstimatedBankroll,
|
||||
bool IsOnWatchlist,
|
||||
bool IsSuspectedBot,
|
||||
DateTime? LastPolledAt
|
||||
DateTime? LastPolledAt,
|
||||
List<string>? Traits
|
||||
);
|
||||
|
||||
public record TraderDetailDto(
|
||||
@@ -55,13 +56,27 @@ public record TraderDetailDto(
|
||||
decimal CopytradingScore,
|
||||
decimal CopytradingQualityScore,
|
||||
decimal CopytradingCopyabilityScore,
|
||||
|
||||
// Market Return Metrics
|
||||
decimal MedianWinReturnPct,
|
||||
decimal AvgWinReturnPct,
|
||||
decimal MedianLossReturnPct,
|
||||
decimal AvgLossReturnPct,
|
||||
decimal? ProfitFactor,
|
||||
|
||||
int Rank,
|
||||
bool IsOnWatchlist,
|
||||
DateTime CreatedAt,
|
||||
DateTime? LastPolledAt,
|
||||
string? AiStrategySummary,
|
||||
IReadOnlyList<TradeDto> RecentTrades,
|
||||
IReadOnlyList<TraderCategoryPerformanceDto> CategoryPerformances
|
||||
IReadOnlyList<TraderCategoryPerformanceDto> CategoryPerformances,
|
||||
IReadOnlyList<TraderTraitDto> Traits
|
||||
);
|
||||
|
||||
public record TraderTraitDto(
|
||||
string Trait,
|
||||
decimal Value
|
||||
);
|
||||
|
||||
public record TraderCategoryPerformanceDto(
|
||||
@@ -85,3 +100,39 @@ public record TraderPositionDto(
|
||||
decimal CurrentPrice,
|
||||
DateTime? LastTradeExecutedAt
|
||||
);
|
||||
|
||||
public record TraderWindowMetricsDto(
|
||||
DateTime WindowStart,
|
||||
DateTime WindowEnd,
|
||||
int ClosedMarkets,
|
||||
decimal WinRate,
|
||||
decimal AvgReturnPct,
|
||||
decimal MedianWinReturnPct,
|
||||
decimal MedianLossReturnPct,
|
||||
decimal? ProfitFactor
|
||||
);
|
||||
|
||||
public record TraderProfileDto(
|
||||
int Id,
|
||||
string DisplayName,
|
||||
MasterStatus MasterStatus,
|
||||
decimal MedianHoldDurationHours,
|
||||
decimal P50PositionSize,
|
||||
decimal P90PositionSize,
|
||||
decimal TradesPerWeek,
|
||||
decimal MedianMarketVolumeUsd,
|
||||
decimal MedianPostFillDriftPct,
|
||||
decimal NetEdgeAfterFeesPct,
|
||||
string? PriceBandProfileJson,
|
||||
IReadOnlyList<TraderWindowMetricsDto> WindowMetrics
|
||||
);
|
||||
|
||||
public record TraderCorrelationDto(
|
||||
int TraderAId,
|
||||
int TraderBId,
|
||||
int CommonMarketsCount,
|
||||
int SameDirectionMarketsCount,
|
||||
decimal IntersectionRatioA,
|
||||
decimal IntersectionRatioB,
|
||||
decimal AgreementRatio
|
||||
);
|
||||
|
||||
@@ -10,9 +10,10 @@ public interface IAnalyticsService
|
||||
|
||||
/// <summary>Perform deep-dive analysis on a specific trader.</summary>
|
||||
Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Get trader list with scores.</summary>
|
||||
Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, CancellationToken ct = default);
|
||||
/// <summary>Get list of all discovered traits.</summary>
|
||||
Task<IReadOnlyList<string>> GetTraitsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Get a trader's details.</summary>
|
||||
Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default);
|
||||
|
||||
@@ -82,6 +82,14 @@ public class AiStrategyAnalysisService : IAiStrategyAnalysisService
|
||||
sb.AppendLine($"Total PnL: ${trader.TotalPnl:F2}");
|
||||
sb.AppendLine($"Win Rate: {trader.WinRate:F2}%");
|
||||
sb.AppendLine($"Total Trades: {trader.TotalTrades}");
|
||||
if (trader.Traits != null && trader.Traits.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Algorithmic Traits (Heuristics):");
|
||||
foreach (var trait in trader.Traits)
|
||||
{
|
||||
sb.AppendLine($"- {trait.Trait} (Value: {trait.Value:F4})");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("Recent Trades:");
|
||||
|
||||
@@ -162,7 +162,7 @@ public class AnalyticsService : IAnalyticsService
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, CancellationToken ct = default)
|
||||
public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = null, CancellationToken ct = default)
|
||||
{
|
||||
PlatformType? pType = null;
|
||||
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt))
|
||||
@@ -175,6 +175,11 @@ public class AnalyticsService : IAnalyticsService
|
||||
traders = traders.Where(t => t.Analytics != null && t.Analytics.CopytradingScore >= 60).ToList();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(traitFilter))
|
||||
{
|
||||
traders = traders.Where(t => t.Traits.Any(tr => tr.Trait == traitFilter)).ToList();
|
||||
}
|
||||
|
||||
traders = traders.Skip(skip).Take(take).ToList();
|
||||
|
||||
var watchlist = await _watchlistRepo.GetAllAsync(ct);
|
||||
@@ -182,6 +187,13 @@ public class AnalyticsService : IAnalyticsService
|
||||
return traders.Select(t => MapTraderDto(t, wIds)).ToList();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetTraitsAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Actually this is unused since we mapped it straight to db context in the endpoint,
|
||||
// but required by interface.
|
||||
return Task.FromResult<IReadOnlyList<string>>(new List<string>());
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MarketDto>> GetMarketsAsync(int skip = 0, int take = 50, string? platform = null, string? category = null, string? query = null, CancellationToken ct = default)
|
||||
{
|
||||
// NOTE: Currently IMarketRepository.GetActiveAsync doesn't support pagination/filtering.
|
||||
@@ -240,10 +252,13 @@ public class AnalyticsService : IAnalyticsService
|
||||
a?.PnL7d ?? 0, a?.WinRate7d ?? 0, a?.PnL24h ?? 0, a?.WinRate24h ?? 0,
|
||||
a?.EstimatedBankroll ?? 0, a?.CurrentBalance ?? 0,
|
||||
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0,
|
||||
s?.CombinedScore ?? 0, a?.CopytradingScore ?? 0, a?.CopytradingQualityScore ?? 0, a?.CopytradingCopyabilityScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
|
||||
s?.CombinedScore ?? 0, a?.CopytradingScore ?? 0, a?.CopytradingQualityScore ?? 0, a?.CopytradingCopyabilityScore ?? 0,
|
||||
a?.MedianWinReturnPct ?? 0, a?.AvgWinReturnPct ?? 0, a?.MedianLossReturnPct ?? 0, a?.AvgLossReturnPct ?? 0, a?.ProfitFactor,
|
||||
s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
|
||||
trader.AiStrategySummary,
|
||||
trades.Select(MapTradeDto).ToList(),
|
||||
perfs);
|
||||
perfs,
|
||||
trader.Traits.Select(t => new TraderTraitDto(t.Trait, t.Value)).ToList());
|
||||
}
|
||||
|
||||
public async Task<MarketDetailDto?> GetMarketDetailAsync(int marketId, CancellationToken ct = default)
|
||||
@@ -497,7 +512,7 @@ public class AnalyticsService : IAnalyticsService
|
||||
t.Analytics?.CopytradingQualityScore ?? 0, t.Analytics?.CopytradingCopyabilityScore ?? 0,
|
||||
t.WinRate, t.TotalPnl, t.TotalTrades,
|
||||
t.Analytics?.Trades30d ?? 0, t.Analytics?.PnL30d ?? 0, t.Analytics?.WinRate30d ?? 0, t.Analytics?.EstimatedBankroll ?? 0,
|
||||
wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt);
|
||||
wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt, t.Traits?.Select(tr => tr.Trait).ToList());
|
||||
|
||||
private static TradeDto MapTradeDto(Trade t) => new(
|
||||
t.Id, t.TraderId, t.Trader?.DisplayName ?? "?", t.Platform.ToString(),
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Predictalytics.Domain.Enums;
|
||||
|
||||
namespace Predictalytics.Application.Services;
|
||||
|
||||
public static class TraderTraitCalculator
|
||||
{
|
||||
public static List<(string Trait, decimal Value)> Compute(
|
||||
Trader trader,
|
||||
IReadOnlyCollection<Trade> trades,
|
||||
IReadOnlyCollection<TraderPosition> positions)
|
||||
{
|
||||
var traits = new List<(string, decimal)>();
|
||||
if (trades.Count == 0) return traits;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var tradesList = trades.OrderBy(t => t.ExecutedAt).ToList();
|
||||
|
||||
// 1. sub_second_cadence
|
||||
if (tradesList.Count >= 50)
|
||||
{
|
||||
var intervals = new List<double>();
|
||||
for (int i = 1; i < tradesList.Count; i++)
|
||||
{
|
||||
intervals.Add((tradesList[i].ExecutedAt - tradesList[i - 1].ExecutedAt).TotalSeconds);
|
||||
}
|
||||
|
||||
intervals.Sort();
|
||||
var medianInterval = intervals[intervals.Count / 2];
|
||||
|
||||
if (medianInterval < 2.0)
|
||||
{
|
||||
traits.Add(("sub_second_cadence", (decimal)medianInterval));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. always_on_24_7
|
||||
var last7dTrades = tradesList.Where(t => t.ExecutedAt >= now.AddDays(-7)).ToList();
|
||||
if (last7dTrades.Count >= 200)
|
||||
{
|
||||
double maxGapHours = 0;
|
||||
for (int i = 1; i < last7dTrades.Count; i++)
|
||||
{
|
||||
var gap = (last7dTrades[i].ExecutedAt - last7dTrades[i - 1].ExecutedAt).TotalHours;
|
||||
if (gap > maxGapHours) maxGapHours = gap;
|
||||
}
|
||||
if (maxGapHours < 4.0)
|
||||
{
|
||||
traits.Add(("always_on_24_7", (decimal)maxGapHours));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. uniform_sizes
|
||||
var last200 = tradesList.TakeLast(200).ToList();
|
||||
if (last200.Count >= 10)
|
||||
{
|
||||
var sizes = last200.Select(t => t.Size).Where(s => s > 0).ToList();
|
||||
if (sizes.Count > 0)
|
||||
{
|
||||
var mean = sizes.Average();
|
||||
var stdDev = (decimal)Math.Sqrt((double)sizes.Sum(s => (s - mean) * (s - mean)) / sizes.Count);
|
||||
if (mean > 0 && stdDev / mean < 0.1m)
|
||||
{
|
||||
traits.Add(("uniform_sizes", stdDev / mean));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. round_amounts
|
||||
var amounts = tradesList.Select(t => t.Amount).ToList();
|
||||
if (amounts.Count >= 10)
|
||||
{
|
||||
var targetAmounts = new[] { 1m, 5m, 10m, 20m, 25m, 50m, 100m, 250m, 500m, 1000m };
|
||||
int roundCount = 0;
|
||||
foreach (var a in amounts)
|
||||
{
|
||||
if (targetAmounts.Any(ta => Math.Abs(a - ta) <= ta * 0.01m))
|
||||
{
|
||||
roundCount++;
|
||||
}
|
||||
}
|
||||
if ((decimal)roundCount / amounts.Count > 0.6m)
|
||||
{
|
||||
traits.Add(("round_amounts", (decimal)roundCount / amounts.Count));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. uses_split_merge
|
||||
int splitMergeCount = tradesList.Count(t => t.Side == TradeSide.Split || t.Side == TradeSide.Merge);
|
||||
if (tradesList.Count > 0 && (decimal)splitMergeCount / tradesList.Count > 0.1m)
|
||||
{
|
||||
traits.Add(("uses_split_merge", (decimal)splitMergeCount / tradesList.Count));
|
||||
}
|
||||
|
||||
// 6. both_sides_same_market
|
||||
var marketsWithBothSides = tradesList
|
||||
.GroupBy(t => t.MarketId)
|
||||
.Count(g => g.Any(t => t.Side == TradeSide.Buy) && g.Any(t => t.Side == TradeSide.Sell));
|
||||
var totalMarkets = tradesList.Select(t => t.MarketId).Distinct().Count();
|
||||
if (totalMarkets > 0 && (decimal)marketsWithBothSides / totalMarkets > 0.2m)
|
||||
{
|
||||
traits.Add(("both_sides_same_market", (decimal)marketsWithBothSides / totalMarkets));
|
||||
}
|
||||
|
||||
// 7. resolution_farming
|
||||
var buys = tradesList.Where(t => t.Side == TradeSide.Buy).ToList();
|
||||
if (buys.Count >= 10)
|
||||
{
|
||||
// For pure function on Trades, we use price for now as an approximation.
|
||||
int farmingBuys = buys.Count(b => b.Price >= 0.93m);
|
||||
if ((decimal)farmingBuys / buys.Count > 0.3m)
|
||||
{
|
||||
traits.Add(("resolution_farming", (decimal)farmingBuys / buys.Count));
|
||||
}
|
||||
}
|
||||
|
||||
// 8. longshot_buyer
|
||||
if (buys.Count >= 10)
|
||||
{
|
||||
int longshotBuys = buys.Count(b => b.Price <= 0.10m);
|
||||
if ((decimal)longshotBuys / buys.Count > 0.3m)
|
||||
{
|
||||
traits.Add(("longshot_buyer", (decimal)longshotBuys / buys.Count));
|
||||
}
|
||||
}
|
||||
|
||||
// 9. scalper
|
||||
// Approximate holding duration: time between first buy and last sell per market
|
||||
var holdDurations = new List<double>();
|
||||
var marketGroups = tradesList.GroupBy(t => t.MarketId);
|
||||
foreach (var mg in marketGroups)
|
||||
{
|
||||
var firstBuy = mg.Where(t => t.Side == TradeSide.Buy).OrderBy(t => t.ExecutedAt).FirstOrDefault();
|
||||
var lastSell = mg.Where(t => t.Side == TradeSide.Sell).OrderByDescending(t => t.ExecutedAt).FirstOrDefault();
|
||||
if (firstBuy != null && lastSell != null && lastSell.ExecutedAt > firstBuy.ExecutedAt)
|
||||
{
|
||||
holdDurations.Add((lastSell.ExecutedAt - firstBuy.ExecutedAt).TotalHours);
|
||||
}
|
||||
}
|
||||
if (holdDurations.Count > 0)
|
||||
{
|
||||
holdDurations.Sort();
|
||||
var medianHold = holdDurations[holdDurations.Count / 2];
|
||||
if (medianHold < 1.0)
|
||||
{
|
||||
traits.Add(("scalper", (decimal)medianHold));
|
||||
}
|
||||
}
|
||||
|
||||
// 10. holds_to_resolution
|
||||
// If a position was resolved (realizedPnl != 0 or SharesHeld == 0 with a Redeem)
|
||||
var resolvedPositions = positions.Where(p => p.MarketOutcome?.Market?.IsResolved == true).ToList();
|
||||
if (resolvedPositions.Count > 0)
|
||||
{
|
||||
int heldToRes = resolvedPositions.Count(p => p.AvgCost > 0 && p.RealizedPnl != 0 && p.SharesHeld == 0);
|
||||
if ((decimal)heldToRes / resolvedPositions.Count > 0.7m)
|
||||
{
|
||||
traits.Add(("holds_to_resolution", (decimal)heldToRes / resolvedPositions.Count));
|
||||
}
|
||||
}
|
||||
|
||||
// 11. fresh_wallet
|
||||
var firstTrade = tradesList.First();
|
||||
var ageDays = (now - firstTrade.ExecutedAt).TotalDays;
|
||||
if (ageDays < 30)
|
||||
{
|
||||
traits.Add(("fresh_wallet", (decimal)ageDays));
|
||||
}
|
||||
|
||||
// 12. stable_stake_fraction
|
||||
if (trader.Analytics?.EstimatedBankroll > 0 && amounts.Count >= 10)
|
||||
{
|
||||
var bankroll = trader.Analytics.EstimatedBankroll;
|
||||
var fractions = amounts.Select(a => a / bankroll).ToList();
|
||||
var meanF = fractions.Average();
|
||||
var stdDevF = (decimal)Math.Sqrt((double)fractions.Sum(f => (f - meanF) * (f - meanF)) / fractions.Count);
|
||||
if (meanF > 0 && stdDevF / meanF < 0.5m)
|
||||
{
|
||||
traits.Add(("stable_stake_fraction", stdDevF / meanF));
|
||||
}
|
||||
}
|
||||
|
||||
// 13. possible_insider
|
||||
bool isResolutionFarmer = traits.Any(t => t.Item1 == "resolution_farming");
|
||||
var resolvedMarkets = positions
|
||||
.Where(p => p.MarketOutcome?.Market?.IsResolved == true)
|
||||
.GroupBy(p => p.MarketOutcome!.MarketId)
|
||||
.ToList();
|
||||
|
||||
if (resolvedMarkets.Count >= 5 && resolvedMarkets.Count <= 100 && !isResolutionFarmer)
|
||||
{
|
||||
var bankroll = trader.Analytics?.EstimatedBankroll ?? 0;
|
||||
var avgAmount = tradesList.Select(t => t.Amount).DefaultIfEmpty(0).Average();
|
||||
var tradesPerDay = tradesList.Count / Math.Max(1, (now - firstTrade.ExecutedAt).TotalDays);
|
||||
|
||||
if ((avgAmount >= 500 || (bankroll > 0 && avgAmount >= bankroll * 0.1m)) && tradesPerDay < 5)
|
||||
{
|
||||
// Core metric "Market Surprise": L = Product(p(won)) * Product((1-p)(lost))
|
||||
// Value = -log10(L)
|
||||
double logL = 0;
|
||||
int wonCount = 0;
|
||||
var wonVwaps = new List<decimal>();
|
||||
|
||||
foreach (var rm in resolvedMarkets)
|
||||
{
|
||||
// p = entry VWAP. Approximate with AvgCost.
|
||||
var pos = rm.FirstOrDefault();
|
||||
if (pos == null || pos.AvgCost <= 0) continue;
|
||||
|
||||
double p = (double)pos.AvgCost;
|
||||
bool won = pos.RealizedPnl > 0 || (pos.MarketOutcome.Market.ResolutionOutcome == pos.MarketOutcome.Label);
|
||||
|
||||
if (won)
|
||||
{
|
||||
logL += Math.Log10(p);
|
||||
wonCount++;
|
||||
wonVwaps.Add(pos.AvgCost);
|
||||
}
|
||||
else
|
||||
{
|
||||
logL += Math.Log10(Math.Max(1e-6, 1.0 - p));
|
||||
}
|
||||
}
|
||||
|
||||
decimal winRate = resolvedMarkets.Count > 0 ? (decimal)wonCount / resolvedMarkets.Count : 0;
|
||||
decimal avgWonVwap = wonVwaps.Count > 0 ? wonVwaps.Average() : 0;
|
||||
decimal surpriseValue = (decimal)(-logL);
|
||||
|
||||
if (winRate >= 0.85m && avgWonVwap <= 0.70m && surpriseValue >= 3m)
|
||||
{
|
||||
traits.Add(("possible_insider", surpriseValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 14. Market Return Profile Traits (D2c)
|
||||
if (trader.Analytics != null && trader.Analytics.MedianWinReturnPct != 0)
|
||||
{
|
||||
int wonMarkets = resolvedMarkets.Count(rm => rm.Sum(p => p.RealizedPnl) > 0);
|
||||
|
||||
if (trader.Analytics.MedianWinReturnPct < 10m && wonMarkets >= 20)
|
||||
{
|
||||
traits.Add(("thin_margin_wins", trader.Analytics.MedianWinReturnPct));
|
||||
}
|
||||
|
||||
if (trader.Analytics.MedianWinReturnPct > 100m && wonMarkets >= 5)
|
||||
{
|
||||
traits.Add(("high_payoff_wins", trader.Analytics.MedianWinReturnPct));
|
||||
}
|
||||
}
|
||||
|
||||
// sells_at_loss (stop_loss_ratio)
|
||||
var sells = tradesList.Where(t => t.Side == TradeSide.Sell && t.Price > 0).ToList();
|
||||
int stopLossCount = 0;
|
||||
int totalValidSells = 0;
|
||||
foreach (var sell in sells)
|
||||
{
|
||||
var pos = positions.FirstOrDefault(p => p.MarketOutcomeId == sell.MarketOutcomeId);
|
||||
if (pos != null && pos.AvgCost > 0)
|
||||
{
|
||||
totalValidSells++;
|
||||
if (sell.Price <= pos.AvgCost * 0.9m)
|
||||
{
|
||||
stopLossCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (totalValidSells > 0)
|
||||
{
|
||||
decimal stopLossRatio = (decimal)stopLossCount / totalValidSells;
|
||||
if (stopLossRatio > 0.15m)
|
||||
{
|
||||
traits.Add(("sells_at_loss", stopLossRatio));
|
||||
}
|
||||
}
|
||||
|
||||
// days_active
|
||||
var daysActive = (now - firstTrade.ExecutedAt).TotalDays;
|
||||
traits.Add(("days_active", (decimal)daysActive));
|
||||
|
||||
// trades_last_30_days
|
||||
int tradesLast30d = tradesList.Count(t => (now - t.ExecutedAt).TotalDays <= 30);
|
||||
traits.Add(("trades_last_30_days", (decimal)tradesLast30d));
|
||||
|
||||
// martingale_pattern
|
||||
var chronologicalMarkets = resolvedMarkets
|
||||
.Select(g => new {
|
||||
MarketId = g.Key,
|
||||
Won = g.Sum(p => p.RealizedPnl) > 0 || g.Any(p => p.MarketOutcome?.Market?.ResolutionOutcome == p.MarketOutcome?.Label),
|
||||
Invested = tradesList.Where(t => t.DbMarketId == g.Key && t.Side == TradeSide.Buy).Sum(t => t.Amount),
|
||||
ResolvedTime = g.First().MarketOutcome?.Market?.EndDate ?? DateTime.MinValue
|
||||
})
|
||||
.Where(x => x.Invested > 0)
|
||||
.OrderBy(x => x.ResolvedTime)
|
||||
.ToList();
|
||||
|
||||
if (chronologicalMarkets.Count >= 20)
|
||||
{
|
||||
var stakesAfterWin = new List<decimal>();
|
||||
var stakesAfterLoss = new List<decimal>();
|
||||
for (int i = 1; i < chronologicalMarkets.Count; i++)
|
||||
{
|
||||
if (chronologicalMarkets[i-1].Won)
|
||||
{
|
||||
stakesAfterWin.Add(chronologicalMarkets[i].Invested);
|
||||
}
|
||||
else
|
||||
{
|
||||
stakesAfterLoss.Add(chronologicalMarkets[i].Invested);
|
||||
}
|
||||
}
|
||||
if (stakesAfterWin.Count > 0 && stakesAfterLoss.Count > 0)
|
||||
{
|
||||
decimal avgAfterWin = stakesAfterWin.Average();
|
||||
decimal avgAfterLoss = stakesAfterLoss.Average();
|
||||
if (avgAfterWin > 0)
|
||||
{
|
||||
decimal ratio = avgAfterLoss / avgAfterWin;
|
||||
if (ratio >= 1.5m)
|
||||
{
|
||||
traits.Add(("martingale_pattern", ratio));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return traits;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,25 @@ public class TraderAnalytics
|
||||
/// <summary>The copyability dimension (alpha-decay, sizing consistency) of the copytrading score (0-100). Generated by the Estimator.</summary>
|
||||
public decimal CopytradingCopyabilityScore { get; set; }
|
||||
|
||||
// D2c Market Return Metrics
|
||||
public decimal MedianWinReturnPct { get; set; }
|
||||
public decimal AvgWinReturnPct { get; set; }
|
||||
public decimal MedianLossReturnPct { get; set; }
|
||||
public decimal AvgLossReturnPct { get; set; }
|
||||
public decimal? ProfitFactor { get; set; }
|
||||
|
||||
// E3 Fingerprints
|
||||
public string? PriceBandProfileJson { get; set; }
|
||||
public decimal MedianHoldDurationHours { get; set; }
|
||||
public decimal P50PositionSize { get; set; }
|
||||
public decimal P90PositionSize { get; set; }
|
||||
public decimal TradesPerWeek { get; set; }
|
||||
|
||||
// E5 Copyability Aggregates
|
||||
public decimal MedianMarketVolumeUsd { get; set; }
|
||||
public decimal MedianPostFillDriftPct { get; set; }
|
||||
public decimal NetEdgeAfterFeesPct { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Trader Trader { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,12 @@ public class Trade
|
||||
/// <summary>Indicates if high-res price context was fetched.</summary>
|
||||
public bool IsContextEnriched { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For aggregated trades (compacted history or HF trader hourly bucket), this stores
|
||||
/// the number of original trades that were grouped into this single row. Null means 1.
|
||||
/// </summary>
|
||||
public int? AggregatedCount { get; set; }
|
||||
|
||||
// ── Transient (not persisted) ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -87,7 +93,13 @@ public class Trade
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public string? TransientWallet { get; set; }
|
||||
[NotMapped] public string? TransientDisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Transient reference to the trader's display name from the platform, used to opportunistically
|
||||
/// update the local name if it's currently a placeholder or empty.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public string? TransientDisplayName { get; set; }
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────
|
||||
public Trader Trader { get; set; } = null!;
|
||||
|
||||
@@ -34,6 +34,9 @@ public class Trader
|
||||
/// <summary>Whether the trader shows bot-like behavior.</summary>
|
||||
public bool IsSuspectedBot { get; set; }
|
||||
|
||||
/// <summary>Qualification status for copy-trading master.</summary>
|
||||
public MasterStatus MasterStatus { get; set; } = MasterStatus.None;
|
||||
|
||||
/// <summary>Manual priority override (null = use calculated score).</summary>
|
||||
public int? ManualPriorityOverride { get; set; }
|
||||
|
||||
@@ -58,6 +61,9 @@ public class Trader
|
||||
/// <summary>Win rate as a percentage (0-100).</summary>
|
||||
public decimal WinRate { get; set; }
|
||||
|
||||
/// <summary>Defines how this trader's data is ingested (based on activity level).</summary>
|
||||
public IngestMode IngestMode { get; set; } = IngestMode.Full;
|
||||
|
||||
/// <summary>AI-generated strategy summary based on trade history.</summary>
|
||||
public string? AiStrategySummary { get; set; }
|
||||
|
||||
@@ -77,4 +83,5 @@ public class Trader
|
||||
public ICollection<WatchlistEntry> WatchlistEntries { get; set; } = new List<WatchlistEntry>();
|
||||
public ICollection<TraderPosition> Positions { get; set; } = new List<TraderPosition>();
|
||||
public ICollection<TraderCategoryPerformance> CategoryPerformances { get; set; } = new List<TraderCategoryPerformance>();
|
||||
public ICollection<TraderTrait> Traits { get; set; } = new List<TraderTrait>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Predictalytics.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a heuristically derived strategy trait for a trader.
|
||||
/// </summary>
|
||||
public class TraderTrait
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int TraderId { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string Trait { get; set; } = string.Empty;
|
||||
|
||||
public decimal Value { get; set; }
|
||||
|
||||
public DateTime ComputedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public virtual Trader Trader { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Predictalytics.Domain.Entities;
|
||||
|
||||
public class TraderWindowMetrics
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int TraderId { get; set; }
|
||||
public Trader? Trader { get; set; }
|
||||
|
||||
public DateTime WindowStart { get; set; }
|
||||
public DateTime WindowEnd { get; set; }
|
||||
|
||||
public int ClosedMarkets { get; set; }
|
||||
public decimal WinRate { get; set; }
|
||||
|
||||
public decimal AvgReturnPct { get; set; }
|
||||
public decimal MedianWinReturnPct { get; set; }
|
||||
public decimal MedianLossReturnPct { get; set; }
|
||||
public decimal? ProfitFactor { get; set; }
|
||||
|
||||
public DateTime ComputedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Predictalytics.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Defines how a trader's data is ingested based on their activity level.
|
||||
/// </summary>
|
||||
public enum IngestMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Default mode. All individual trades are fetched and processed.
|
||||
/// </summary>
|
||||
Full = 0,
|
||||
|
||||
/// <summary>
|
||||
/// For High-Frequency traders (> 100 trades/day).
|
||||
/// Trades are fetched but aggregated into hourly buckets before saving.
|
||||
/// </summary>
|
||||
Aggregated = 1,
|
||||
|
||||
/// <summary>
|
||||
/// For Ultra-High-Frequency traders (> 5000 trades/day).
|
||||
/// Trade fetching is skipped entirely. Only periodic snapshots and position polling are performed.
|
||||
/// </summary>
|
||||
SnapshotOnly = 2
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Predictalytics.Domain.Enums;
|
||||
|
||||
public enum MasterStatus
|
||||
{
|
||||
None = 0,
|
||||
Candidate = 1,
|
||||
Active = 2,
|
||||
Rejected = 3
|
||||
}
|
||||
@@ -22,6 +22,8 @@ public class AppDbContext : DbContext
|
||||
public DbSet<TraderCategoryPerformance> TraderCategoryPerformances => Set<TraderCategoryPerformance>();
|
||||
public DbSet<TradeContext> TradeContexts => Set<TradeContext>();
|
||||
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
|
||||
public DbSet<TraderTrait> TraderTraits => Set<TraderTrait>();
|
||||
public DbSet<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>();
|
||||
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
@@ -40,6 +42,30 @@ public class AppDbContext : DbContext
|
||||
.HasForeignKey<TraderScore>(s => s.TraderId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// TraderTrait
|
||||
mb.Entity<TraderTrait>(e =>
|
||||
{
|
||||
e.HasKey(t => t.Id);
|
||||
e.HasIndex(t => new { t.TraderId, t.Trait }).IsUnique();
|
||||
e.Property(t => t.Value).HasPrecision(18, 4);
|
||||
e.HasOne(t => t.Trader).WithMany(tr => tr.Traits)
|
||||
.HasForeignKey(t => t.TraderId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// TraderWindowMetrics
|
||||
mb.Entity<TraderWindowMetrics>(e =>
|
||||
{
|
||||
e.HasKey(t => t.Id);
|
||||
e.HasIndex(t => new { t.TraderId, t.WindowStart, t.WindowEnd }).IsUnique();
|
||||
e.Property(t => t.WinRate).HasPrecision(8, 4);
|
||||
e.Property(t => t.AvgReturnPct).HasPrecision(18, 4);
|
||||
e.Property(t => t.MedianWinReturnPct).HasPrecision(18, 4);
|
||||
e.Property(t => t.MedianLossReturnPct).HasPrecision(18, 4);
|
||||
e.Property(t => t.ProfitFactor).HasPrecision(18, 4);
|
||||
e.HasOne(t => t.Trader).WithMany()
|
||||
.HasForeignKey(t => t.TraderId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Trade
|
||||
mb.Entity<Trade>(e =>
|
||||
{
|
||||
@@ -193,6 +219,22 @@ public class AppDbContext : DbContext
|
||||
e.Property(a => a.WinRate7d).HasPrecision(8, 4);
|
||||
e.Property(a => a.PnL24h).HasPrecision(18, 4);
|
||||
e.Property(a => a.WinRate24h).HasPrecision(8, 4);
|
||||
|
||||
// D2c & E3 & E5
|
||||
e.Property(a => a.MedianWinReturnPct).HasPrecision(18, 4);
|
||||
e.Property(a => a.AvgWinReturnPct).HasPrecision(18, 4);
|
||||
e.Property(a => a.MedianLossReturnPct).HasPrecision(18, 4);
|
||||
e.Property(a => a.AvgLossReturnPct).HasPrecision(18, 4);
|
||||
e.Property(a => a.ProfitFactor).HasPrecision(18, 4);
|
||||
|
||||
e.Property(a => a.MedianHoldDurationHours).HasPrecision(18, 4);
|
||||
e.Property(a => a.P50PositionSize).HasPrecision(18, 4);
|
||||
e.Property(a => a.P90PositionSize).HasPrecision(18, 4);
|
||||
e.Property(a => a.TradesPerWeek).HasPrecision(18, 4);
|
||||
|
||||
e.Property(a => a.MedianMarketVolumeUsd).HasPrecision(18, 4);
|
||||
e.Property(a => a.MedianPostFillDriftPct).HasPrecision(18, 4);
|
||||
e.Property(a => a.NetEdgeAfterFeesPct).HasPrecision(18, 4);
|
||||
});
|
||||
|
||||
// MarketAnalytics
|
||||
|
||||
@@ -70,14 +70,14 @@ public class TradeRepository : ITradeRepository
|
||||
|
||||
foreach (var chunk in tradeList.Chunk(500))
|
||||
{
|
||||
var sb = new System.Text.StringBuilder("INSERT IGNORE INTO Trades (PlatformTradeId, MarketId, AssetId, Outcome, Side, Price, Size, Amount, ExecutedAt, TransactionHash, TraderId, MarketOutcomeId, DbMarketId, Platform, IsContextEnriched) VALUES ");
|
||||
var sb = new System.Text.StringBuilder("INSERT INTO Trades (PlatformTradeId, MarketId, AssetId, Outcome, Side, Price, Size, Amount, ExecutedAt, TransactionHash, TraderId, MarketOutcomeId, DbMarketId, Platform, IsContextEnriched, AggregatedCount) VALUES ");
|
||||
var parameters = new List<object>();
|
||||
|
||||
for (int i = 0; i < chunk.Length; i++)
|
||||
{
|
||||
var t = chunk[i];
|
||||
int pIdx = i * 15;
|
||||
sb.Append($"({{{pIdx}}}, {{{pIdx + 1}}}, {{{pIdx + 2}}}, {{{pIdx + 3}}}, {{{pIdx + 4}}}, {{{pIdx + 5}}}, {{{pIdx + 6}}}, {{{pIdx + 7}}}, {{{pIdx + 8}}}, {{{pIdx + 9}}}, {{{pIdx + 10}}}, {{{pIdx + 11}}}, {{{pIdx + 12}}}, {{{pIdx + 13}}}, {{{pIdx + 14}}})");
|
||||
int pIdx = i * 16;
|
||||
sb.Append($"({{{pIdx}}}, {{{pIdx + 1}}}, {{{pIdx + 2}}}, {{{pIdx + 3}}}, {{{pIdx + 4}}}, {{{pIdx + 5}}}, {{{pIdx + 6}}}, {{{pIdx + 7}}}, {{{pIdx + 8}}}, {{{pIdx + 9}}}, {{{pIdx + 10}}}, {{{pIdx + 11}}}, {{{pIdx + 12}}}, {{{pIdx + 13}}}, {{{pIdx + 14}}}, {{{pIdx + 15}}})");
|
||||
|
||||
if (i < chunk.Length - 1)
|
||||
sb.Append(", ");
|
||||
@@ -97,8 +97,12 @@ public class TradeRepository : ITradeRepository
|
||||
parameters.Add(t.DbMarketId ?? (object?)null);
|
||||
parameters.Add((int)t.Platform);
|
||||
parameters.Add(t.IsContextEnriched);
|
||||
parameters.Add(t.AggregatedCount ?? (object?)null);
|
||||
}
|
||||
|
||||
// For Aggregated Trades, we want UPSERT logic to update size, amount and VWAP
|
||||
sb.Append(" ON DUPLICATE KEY UPDATE Price=VALUES(Price), Size=VALUES(Size), Amount=VALUES(Amount), AggregatedCount=VALUES(AggregatedCount);");
|
||||
|
||||
int maxRetries = 3;
|
||||
var backoffs = new[] { 250, 500, 1000 };
|
||||
for (int retry = 0; retry <= maxRetries; retry++)
|
||||
|
||||
@@ -17,6 +17,7 @@ public class TraderRepository : ITraderRepository
|
||||
.Include(t => t.CurrentScore)
|
||||
.Include(t => t.Analytics)
|
||||
.Include(t => t.CategoryPerformances)
|
||||
.Include(t => t.Traits)
|
||||
.FirstOrDefaultAsync(t => t.Id == id, ct);
|
||||
}
|
||||
|
||||
@@ -29,6 +30,7 @@ public class TraderRepository : ITraderRepository
|
||||
var q = _db.Traders
|
||||
.Include(t => t.CurrentScore)
|
||||
.Include(t => t.Analytics)
|
||||
.Include(t => t.Traits)
|
||||
.AsQueryable();
|
||||
|
||||
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
|
||||
|
||||
Generated
+1103
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Predictalytics.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAggregatedCountToTrade : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AggregatedCount",
|
||||
table: "Trades",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AggregatedCount",
|
||||
table: "Trades");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1147
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Predictalytics.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTraderTraits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TraderTraits",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
TraderId = table.Column<int>(type: "int", nullable: false),
|
||||
Trait = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Value = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
ComputedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TraderTraits", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TraderTraits_Traders_TraderId",
|
||||
column: x => x.TraderId,
|
||||
principalTable: "Traders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TraderTraits_TraderId_Trait",
|
||||
table: "TraderTraits",
|
||||
columns: new[] { "TraderId", "Trait" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TraderTraits");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1162
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Predictalytics.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMarketReturnMetrics : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "AvgLossReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(65,30)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "AvgWinReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(65,30)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "MedianLossReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(65,30)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "MedianWinReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(65,30)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ProfitFactor",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(65,30)",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvgLossReturnPct",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvgWinReturnPct",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MedianLossReturnPct",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MedianWinReturnPct",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProfitFactor",
|
||||
table: "TraderAnalytics");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Predictalytics.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTraderIngestMode : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "IngestMode",
|
||||
table: "Traders",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IngestMode",
|
||||
table: "Traders");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1230
File diff suppressed because it is too large
Load Diff
+69
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Predictalytics.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMasterStatusAndWindowMetrics : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MasterStatus",
|
||||
table: "Traders",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TraderWindowMetrics",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
TraderId = table.Column<int>(type: "int", nullable: false),
|
||||
WindowStart = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
WindowEnd = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
ClosedMarkets = table.Column<int>(type: "int", nullable: false),
|
||||
WinRate = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
|
||||
AvgReturnPct = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
MedianWinReturnPct = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
MedianLossReturnPct = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
ProfitFactor = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
|
||||
ComputedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TraderWindowMetrics", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TraderWindowMetrics_Traders_TraderId",
|
||||
column: x => x.TraderId,
|
||||
principalTable: "Traders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TraderWindowMetrics_TraderId_WindowStart_WindowEnd",
|
||||
table: "TraderWindowMetrics",
|
||||
columns: new[] { "TraderId", "WindowStart", "WindowEnd" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TraderWindowMetrics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MasterStatus",
|
||||
table: "Traders");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1266
File diff suppressed because it is too large
Load Diff
+222
@@ -0,0 +1,222 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Predictalytics.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTraderAnalyticsFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "ProfitFactor",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(65,30)",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "MedianWinReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(65,30)");
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "MedianLossReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(65,30)");
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "AvgWinReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(65,30)");
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "AvgLossReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(65,30)");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "MedianHoldDurationHours",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "MedianMarketVolumeUsd",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "MedianPostFillDriftPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "NetEdgeAfterFeesPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "P50PositionSize",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "P90PositionSize",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PriceBandProfileJson",
|
||||
table: "TraderAnalytics",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "TradesPerWeek",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MedianHoldDurationHours",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MedianMarketVolumeUsd",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MedianPostFillDriftPct",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NetEdgeAfterFeesPct",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "P50PositionSize",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "P90PositionSize",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PriceBandProfileJson",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TradesPerWeek",
|
||||
table: "TraderAnalytics");
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "ProfitFactor",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(65,30)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(18,4)",
|
||||
oldPrecision: 18,
|
||||
oldScale: 4,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "MedianWinReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(65,30)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(18,4)",
|
||||
oldPrecision: 18,
|
||||
oldScale: 4);
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "MedianLossReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(65,30)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(18,4)",
|
||||
oldPrecision: 18,
|
||||
oldScale: 4);
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "AvgWinReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(65,30)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(18,4)",
|
||||
oldPrecision: 18,
|
||||
oldScale: 4);
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "AvgLossReturnPct",
|
||||
table: "TraderAnalytics",
|
||||
type: "decimal(65,30)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(18,4)",
|
||||
oldPrecision: 18,
|
||||
oldScale: 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,6 +419,9 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int?>("AggregatedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
@@ -564,6 +567,9 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("varchar(256)");
|
||||
|
||||
b.Property<int>("IngestMode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsAutoDiscovered")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
@@ -588,6 +594,9 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
b.Property<int?>("ManualPriorityOverride")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("MasterStatus")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
@@ -629,6 +638,14 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
b.Property<int>("TraderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("AvgLossReturnPct")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("AvgWinReturnPct")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("CopytradingCopyabilityScore")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
@@ -647,6 +664,30 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
b.Property<DateTime>("LastCalculatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal>("MedianHoldDurationHours")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("MedianLossReturnPct")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("MedianMarketVolumeUsd")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("MedianPostFillDriftPct")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("MedianWinReturnPct")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("NetEdgeAfterFeesPct")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("OverallPnL")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
@@ -655,6 +696,14 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
.HasPrecision(8, 4)
|
||||
.HasColumnType("decimal(8,4)");
|
||||
|
||||
b.Property<decimal>("P50PositionSize")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("P90PositionSize")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("PnL24h")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
@@ -667,9 +716,20 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<string>("PriceBandProfileJson")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<decimal?>("ProfitFactor")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<int>("Trades30d")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("TradesPerWeek")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("WinRate24h")
|
||||
.HasPrecision(8, 4)
|
||||
.HasColumnType("decimal(8,4)");
|
||||
@@ -853,6 +913,88 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
b.ToTable("TraderScores");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderTrait", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("ComputedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("TraderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Trait")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TraderId", "Trait")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TraderTraits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderWindowMetrics", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal>("AvgReturnPct")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<int>("ClosedMarkets")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("ComputedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal>("MedianLossReturnPct")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal>("MedianWinReturnPct")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<decimal?>("ProfitFactor")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<int>("TraderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("WinRate")
|
||||
.HasPrecision(8, 4)
|
||||
.HasColumnType("decimal(8,4)");
|
||||
|
||||
b.Property<DateTime>("WindowEnd")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("WindowStart")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TraderId", "WindowStart", "WindowEnd")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TraderWindowMetrics");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1049,6 +1191,28 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
b.Navigation("Trader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderTrait", b =>
|
||||
{
|
||||
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||
.WithMany("Traits")
|
||||
.HasForeignKey("TraderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Trader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderWindowMetrics", b =>
|
||||
{
|
||||
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||
.WithMany()
|
||||
.HasForeignKey("TraderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Trader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
|
||||
{
|
||||
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||
@@ -1089,6 +1253,8 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
|
||||
b.Navigation("Trades");
|
||||
|
||||
b.Navigation("Traits");
|
||||
|
||||
b.Navigation("WatchlistEntries");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
|
||||
@@ -353,15 +353,23 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
analytics.PnL30d = overallPnl - (snapshot30d?.TotalPnl ?? GetFallback(today.AddDays(-30)));
|
||||
|
||||
// Count Trades30d
|
||||
analytics.Trades30d = trades.Count(t => t.ExecutedAt >= cutoff30d);
|
||||
analytics.Trades30d = trades.Where(t => t.ExecutedAt >= cutoff30d).Sum(t => t.AggregatedCount ?? 1);
|
||||
|
||||
// Calculate Win Rate on Market level
|
||||
var (winRateOverall, winRate30d, winRate7d, winRate24h) = CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h);
|
||||
// Calculate Win Rate and Return Pcts on Market level
|
||||
var (winRateOverall, winRate30d, winRate7d, winRate24h,
|
||||
medianWin, avgWin, medianLoss, avgLoss, profitFactor) =
|
||||
CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h);
|
||||
|
||||
analytics.OverallWinRate = winRateOverall;
|
||||
analytics.WinRate30d = winRate30d;
|
||||
analytics.WinRate7d = winRate7d;
|
||||
analytics.WinRate24h = winRate24h;
|
||||
|
||||
analytics.MedianWinReturnPct = medianWin;
|
||||
analytics.AvgWinReturnPct = avgWin;
|
||||
analytics.MedianLossReturnPct = medianLoss;
|
||||
analytics.AvgLossReturnPct = avgLoss;
|
||||
analytics.ProfitFactor = profitFactor;
|
||||
analytics.LastCalculatedAt = DateTime.UtcNow;
|
||||
|
||||
// Sync back to Trader record for quick sorting / UI display
|
||||
@@ -371,7 +379,7 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
// increment counters drift (INSERT IGNORE, deletions, historic imports) and
|
||||
// produced impossible states like Trades30d > TotalTrades. Compacted rows
|
||||
// count as 1 until an AggregatedCount column exists (FIXPLAN Teil D).
|
||||
trader.TotalTrades = trades.Count;
|
||||
trader.TotalTrades = trades.Sum(t => t.AggregatedCount ?? 1);
|
||||
if (trades.Count > 0 || trader.LastTradesUpdatedAt != null)
|
||||
{
|
||||
trader.LastAnalyzedAt = DateTime.UtcNow;
|
||||
@@ -409,7 +417,10 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
trader.DisplayName, traderId, totalRealizedPnl, totalUnrealizedPnl, overallPnl, winRateOverall);
|
||||
}
|
||||
|
||||
private static (decimal Overall, decimal WinRate30d, decimal WinRate7d, decimal WinRate24h) CalculateMarketWinRates(
|
||||
private static (decimal Overall, decimal WinRate30d, decimal WinRate7d, decimal WinRate24h,
|
||||
decimal MedianWinReturnPct, decimal AvgWinReturnPct,
|
||||
decimal MedianLossReturnPct, decimal AvgLossReturnPct,
|
||||
decimal? ProfitFactor) CalculateMarketWinRates(
|
||||
List<Trade> trades,
|
||||
Dictionary<int, TraderPosition> finalPositions,
|
||||
DateTime cutoff30d,
|
||||
@@ -426,6 +437,11 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
int closedMarkets7d = 0, wins7d = 0;
|
||||
int closedMarkets24h = 0, wins24h = 0;
|
||||
|
||||
var winReturns = new List<decimal>();
|
||||
var lossReturns = new List<decimal>();
|
||||
decimal totalGrossWins = 0;
|
||||
decimal totalGrossLosses = 0;
|
||||
|
||||
foreach (var marketGroup in tradesByMarket)
|
||||
{
|
||||
var outcomeIds = marketGroup
|
||||
@@ -455,6 +471,22 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
}
|
||||
}
|
||||
|
||||
var invested = marketGroup.Where(t => t.Side == TradeSide.Buy).Sum(t => t.Amount);
|
||||
if (invested > 0)
|
||||
{
|
||||
var returnPct = marketPnl / invested * 100m;
|
||||
if (returnPct > 0)
|
||||
{
|
||||
winReturns.Add(returnPct);
|
||||
totalGrossWins += marketPnl;
|
||||
}
|
||||
else if (returnPct < 0)
|
||||
{
|
||||
lossReturns.Add(returnPct);
|
||||
totalGrossLosses += Math.Abs(marketPnl);
|
||||
}
|
||||
}
|
||||
|
||||
var lastTradeTime = marketGroup.Max(t => t.ExecutedAt);
|
||||
var isWin = marketPnl > 0;
|
||||
|
||||
@@ -484,7 +516,38 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
var winRate7d = closedMarkets7d > 0 ? (decimal)wins7d / closedMarkets7d * 100m : 0m;
|
||||
var winRate24h = closedMarkets24h > 0 ? (decimal)wins24h / closedMarkets24h * 100m : 0m;
|
||||
|
||||
return (winRateOverall, winRate30d, winRate7d, winRate24h);
|
||||
decimal medianWin = 0, avgWin = 0, medianLoss = 0, avgLoss = 0;
|
||||
decimal? profitFactor = null;
|
||||
|
||||
if (winReturns.Count > 0)
|
||||
{
|
||||
winReturns.Sort();
|
||||
avgWin = winReturns.Average();
|
||||
medianWin = winReturns.Count % 2 != 0
|
||||
? winReturns[winReturns.Count / 2]
|
||||
: (winReturns[(winReturns.Count - 1) / 2] + winReturns[winReturns.Count / 2]) / 2m;
|
||||
}
|
||||
|
||||
if (lossReturns.Count > 0)
|
||||
{
|
||||
lossReturns.Sort();
|
||||
avgLoss = lossReturns.Average();
|
||||
medianLoss = lossReturns.Count % 2 != 0
|
||||
? lossReturns[lossReturns.Count / 2]
|
||||
: (lossReturns[(lossReturns.Count - 1) / 2] + lossReturns[lossReturns.Count / 2]) / 2m;
|
||||
}
|
||||
|
||||
if (totalGrossLosses > 0)
|
||||
{
|
||||
profitFactor = totalGrossWins / totalGrossLosses;
|
||||
}
|
||||
else if (totalGrossWins > 0)
|
||||
{
|
||||
// Edge case: trader only wins and has no losses
|
||||
profitFactor = null; // or theoretically infinity, but null is safer and standard
|
||||
}
|
||||
|
||||
return (winRateOverall, winRate30d, winRate7d, winRate24h, medianWin, avgWin, medianLoss, avgLoss, profitFactor);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -122,17 +122,25 @@ public class TradeHistoryWorker : BackgroundService
|
||||
|
||||
bool isDeepResync = activeJob != null && activeJob.JobType == Predictalytics.Domain.Enums.JobType.DeepResync;
|
||||
bool isInitial = !trader.IsInitialImportComplete || isDeepResync;
|
||||
_logger.LogInformation("{Trader}: Starting {Type} sync", trader.DisplayName, isDeepResync ? "DEEP RESYNC" : (isInitial ? "INITIAL FULL" : "INCREMENTAL"));
|
||||
|
||||
bool isWeeklyBiopsy = trader.IngestMode == IngestMode.SnapshotOnly && (!trader.LastTradesUpdatedAt.HasValue || (DateTime.UtcNow - trader.LastTradesUpdatedAt.Value).TotalDays >= 7);
|
||||
bool skipTradeFetch = trader.IngestMode == IngestMode.SnapshotOnly && !isWeeklyBiopsy && !isDeepResync;
|
||||
|
||||
_logger.LogInformation("{Trader}: Starting {Type} sync (Mode: {Mode})", trader.DisplayName, isDeepResync ? "DEEP RESYNC" : (isInitial ? "INITIAL FULL" : "INCREMENTAL"), trader.IngestMode);
|
||||
|
||||
if (isDeepResync)
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
|
||||
await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, "DELETE FROM Trades WHERE TraderId = {0} AND PlatformTradeId LIKE 'COMPACT_%'", t.Id);
|
||||
await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, "DELETE FROM Trades WHERE TraderId = {0} AND PlatformTradeId LIKE 'AGG_%'", t.Id);
|
||||
await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, "DELETE FROM TraderPositions WHERE TraderId = {0}", t.Id);
|
||||
}
|
||||
|
||||
IReadOnlyList<Domain.Entities.Trade> fetchedTrades;
|
||||
if (isDeepResync)
|
||||
IReadOnlyList<Domain.Entities.Trade> fetchedTrades = new List<Domain.Entities.Trade>();
|
||||
|
||||
if (!skipTradeFetch)
|
||||
{
|
||||
if (isDeepResync || isWeeklyBiopsy)
|
||||
{
|
||||
fetchedTrades = await provider.GetTradesPagedAsync(trader.PlatformUserId, 500, ct);
|
||||
}
|
||||
@@ -140,6 +148,7 @@ public class TradeHistoryWorker : BackgroundService
|
||||
{
|
||||
fetchedTrades = await provider.GetTraderTradesAsync(trader.PlatformUserId, TradesPerFetch, ct);
|
||||
}
|
||||
|
||||
var validTrades = fetchedTrades.Where(tr => !string.IsNullOrWhiteSpace(tr.PlatformTradeId)).ToList();
|
||||
|
||||
var updatedName = validTrades.FirstOrDefault(t => !string.IsNullOrEmpty(t.TransientDisplayName))?.TransientDisplayName;
|
||||
@@ -149,6 +158,45 @@ public class TradeHistoryWorker : BackgroundService
|
||||
await traderRepo.UpdateAsync(trader, ct);
|
||||
}
|
||||
|
||||
// Classification (IngestMode)
|
||||
var last500 = validTrades.OrderByDescending(t => t.ExecutedAt).Take(500).ToList();
|
||||
if (last500.Count >= 50)
|
||||
{
|
||||
var minDate = last500.Min(x => x.ExecutedAt);
|
||||
var maxDate = last500.Max(x => x.ExecutedAt);
|
||||
var days = (maxDate - minDate).TotalDays;
|
||||
if (days > 0.01)
|
||||
{
|
||||
var tradesPerDay = last500.Count / days;
|
||||
var newMode = trader.IngestMode;
|
||||
|
||||
if (tradesPerDay > 5000) newMode = IngestMode.SnapshotOnly;
|
||||
else if (tradesPerDay > 100 && trader.IngestMode == IngestMode.Full) newMode = IngestMode.Aggregated;
|
||||
else if (trader.IngestMode == IngestMode.SnapshotOnly && tradesPerDay < 2500) newMode = IngestMode.Aggregated;
|
||||
else if (trader.IngestMode == IngestMode.Aggregated && tradesPerDay < 50) newMode = IngestMode.Full;
|
||||
|
||||
if (newMode != trader.IngestMode)
|
||||
{
|
||||
_logger.LogInformation("{Trader}: IngestMode changing from {Old} to {New} (Trades/Day: {TPD:F1})", trader.DisplayName, trader.IngestMode, newMode, tradesPerDay);
|
||||
trader.IngestMode = newMode;
|
||||
await traderRepo.UpdateAsync(trader, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isWeeklyBiopsy)
|
||||
{
|
||||
// "NUR durch den TraderTraitCalculator schicken, NICHT persistieren."
|
||||
// This would require resolving markets and positions and calling TraderTraitCalculator.Compute
|
||||
// For now we skip persisting.
|
||||
fetchedTrades = new List<Domain.Entities.Trade>();
|
||||
}
|
||||
}
|
||||
|
||||
var newTrades = new List<Domain.Entities.Trade>();
|
||||
if (fetchedTrades.Count > 0)
|
||||
{
|
||||
var validTrades = fetchedTrades.Where(tr => !string.IsNullOrWhiteSpace(tr.PlatformTradeId)).ToList();
|
||||
var fetchedTradeIds = validTrades.Select(tr => tr.PlatformTradeId).ToList();
|
||||
var knownTradeIds = await tradeRepo.GetKnownPlatformTradeIdsAsync(trader.Platform, trader.Id, fetchedTradeIds, ct);
|
||||
|
||||
@@ -171,7 +219,6 @@ public class TradeHistoryWorker : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
var newTrades = new List<Domain.Entities.Trade>();
|
||||
foreach (var trade in validTrades)
|
||||
{
|
||||
if (knownTradeIds.Contains(trade.PlatformTradeId))
|
||||
@@ -212,6 +259,95 @@ public class TradeHistoryWorker : BackgroundService
|
||||
}
|
||||
newTrades.Add(trade);
|
||||
}
|
||||
}
|
||||
|
||||
if (trader.IngestMode == IngestMode.Aggregated && newTrades.Count > 0)
|
||||
{
|
||||
// Aggregate trades: Bucket (TraderId, MarketOutcomeId, Side, Stunde)
|
||||
var aggregated = new List<Domain.Entities.Trade>();
|
||||
foreach (var grp in newTrades.GroupBy(t => new { t.MarketOutcomeId, t.Side, Hour = t.ExecutedAt.ToString("yyyyMMddHH") }))
|
||||
{
|
||||
var first = grp.First();
|
||||
var totalAmount = grp.Sum(t => t.Amount);
|
||||
var totalSize = grp.Sum(t => t.Size);
|
||||
var vwap = totalSize > 0 ? totalAmount / totalSize : first.Price;
|
||||
|
||||
var aggTrade = new Domain.Entities.Trade
|
||||
{
|
||||
PlatformTradeId = $"AGG_{trader.Id}_{grp.Key.MarketOutcomeId}_{grp.Key.Side}_{grp.Key.Hour}",
|
||||
TraderId = trader.Id,
|
||||
MarketOutcomeId = first.MarketOutcomeId,
|
||||
DbMarketId = first.DbMarketId,
|
||||
MarketId = first.MarketId,
|
||||
AssetId = first.AssetId,
|
||||
Outcome = first.Outcome,
|
||||
Side = first.Side,
|
||||
Price = vwap,
|
||||
Amount = totalAmount,
|
||||
Size = totalSize,
|
||||
ExecutedAt = first.ExecutedAt,
|
||||
AggregatedCount = grp.Count()
|
||||
};
|
||||
aggregated.Add(aggTrade);
|
||||
}
|
||||
newTrades = aggregated;
|
||||
}
|
||||
|
||||
if (trader.IngestMode == IngestMode.SnapshotOnly)
|
||||
{
|
||||
// "SnapshotOnly (Tier C): Stündlich: GetTraderPositionsAsync -> TraderPositions upserten"
|
||||
if (!trader.LastTradesUpdatedAt.HasValue || (DateTime.UtcNow - trader.LastTradesUpdatedAt.Value).TotalHours >= 1)
|
||||
{
|
||||
try
|
||||
{
|
||||
var positions = await provider.GetTraderPositionsAsync(trader.PlatformUserId, ct);
|
||||
if (positions != null && positions.Count > 0)
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
|
||||
// For simplicity, just use the endpoint's positions
|
||||
var existingPos = db.TraderPositions.Where(tp => tp.TraderId == trader.Id).ToList();
|
||||
foreach(var info in positions)
|
||||
{
|
||||
int? marketOutcomeId = null;
|
||||
if (!string.IsNullOrEmpty(info.AssetId))
|
||||
{
|
||||
var outcome = await marketRepo.GetOutcomeByTokenIdAsync(info.AssetId, ct);
|
||||
if (outcome != null)
|
||||
{
|
||||
marketOutcomeId = outcome.Id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!marketOutcomeId.HasValue) continue;
|
||||
|
||||
var ex = existingPos.FirstOrDefault(ep => ep.MarketOutcomeId == marketOutcomeId.Value);
|
||||
if (ex != null)
|
||||
{
|
||||
ex.SharesHeld = info.Size;
|
||||
ex.AvgCost = info.AveragePrice;
|
||||
// RealizedPnl is built by tape replay, we skip it for SnapshotOnly
|
||||
}
|
||||
else
|
||||
{
|
||||
db.TraderPositions.Add(new Predictalytics.Domain.Entities.TraderPosition
|
||||
{
|
||||
TraderId = trader.Id,
|
||||
MarketOutcomeId = marketOutcomeId.Value,
|
||||
SharesHeld = info.Size,
|
||||
AvgCost = info.AveragePrice,
|
||||
RealizedPnl = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch positions for SnapshotOnly trader {TraderId}", trader.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newTrades.Count > 0)
|
||||
{
|
||||
|
||||
@@ -69,7 +69,7 @@ public class TradeRetentionWorker : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
var retentionDays = _config.GetValue("RetentionSettings:RetentionDays", 90);
|
||||
var retentionDays = _config.GetValue("RetentionSettings:RetentionDays", 180);
|
||||
var compactionDays = _config.GetValue("RetentionSettings:CompactionDays", 14);
|
||||
|
||||
_logger.LogInformation("🧹 TradeRetentionWorker: Starting optimization. RetentionDays={Retention}, CompactionDays={Compaction}",
|
||||
@@ -83,13 +83,13 @@ public class TradeRetentionWorker : BackgroundService
|
||||
_logger.LogInformation("Pruning trades older than {Cutoff}...", retentionCutoff);
|
||||
|
||||
var deletedTrades = await db.Trades
|
||||
.Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any())
|
||||
.Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any() && t.Trader.IngestMode == IngestMode.Full)
|
||||
.Select(t => new { t.TraderId, t.MarketOutcomeId })
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var deletedCount = await db.Trades
|
||||
.Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any())
|
||||
.Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any() && t.Trader.IngestMode == IngestMode.Full)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
if (deletedCount > 0 && deletedTrades.Any())
|
||||
@@ -217,7 +217,8 @@ public class TradeRetentionWorker : BackgroundService
|
||||
Size = totalSize,
|
||||
Amount = totalAmount,
|
||||
ExecutedAt = date.AddHours(12), // Set to noon of that day
|
||||
TransactionHash = null
|
||||
TransactionHash = null,
|
||||
AggregatedCount = list.Sum(t => t.AggregatedCount ?? 1)
|
||||
};
|
||||
|
||||
// Remove the individual trades
|
||||
|
||||
@@ -117,15 +117,36 @@ public class TraderAnalyticsWorker : BackgroundService
|
||||
{
|
||||
using var traderScope = _services.CreateScope();
|
||||
var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
|
||||
var traderRepo = traderScope.ServiceProvider.GetRequiredService<ITraderRepository>();
|
||||
|
||||
var trader = await traderRepo.GetByIdAsync(id, ct);
|
||||
if (trader != null && trader.IngestMode != Predictalytics.Domain.Enums.IngestMode.SnapshotOnly)
|
||||
{
|
||||
await pnlEngine.RecalculateTraderPositionsAsync(id, ct);
|
||||
}
|
||||
|
||||
// Run CopytradingEstimator
|
||||
var traderRepo = traderScope.ServiceProvider.GetRequiredService<ITraderRepository>();
|
||||
var db = traderScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var estimator = traderScope.ServiceProvider.GetRequiredService<ICopytradingEstimator>();
|
||||
|
||||
var trader = await traderRepo.GetByIdAsync(id, ct);
|
||||
if (trader != null)
|
||||
{
|
||||
int tradesCount = 0;
|
||||
if (trader.IngestMode == Predictalytics.Domain.Enums.IngestMode.SnapshotOnly)
|
||||
{
|
||||
var analyticsObj = trader.Analytics ?? new Predictalytics.Domain.Entities.TraderAnalytics { TraderId = trader.Id };
|
||||
analyticsObj.CopytradingScore = 0;
|
||||
analyticsObj.CopytradingQualityScore = 0;
|
||||
analyticsObj.CopytradingCopyabilityScore = 0;
|
||||
trader.Analytics = analyticsObj;
|
||||
|
||||
db.TraderTraits.RemoveRange(db.TraderTraits.Where(t => t.TraderId == id));
|
||||
db.TraderTraits.Add(new Predictalytics.Domain.Entities.TraderTrait { TraderId = id, Trait = "not_copyable_hf", Value = 1 });
|
||||
|
||||
// We still write daily snapshots below, so keep moving
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var trades = await db.Trades
|
||||
.Include(t => t.MarketOutcome).ThenInclude(o => o.Market)
|
||||
@@ -136,6 +157,7 @@ public class TraderAnalyticsWorker : BackgroundService
|
||||
.Take(1000)
|
||||
.ToListAsync(ct);
|
||||
|
||||
tradesCount = trades.Count;
|
||||
if (trades.Count > 0)
|
||||
{
|
||||
var estScores = await estimator.CalculateScoresAsync(trader, trades, ct);
|
||||
@@ -148,7 +170,162 @@ public class TraderAnalyticsWorker : BackgroundService
|
||||
|
||||
trader.Analytics = analyticsObj;
|
||||
|
||||
// Only stamp if there were actually trades to analyze
|
||||
var positions = await db.TraderPositions
|
||||
.Include(p => p.MarketOutcome).ThenInclude(o => o.Market)
|
||||
.Where(p => p.TraderId == id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var computedTraits = Predictalytics.Application.Services.TraderTraitCalculator.Compute(trader, trades, positions);
|
||||
|
||||
db.TraderTraits.RemoveRange(db.TraderTraits.Where(t => t.TraderId == id));
|
||||
foreach (var (traitName, value) in computedTraits)
|
||||
{
|
||||
db.TraderTraits.Add(new Predictalytics.Domain.Entities.TraderTrait { TraderId = id, Trait = traitName, Value = value });
|
||||
}
|
||||
|
||||
// Window Metrics
|
||||
var window1End = DateTime.UtcNow;
|
||||
var window1Start = window1End.AddDays(-60);
|
||||
var window2End = window1Start;
|
||||
var window2Start = window2End.AddDays(-120); // -180 to -60
|
||||
|
||||
db.TraderWindowMetrics.RemoveRange(db.TraderWindowMetrics.Where(w => w.TraderId == id));
|
||||
|
||||
Predictalytics.Domain.Entities.TraderWindowMetrics CalcWindow(DateTime start, DateTime end)
|
||||
{
|
||||
var windowPositions = positions.Where(p =>
|
||||
p.MarketOutcome?.Market?.IsResolved == true &&
|
||||
p.MarketOutcome.Market.EndDate >= start &&
|
||||
p.MarketOutcome.Market.EndDate < end).ToList();
|
||||
|
||||
int closedMarkets = windowPositions.Select(p => p.MarketOutcome!.MarketId).Distinct().Count();
|
||||
|
||||
var winReturnPcts = new List<decimal>();
|
||||
var lossReturnPcts = new List<decimal>();
|
||||
int wonCount = 0;
|
||||
|
||||
var marketGroups = windowPositions.GroupBy(p => p.MarketOutcome!.MarketId);
|
||||
foreach(var mg in marketGroups)
|
||||
{
|
||||
decimal realizedPnl = mg.Sum(p => p.RealizedPnl);
|
||||
// Approximate invested as total buy amount in window for this market
|
||||
decimal invested = trades.Where(t => t.DbMarketId == mg.Key && t.Side == Predictalytics.Domain.Enums.TradeSide.Buy && t.ExecutedAt >= start && t.ExecutedAt < end).Sum(t => t.Amount);
|
||||
|
||||
if (invested > 0)
|
||||
{
|
||||
decimal returnPct = (realizedPnl / invested) * 100m;
|
||||
if (returnPct > 0)
|
||||
{
|
||||
winReturnPcts.Add(returnPct);
|
||||
wonCount++;
|
||||
}
|
||||
else if (returnPct < 0)
|
||||
{
|
||||
lossReturnPcts.Add(returnPct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
winReturnPcts.Sort();
|
||||
lossReturnPcts.Sort();
|
||||
|
||||
decimal medianWin = winReturnPcts.Count > 0 ? winReturnPcts[winReturnPcts.Count / 2] : 0;
|
||||
decimal medianLoss = lossReturnPcts.Count > 0 ? lossReturnPcts[lossReturnPcts.Count / 2] : 0;
|
||||
decimal avgReturn = winReturnPcts.Concat(lossReturnPcts).DefaultIfEmpty(0).Average();
|
||||
decimal winRate = closedMarkets > 0 ? (decimal)wonCount / closedMarkets : 0;
|
||||
decimal totalWins = winReturnPcts.Sum();
|
||||
decimal totalLosses = Math.Abs(lossReturnPcts.Sum());
|
||||
decimal? pf = totalLosses > 0 ? totalWins / totalLosses : (totalWins > 0 ? 999m : null);
|
||||
|
||||
return new Predictalytics.Domain.Entities.TraderWindowMetrics
|
||||
{
|
||||
TraderId = id,
|
||||
WindowStart = start,
|
||||
WindowEnd = end,
|
||||
ClosedMarkets = closedMarkets,
|
||||
WinRate = winRate,
|
||||
AvgReturnPct = avgReturn,
|
||||
MedianWinReturnPct = medianWin,
|
||||
MedianLossReturnPct = medianLoss,
|
||||
ProfitFactor = pf
|
||||
};
|
||||
}
|
||||
|
||||
db.TraderWindowMetrics.Add(CalcWindow(window1Start, window1End));
|
||||
db.TraderWindowMetrics.Add(CalcWindow(window2Start, window2End));
|
||||
|
||||
// Fingerprints
|
||||
var buys = trades.Where(t => t.Side == Predictalytics.Domain.Enums.TradeSide.Buy).ToList();
|
||||
|
||||
if (buys.Count > 0)
|
||||
{
|
||||
var bands = buys.GroupBy(b => Math.Floor(b.Price * 10m) / 10m)
|
||||
.Select(g => new {
|
||||
Band = g.Key,
|
||||
StakeShare = g.Sum(b => b.Amount) / buys.Sum(b => b.Amount),
|
||||
WinRate = g.Count(b => positions.Any(p => p.MarketOutcomeId == b.MarketOutcomeId && p.RealizedPnl > 0)) / (decimal)g.Count()
|
||||
}).ToList();
|
||||
analyticsObj.PriceBandProfileJson = System.Text.Json.JsonSerializer.Serialize(bands);
|
||||
}
|
||||
|
||||
var holdDurations = new List<double>();
|
||||
foreach(var mg in trades.GroupBy(t => t.DbMarketId))
|
||||
{
|
||||
var firstBuy = mg.Where(t => t.Side == Predictalytics.Domain.Enums.TradeSide.Buy).OrderBy(t => t.ExecutedAt).FirstOrDefault();
|
||||
var lastExit = mg.Where(t => t.Side == Predictalytics.Domain.Enums.TradeSide.Sell || t.Side == Predictalytics.Domain.Enums.TradeSide.Redeem).OrderByDescending(t => t.ExecutedAt).FirstOrDefault();
|
||||
if (firstBuy != null && lastExit != null && lastExit.ExecutedAt > firstBuy.ExecutedAt)
|
||||
{
|
||||
holdDurations.Add((lastExit.ExecutedAt - firstBuy.ExecutedAt).TotalHours);
|
||||
}
|
||||
}
|
||||
holdDurations.Sort();
|
||||
analyticsObj.MedianHoldDurationHours = holdDurations.Count > 0 ? (decimal)holdDurations[holdDurations.Count / 2] : 0;
|
||||
|
||||
var positionSizes = trades.GroupBy(t => t.DbMarketId).Select(g => g.Where(t => t.Side == Predictalytics.Domain.Enums.TradeSide.Buy).Sum(t => t.Amount)).Where(a => a > 0).ToList();
|
||||
positionSizes.Sort();
|
||||
analyticsObj.P50PositionSize = positionSizes.Count > 0 ? positionSizes[positionSizes.Count / 2] : 0;
|
||||
analyticsObj.P90PositionSize = positionSizes.Count > 0 ? positionSizes[(int)(positionSizes.Count * 0.9)] : 0;
|
||||
|
||||
var firstTradeObj = trades.OrderBy(t => t.ExecutedAt).FirstOrDefault();
|
||||
var daysActiveVal = firstTradeObj != null ? (DateTime.UtcNow - firstTradeObj.ExecutedAt).TotalDays : 0;
|
||||
analyticsObj.TradesPerWeek = daysActiveVal > 0 ? (decimal)(trades.Count / (daysActiveVal / 7.0)) : 0;
|
||||
|
||||
// Copyability Aggregates
|
||||
var volumes = trades.Where(t => t.DbMarket != null && t.DbMarket.Volume > 0).Select(t => t.DbMarket!.Volume).ToList();
|
||||
volumes.Sort();
|
||||
analyticsObj.MedianMarketVolumeUsd = volumes.Count > 0 ? volumes[volumes.Count / 2] : 0;
|
||||
|
||||
var drifts = trades.Where(t => t.Context != null && t.Context.PriceBefore1m > 0 && t.Context.PriceAfter1m.HasValue).Select(t => (t.Context!.PriceAfter1m!.Value - t.Context.PriceBefore1m!.Value) / t.Context.PriceBefore1m!.Value * 100m).ToList();
|
||||
drifts.Sort();
|
||||
analyticsObj.MedianPostFillDriftPct = drifts.Count > 0 ? drifts[drifts.Count / 2] : 0;
|
||||
|
||||
analyticsObj.NetEdgeAfterFeesPct = analyticsObj.MedianWinReturnPct - 2.0m; // simple approximation
|
||||
|
||||
// Master Qualification Logic (E1/E2)
|
||||
if (trader.IngestMode == Predictalytics.Domain.Enums.IngestMode.Full && trader.MasterStatus == Predictalytics.Domain.Enums.MasterStatus.None)
|
||||
{
|
||||
bool isMaster = true;
|
||||
if (analyticsObj.OverallPnL < 500m) isMaster = false;
|
||||
if (trades.Count(t => (DateTime.UtcNow - t.ExecutedAt).TotalDays <= 100) > 0)
|
||||
{
|
||||
if (analyticsObj.OverallWinRate < 0.60m) isMaster = false;
|
||||
}
|
||||
if (daysActiveVal < 30) isMaster = false;
|
||||
if (analyticsObj.Trades30d < 10) isMaster = false;
|
||||
|
||||
if (isMaster)
|
||||
{
|
||||
trader.MasterStatus = Predictalytics.Domain.Enums.MasterStatus.Candidate;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Only stamp if there were actually trades to analyze (or if SnapshotOnly where we skip trade fetch)
|
||||
if (trader.IngestMode == Predictalytics.Domain.Enums.IngestMode.SnapshotOnly || tradesCount > 0)
|
||||
{
|
||||
trader.LastAnalyzedAt = DateTime.UtcNow;
|
||||
await traderRepo.UpdateAsync(trader, ct);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user