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:
Richard
2026-07-14 09:04:31 +02:00
parent a1fcb4ace5
commit 16431f38a5
39 changed files with 9028 additions and 167 deletions
+4 -54
View File
@@ -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)* 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) ### D1. `AggregatedCount`-Spalte (Grundlage für D2/D3)
- Migration: `Trades.AggregatedCount INT NULL` (NULL = einzelner Roh-Trade). - 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 — - Resolution-Farmer-Profil (viele kleine Wins, seltene 100 %-Losses) liefert ProfitFactor nahe 1 —
der Fall, den die Winrate allein verschleiert. 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) ## 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 > Master-Trader-Auswahl (Prüfplan liegt in `PREDICTALYTICS-PRUEFPLAN-Master-Auswahl.md` im
> PolyTraderSharp-Repo). **Bewusste Architektur-Entscheidung: Die API bleibt generisch.** > externen Repo). **Bewusste Architektur-Entscheidung: Die API bleibt generisch.**
> Kein PolyTrader-spezifisches Format, kein „HOLDER/STOPLOSS"-Vokabular in der API — > Kein kunden-spezifisches Format, kein „HOLDER/STOPLOSS"-Vokabular in der API —
> wir exponieren neutrale Metriken, der Konsument mappt selbst. > wir exponieren neutrale Metriken, der Konsument mappt selbst.
> Abgleich: M2/M8 existieren (D2c/CategoryPerformances), M3/M9 teilweise als Traits (D2), > 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- > 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 2. `GET /api/traders/{id}/profile` liefert für einen analysierten Trader alle Blöcke
gefüllt; Antwortzeit < 200 ms (nur persistierte Daten). gefüllt; Antwortzeit < 200 ms (nur persistierte Daten).
3. `docs/API.md` um Profile-/Correlation-Endpoint ergänzt. 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 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. (Fenster), E4/E5/E7 (Kennzahlen), E6 zuletzt. Teil E setzt D1/D2/D2c voraus.
+73
View File
@@ -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
View File
@@ -56,6 +56,17 @@ Offene und realisierte Positionen. Je **TraderPositionDto**: `marketId`,
`marketName`, `category`, `outcomeToken`, `sharesHeld`, `avgCost`, `realizedPnl`, `marketName`, `category`, `outcomeToken`, `sharesHeld`, `avgCost`, `realizedPnl`,
`unrealizedPnl`, `currentPrice`, `lastTradeExecutedAt`. `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…` ### `POST /api/traders?platform=Polymarket&wallet=0x…`
Trader manuell anlegen (Import + Discovery-Flag). Antwort: Trader-Id. 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). `medianLossReturnPct`, `avgLossReturnPct`, `profitFactor` (D2c).
- `GET /api/traders/{id}/profile` — vollständiges Analyse-Profil eines Traders in - `GET /api/traders/{id}/profile` — vollständiges Analyse-Profil eines Traders in
einem Aufruf (Kennzahlen + Zeitfenster + Traits + Fingerprint-Verteilungen + 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, - Out-of-Sample-Fenster (A/B), Preisband-Profil, Stop-Loss-Klassifikation,
Portfolio-/Korrelations-Sicht (Teil E). Portfolio-/Korrelations-Sicht (Teil E).
@@ -1,5 +1,6 @@
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
using Predictalytics.Application.Services; using Predictalytics.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Predictalytics.Api.Endpoints; namespace Predictalytics.Api.Endpoints;
@@ -9,8 +10,11 @@ public static class TraderEndpoints
{ {
var group = app.MapGroup("/api/traders").WithTags("Traders"); var group = app.MapGroup("/api/traders").WithTags("Traders");
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, CancellationToken ct) => group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, string? trait, CancellationToken ct) =>
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, 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) => 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); var id = await svc.AddTraderAsync(platform, wallet, ct);
return Results.Ok(new { id }); 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
));
});
} }
} }
+11
View File
@@ -156,6 +156,10 @@
<input type="number" id="filterCopyabilityMin" class="platform-select" style="width:70px" placeholder="%" onchange="loadTraders()"> <input type="number" id="filterCopyabilityMin" class="platform-select" style="width:70px" placeholder="%" onchange="loadTraders()">
</div> </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);"> <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 <input type="checkbox" id="chk-highly-copyable" onchange="loadTraders()"> Highly Copyable
</label> </label>
@@ -343,6 +347,10 @@
<div class="stat-label">Strategy</div> <div class="stat-label">Strategy</div>
<div class="stat-value" id="td-strategy"></div> <div class="stat-value" id="td-strategy"></div>
</div> </div>
<div class="stat-group" id="td-traits-container" style="display:none; margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px;">
<div class="stat-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> </div>
<div class="detail-main"> <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">Est. Bankroll</div><div class="metric-value" id="td-bankroll">---</div></div>
<div class="metric-card"><div class="metric-label">Quality Edge</div><div class="metric-value" id="td-quality-score">---</div></div> <div class="metric-card"><div class="metric-label">Quality Edge</div><div class="metric-value" id="td-quality-score">---</div></div>
<div class="metric-card"><div class="metric-label">Copyability</div><div class="metric-value" id="td-copyability-score">---</div></div> <div class="metric-card"><div class="metric-label">Copyability</div><div class="metric-value" id="td-copyability-score">---</div></div>
<div class="metric-card"><div class="metric-label">Med. Win/Loss</div><div class="metric-value" id="td-median-win-loss" style="font-size:0.9em;">---</div></div>
<div class="metric-card"><div class="metric-label">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-card accent">
<div class="metric-value" id="td-score">---</div> <div class="metric-value" id="td-score">---</div>
</div> </div>
+48 -2
View File
@@ -292,6 +292,11 @@ async function loadTraders() {
url += `&highlyCopyable=true`; url += `&highlyCopyable=true`;
} }
const filterTrait = document.getElementById('filterTrait');
if (filterTrait && filterTrait.value) {
url += `&trait=${encodeURIComponent(filterTrait.value)}`;
}
let data = await api(url); let data = await api(url);
const tbody = document.getElementById('allTradersBody'); 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; } 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.pct(t.winRate)}</td>
<td>${fmt.pnl(t.totalPnl)}</td> <td>${fmt.pnl(t.totalPnl)}</td>
<td>${t.trades30d} | ${t.totalTrades}</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> <td>
<div style="display:flex; gap:4px;"> <div style="display:flex; gap:4px;">
<button class="btn-sm" onclick="viewTrader(${t.id})">Details</button> <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-platformId').textContent = t.platformUserId;
document.getElementById('td-tier').innerHTML = fmt.tier(t.tier); document.getElementById('td-tier').innerHTML = fmt.tier(t.tier);
document.getElementById('td-strategy').textContent = t.strategy; document.getElementById('td-strategy').textContent = t.strategy;
const 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-winrate').innerHTML = fmt.pct(t.winRate);
document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d); document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d);
document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl); 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-score').textContent = Number(t.combinedScore).toFixed(1);
document.getElementById('td-quality-score').textContent = Number(t.copytradingQualityScore || 0).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); 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.'; document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Not analyzed yet.';
const syncBtn = document.getElementById('btn-sync-trader'); const syncBtn = document.getElementById('btn-sync-trader');
@@ -722,4 +749,23 @@ async function queueBacklogAnalysis() {
} }
// Initialize Dashboard // Initialize Dashboard
loadDashboard(); 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})"); $"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, decimal EstimatedBankroll,
bool IsOnWatchlist, bool IsOnWatchlist,
bool IsSuspectedBot, bool IsSuspectedBot,
DateTime? LastPolledAt DateTime? LastPolledAt,
List<string>? Traits
); );
public record TraderDetailDto( public record TraderDetailDto(
@@ -55,13 +56,27 @@ public record TraderDetailDto(
decimal CopytradingScore, decimal CopytradingScore,
decimal CopytradingQualityScore, decimal CopytradingQualityScore,
decimal CopytradingCopyabilityScore, decimal CopytradingCopyabilityScore,
// Market Return Metrics
decimal MedianWinReturnPct,
decimal AvgWinReturnPct,
decimal MedianLossReturnPct,
decimal AvgLossReturnPct,
decimal? ProfitFactor,
int Rank, int Rank,
bool IsOnWatchlist, bool IsOnWatchlist,
DateTime CreatedAt, DateTime CreatedAt,
DateTime? LastPolledAt, DateTime? LastPolledAt,
string? AiStrategySummary, string? AiStrategySummary,
IReadOnlyList<TradeDto> RecentTrades, IReadOnlyList<TradeDto> RecentTrades,
IReadOnlyList<TraderCategoryPerformanceDto> CategoryPerformances IReadOnlyList<TraderCategoryPerformanceDto> CategoryPerformances,
IReadOnlyList<TraderTraitDto> Traits
);
public record TraderTraitDto(
string Trait,
decimal Value
); );
public record TraderCategoryPerformanceDto( public record TraderCategoryPerformanceDto(
@@ -85,3 +100,39 @@ public record TraderPositionDto(
decimal CurrentPrice, decimal CurrentPrice,
DateTime? LastTradeExecutedAt 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> /// <summary>Perform deep-dive analysis on a specific trader.</summary>
Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default); 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> /// <summary>Get list of all discovered traits.</summary>
Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, CancellationToken ct = default); Task<IReadOnlyList<string>> GetTraitsAsync(CancellationToken ct = default);
/// <summary>Get a trader's details.</summary> /// <summary>Get a trader's details.</summary>
Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default); 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($"Total PnL: ${trader.TotalPnl:F2}");
sb.AppendLine($"Win Rate: {trader.WinRate:F2}%"); sb.AppendLine($"Win Rate: {trader.WinRate:F2}%");
sb.AppendLine($"Total Trades: {trader.TotalTrades}"); 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();
sb.AppendLine("Recent Trades:"); sb.AppendLine("Recent Trades:");
@@ -162,7 +162,7 @@ public class AnalyticsService : IAnalyticsService
)).ToList(); )).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; PlatformType? pType = null;
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt)) 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(); 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(); traders = traders.Skip(skip).Take(take).ToList();
var watchlist = await _watchlistRepo.GetAllAsync(ct); var watchlist = await _watchlistRepo.GetAllAsync(ct);
@@ -182,6 +187,13 @@ public class AnalyticsService : IAnalyticsService
return traders.Select(t => MapTraderDto(t, wIds)).ToList(); 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) 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. // 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?.PnL7d ?? 0, a?.WinRate7d ?? 0, a?.PnL24h ?? 0, a?.WinRate24h ?? 0,
a?.EstimatedBankroll ?? 0, a?.CurrentBalance ?? 0, a?.EstimatedBankroll ?? 0, a?.CurrentBalance ?? 0,
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 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, trader.AiStrategySummary,
trades.Select(MapTradeDto).ToList(), 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) 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.Analytics?.CopytradingQualityScore ?? 0, t.Analytics?.CopytradingCopyabilityScore ?? 0,
t.WinRate, t.TotalPnl, t.TotalTrades, t.WinRate, t.TotalPnl, t.TotalTrades,
t.Analytics?.Trades30d ?? 0, t.Analytics?.PnL30d ?? 0, t.Analytics?.WinRate30d ?? 0, t.Analytics?.EstimatedBankroll ?? 0, 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( private static TradeDto MapTradeDto(Trade t) => new(
t.Id, t.TraderId, t.Trader?.DisplayName ?? "?", t.Platform.ToString(), 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> /// <summary>The copyability dimension (alpha-decay, sizing consistency) of the copytrading score (0-100). Generated by the Estimator.</summary>
public decimal CopytradingCopyabilityScore { get; set; } 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 // Navigation
public virtual Trader Trader { get; set; } = null!; public virtual Trader Trader { get; set; } = null!;
} }
+13 -1
View File
@@ -79,6 +79,12 @@ public class Trade
/// <summary>Indicates if high-res price context was fetched.</summary> /// <summary>Indicates if high-res price context was fetched.</summary>
public bool IsContextEnriched { get; set; } 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) ────────────────────────────────────────── // ── Transient (not persisted) ──────────────────────────────────────────
/// <summary> /// <summary>
@@ -87,7 +93,13 @@ public class Trade
/// </summary> /// </summary>
[NotMapped] [NotMapped]
public string? TransientWallet { get; set; } 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 ──────────────────────────────────────────────────────── // ── Navigation ────────────────────────────────────────────────────────
public Trader Trader { get; set; } = null!; public Trader Trader { get; set; } = null!;
@@ -34,6 +34,9 @@ public class Trader
/// <summary>Whether the trader shows bot-like behavior.</summary> /// <summary>Whether the trader shows bot-like behavior.</summary>
public bool IsSuspectedBot { get; set; } 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> /// <summary>Manual priority override (null = use calculated score).</summary>
public int? ManualPriorityOverride { get; set; } public int? ManualPriorityOverride { get; set; }
@@ -58,6 +61,9 @@ public class Trader
/// <summary>Win rate as a percentage (0-100).</summary> /// <summary>Win rate as a percentage (0-100).</summary>
public decimal WinRate { get; set; } 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> /// <summary>AI-generated strategy summary based on trade history.</summary>
public string? AiStrategySummary { get; set; } public string? AiStrategySummary { get; set; }
@@ -77,4 +83,5 @@ public class Trader
public ICollection<WatchlistEntry> WatchlistEntries { get; set; } = new List<WatchlistEntry>(); public ICollection<WatchlistEntry> WatchlistEntries { get; set; } = new List<WatchlistEntry>();
public ICollection<TraderPosition> Positions { get; set; } = new List<TraderPosition>(); public ICollection<TraderPosition> Positions { get; set; } = new List<TraderPosition>();
public ICollection<TraderCategoryPerformance> CategoryPerformances { get; set; } = new List<TraderCategoryPerformance>(); 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<TraderCategoryPerformance> TraderCategoryPerformances => Set<TraderCategoryPerformance>();
public DbSet<TradeContext> TradeContexts => Set<TradeContext>(); public DbSet<TradeContext> TradeContexts => Set<TradeContext>();
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>(); 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) { } public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
@@ -40,6 +42,30 @@ public class AppDbContext : DbContext
.HasForeignKey<TraderScore>(s => s.TraderId).OnDelete(DeleteBehavior.Cascade); .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 // Trade
mb.Entity<Trade>(e => mb.Entity<Trade>(e =>
{ {
@@ -193,6 +219,22 @@ public class AppDbContext : DbContext
e.Property(a => a.WinRate7d).HasPrecision(8, 4); e.Property(a => a.WinRate7d).HasPrecision(8, 4);
e.Property(a => a.PnL24h).HasPrecision(18, 4); e.Property(a => a.PnL24h).HasPrecision(18, 4);
e.Property(a => a.WinRate24h).HasPrecision(8, 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 // MarketAnalytics
@@ -70,14 +70,14 @@ public class TradeRepository : ITradeRepository
foreach (var chunk in tradeList.Chunk(500)) 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>(); var parameters = new List<object>();
for (int i = 0; i < chunk.Length; i++) for (int i = 0; i < chunk.Length; i++)
{ {
var t = chunk[i]; var t = chunk[i];
int pIdx = i * 15; 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}}})"); 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) if (i < chunk.Length - 1)
sb.Append(", "); sb.Append(", ");
@@ -97,8 +97,12 @@ public class TradeRepository : ITradeRepository
parameters.Add(t.DbMarketId ?? (object?)null); parameters.Add(t.DbMarketId ?? (object?)null);
parameters.Add((int)t.Platform); parameters.Add((int)t.Platform);
parameters.Add(t.IsContextEnriched); 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; int maxRetries = 3;
var backoffs = new[] { 250, 500, 1000 }; var backoffs = new[] { 250, 500, 1000 };
for (int retry = 0; retry <= maxRetries; retry++) for (int retry = 0; retry <= maxRetries; retry++)
@@ -17,6 +17,7 @@ public class TraderRepository : ITraderRepository
.Include(t => t.CurrentScore) .Include(t => t.CurrentScore)
.Include(t => t.Analytics) .Include(t => t.Analytics)
.Include(t => t.CategoryPerformances) .Include(t => t.CategoryPerformances)
.Include(t => t.Traits)
.FirstOrDefaultAsync(t => t.Id == id, ct); .FirstOrDefaultAsync(t => t.Id == id, ct);
} }
@@ -29,6 +30,7 @@ public class TraderRepository : ITraderRepository
var q = _db.Traders var q = _db.Traders
.Include(t => t.CurrentScore) .Include(t => t.CurrentScore)
.Include(t => t.Analytics) .Include(t => t.Analytics)
.Include(t => t.Traits)
.AsQueryable(); .AsQueryable();
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value); if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
@@ -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");
}
}
}
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");
}
}
}
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");
}
}
}
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");
}
}
}
@@ -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");
}
}
}
@@ -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")); MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int?>("AggregatedCount")
.HasColumnType("int");
b.Property<decimal>("Amount") b.Property<decimal>("Amount")
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
@@ -564,6 +567,9 @@ namespace Predictalytics.Infrastructure.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("varchar(256)"); .HasColumnType("varchar(256)");
b.Property<int>("IngestMode")
.HasColumnType("int");
b.Property<bool>("IsAutoDiscovered") b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
@@ -588,6 +594,9 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<int?>("ManualPriorityOverride") b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int>("MasterStatus")
.HasColumnType("int");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("longtext"); .HasColumnType("longtext");
@@ -629,6 +638,14 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<int>("TraderId") b.Property<int>("TraderId")
.HasColumnType("int"); .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") b.Property<decimal>("CopytradingCopyabilityScore")
.HasColumnType("decimal(65,30)"); .HasColumnType("decimal(65,30)");
@@ -647,6 +664,30 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<DateTime>("LastCalculatedAt") b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)"); .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") b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
@@ -655,6 +696,14 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(8, 4) .HasPrecision(8, 4)
.HasColumnType("decimal(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") b.Property<decimal>("PnL24h")
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
@@ -667,9 +716,20 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(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") b.Property<int>("Trades30d")
.HasColumnType("int"); .HasColumnType("int");
b.Property<decimal>("TradesPerWeek")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h") b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4) .HasPrecision(8, 4)
.HasColumnType("decimal(8,4)"); .HasColumnType("decimal(8,4)");
@@ -853,6 +913,88 @@ namespace Predictalytics.Infrastructure.Migrations
b.ToTable("TraderScores"); 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 => modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1049,6 +1191,28 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Trader"); 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 => modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{ {
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
@@ -1089,6 +1253,8 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Trades"); b.Navigation("Trades");
b.Navigation("Traits");
b.Navigation("WatchlistEntries"); b.Navigation("WatchlistEntries");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
@@ -353,15 +353,23 @@ public class PositionPnLEngine : IPositionPnLEngine
analytics.PnL30d = overallPnl - (snapshot30d?.TotalPnl ?? GetFallback(today.AddDays(-30))); analytics.PnL30d = overallPnl - (snapshot30d?.TotalPnl ?? GetFallback(today.AddDays(-30)));
// Count Trades30d // 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 // Calculate Win Rate and Return Pcts on Market level
var (winRateOverall, winRate30d, winRate7d, winRate24h) = CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h); var (winRateOverall, winRate30d, winRate7d, winRate24h,
medianWin, avgWin, medianLoss, avgLoss, profitFactor) =
CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h);
analytics.OverallWinRate = winRateOverall; analytics.OverallWinRate = winRateOverall;
analytics.WinRate30d = winRate30d; analytics.WinRate30d = winRate30d;
analytics.WinRate7d = winRate7d; analytics.WinRate7d = winRate7d;
analytics.WinRate24h = winRate24h; analytics.WinRate24h = winRate24h;
analytics.MedianWinReturnPct = medianWin;
analytics.AvgWinReturnPct = avgWin;
analytics.MedianLossReturnPct = medianLoss;
analytics.AvgLossReturnPct = avgLoss;
analytics.ProfitFactor = profitFactor;
analytics.LastCalculatedAt = DateTime.UtcNow; analytics.LastCalculatedAt = DateTime.UtcNow;
// Sync back to Trader record for quick sorting / UI display // 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 // increment counters drift (INSERT IGNORE, deletions, historic imports) and
// produced impossible states like Trades30d > TotalTrades. Compacted rows // produced impossible states like Trades30d > TotalTrades. Compacted rows
// count as 1 until an AggregatedCount column exists (FIXPLAN Teil D). // 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) if (trades.Count > 0 || trader.LastTradesUpdatedAt != null)
{ {
trader.LastAnalyzedAt = DateTime.UtcNow; trader.LastAnalyzedAt = DateTime.UtcNow;
@@ -409,7 +417,10 @@ public class PositionPnLEngine : IPositionPnLEngine
trader.DisplayName, traderId, totalRealizedPnl, totalUnrealizedPnl, overallPnl, winRateOverall); 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, List<Trade> trades,
Dictionary<int, TraderPosition> finalPositions, Dictionary<int, TraderPosition> finalPositions,
DateTime cutoff30d, DateTime cutoff30d,
@@ -426,6 +437,11 @@ public class PositionPnLEngine : IPositionPnLEngine
int closedMarkets7d = 0, wins7d = 0; int closedMarkets7d = 0, wins7d = 0;
int closedMarkets24h = 0, wins24h = 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) foreach (var marketGroup in tradesByMarket)
{ {
var outcomeIds = marketGroup 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 lastTradeTime = marketGroup.Max(t => t.ExecutedAt);
var isWin = marketPnl > 0; var isWin = marketPnl > 0;
@@ -484,7 +516,38 @@ public class PositionPnLEngine : IPositionPnLEngine
var winRate7d = closedMarkets7d > 0 ? (decimal)wins7d / closedMarkets7d * 100m : 0m; var winRate7d = closedMarkets7d > 0 ? (decimal)wins7d / closedMarkets7d * 100m : 0m;
var winRate24h = closedMarkets24h > 0 ? (decimal)wins24h / closedMarkets24h * 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,95 +122,231 @@ public class TradeHistoryWorker : BackgroundService
bool isDeepResync = activeJob != null && activeJob.JobType == Predictalytics.Domain.Enums.JobType.DeepResync; bool isDeepResync = activeJob != null && activeJob.JobType == Predictalytics.Domain.Enums.JobType.DeepResync;
bool isInitial = !trader.IsInitialImportComplete || isDeepResync; 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) if (isDeepResync)
{ {
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>(); 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 '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); await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, "DELETE FROM TraderPositions WHERE TraderId = {0}", t.Id);
} }
IReadOnlyList<Domain.Entities.Trade> fetchedTrades; IReadOnlyList<Domain.Entities.Trade> fetchedTrades = new List<Domain.Entities.Trade>();
if (isDeepResync)
{
fetchedTrades = await provider.GetTradesPagedAsync(trader.PlatformUserId, 500, ct);
}
else
{
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; if (!skipTradeFetch)
if (!string.IsNullOrEmpty(updatedName) && !string.Equals(trader.DisplayName, updatedName, StringComparison.OrdinalIgnoreCase))
{ {
trader.DisplayName = updatedName; if (isDeepResync || isWeeklyBiopsy)
await traderRepo.UpdateAsync(trader, ct);
}
var fetchedTradeIds = validTrades.Select(tr => tr.PlatformTradeId).ToList();
var knownTradeIds = await tradeRepo.GetKnownPlatformTradeIdsAsync(trader.Platform, trader.Id, fetchedTradeIds, ct);
// Collect all unique AssetIds we might need to resolve
var assetIdsToResolve = validTrades
.Where(tr => !knownTradeIds.Contains(tr.PlatformTradeId) || isInitial)
.Select(tr => tr.AssetId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();
// Pre-fill local cache with bulk query
var missingAssetIds = assetIdsToResolve.Where(id => !outcomeCache.ContainsKey(id!)).ToList();
if (missingAssetIds.Count > 0)
{
var resolvedOutcomes = await marketRepo.GetOutcomesByTokenIdsAsync(missingAssetIds!, ct);
foreach (var o in resolvedOutcomes)
{ {
outcomeCache.TryAdd(o.TokenId, o); fetchedTrades = await provider.GetTradesPagedAsync(trader.PlatformUserId, 500, ct);
}
else
{
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;
if (!string.IsNullOrEmpty(updatedName) && !string.Equals(trader.DisplayName, updatedName, StringComparison.OrdinalIgnoreCase))
{
trader.DisplayName = updatedName;
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>(); var newTrades = new List<Domain.Entities.Trade>();
foreach (var trade in validTrades) if (fetchedTrades.Count > 0)
{ {
if (knownTradeIds.Contains(trade.PlatformTradeId)) 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);
// Collect all unique AssetIds we might need to resolve
var assetIdsToResolve = validTrades
.Where(tr => !knownTradeIds.Contains(tr.PlatformTradeId) || isInitial)
.Select(tr => tr.AssetId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();
// Pre-fill local cache with bulk query
var missingAssetIds = assetIdsToResolve.Where(id => !outcomeCache.ContainsKey(id!)).ToList();
if (missingAssetIds.Count > 0)
{ {
continue; var resolvedOutcomes = await marketRepo.GetOutcomesByTokenIdsAsync(missingAssetIds!, ct);
foreach (var o in resolvedOutcomes)
{
outcomeCache.TryAdd(o.TokenId, o);
}
} }
trade.TraderId = trader.Id; foreach (var trade in validTrades)
if (!string.IsNullOrEmpty(trade.AssetId))
{ {
if (outcomeCache.TryGetValue(trade.AssetId, out var outcome)) if (knownTradeIds.Contains(trade.PlatformTradeId))
{ {
trade.MarketOutcomeId = outcome.Id; continue;
trade.Outcome = outcome.Label;
if (outcome.Market != null)
trade.DbMarketId = outcome.Market.Id;
} }
else if (!string.IsNullOrEmpty(trade.MarketId))
trade.TraderId = trader.Id;
if (!string.IsNullOrEmpty(trade.AssetId))
{ {
// Fallback for missing outcomes: try to fetch market if (outcomeCache.TryGetValue(trade.AssetId, out var outcome))
var marketTask = marketFetchCache.GetOrAdd(trade.MarketId, _ => provider.GetMarketAsync(trade.MarketId, ct));
var newMarket = await marketTask;
if (newMarket != null)
{ {
await marketRepo.AddOrUpdateAsync(newMarket, ct); trade.MarketOutcomeId = outcome.Id;
var newOutcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, ct); trade.Outcome = outcome.Label;
if (newOutcome != null) if (outcome.Market != null)
trade.DbMarketId = outcome.Market.Id;
}
else if (!string.IsNullOrEmpty(trade.MarketId))
{
// Fallback for missing outcomes: try to fetch market
var marketTask = marketFetchCache.GetOrAdd(trade.MarketId, _ => provider.GetMarketAsync(trade.MarketId, ct));
var newMarket = await marketTask;
if (newMarket != null)
{ {
outcomeCache.TryAdd(trade.AssetId, newOutcome); await marketRepo.AddOrUpdateAsync(newMarket, ct);
trade.MarketOutcomeId = newOutcome.Id; var newOutcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, ct);
trade.Outcome = newOutcome.Label; if (newOutcome != null)
if (newOutcome.Market != null) {
trade.DbMarketId = newOutcome.Market.Id; outcomeCache.TryAdd(trade.AssetId, newOutcome);
trade.MarketOutcomeId = newOutcome.Id;
trade.Outcome = newOutcome.Label;
if (newOutcome.Market != null)
trade.DbMarketId = newOutcome.Market.Id;
}
} }
} }
} }
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);
}
} }
newTrades.Add(trade);
} }
if (newTrades.Count > 0) if (newTrades.Count > 0)
@@ -69,7 +69,7 @@ public class TradeRetentionWorker : BackgroundService
return; return;
} }
var retentionDays = _config.GetValue("RetentionSettings:RetentionDays", 90); var retentionDays = _config.GetValue("RetentionSettings:RetentionDays", 180);
var compactionDays = _config.GetValue("RetentionSettings:CompactionDays", 14); var compactionDays = _config.GetValue("RetentionSettings:CompactionDays", 14);
_logger.LogInformation("🧹 TradeRetentionWorker: Starting optimization. RetentionDays={Retention}, CompactionDays={Compaction}", _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); _logger.LogInformation("Pruning trades older than {Cutoff}...", retentionCutoff);
var deletedTrades = await db.Trades 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 }) .Select(t => new { t.TraderId, t.MarketOutcomeId })
.Distinct() .Distinct()
.ToListAsync(ct); .ToListAsync(ct);
var deletedCount = await db.Trades 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); .ExecuteDeleteAsync(ct);
if (deletedCount > 0 && deletedTrades.Any()) if (deletedCount > 0 && deletedTrades.Any())
@@ -217,7 +217,8 @@ public class TradeRetentionWorker : BackgroundService
Size = totalSize, Size = totalSize,
Amount = totalAmount, Amount = totalAmount,
ExecutedAt = date.AddHours(12), // Set to noon of that day 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 // Remove the individual trades
@@ -117,38 +117,215 @@ public class TraderAnalyticsWorker : BackgroundService
{ {
using var traderScope = _services.CreateScope(); using var traderScope = _services.CreateScope();
var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>(); var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
await pnlEngine.RecalculateTraderPositionsAsync(id, ct); 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 // Run CopytradingEstimator
var traderRepo = traderScope.ServiceProvider.GetRequiredService<ITraderRepository>();
var db = traderScope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = traderScope.ServiceProvider.GetRequiredService<AppDbContext>();
var estimator = traderScope.ServiceProvider.GetRequiredService<ICopytradingEstimator>(); var estimator = traderScope.ServiceProvider.GetRequiredService<ICopytradingEstimator>();
var trader = await traderRepo.GetByIdAsync(id, ct);
if (trader != null) if (trader != null)
{ {
var trades = await db.Trades int tradesCount = 0;
.Include(t => t.MarketOutcome).ThenInclude(o => o.Market) if (trader.IngestMode == Predictalytics.Domain.Enums.IngestMode.SnapshotOnly)
.Include(t => t.DbMarket)
.Include(t => t.Context)
.Where(t => t.TraderId == id && t.DbMarketId != null)
.OrderByDescending(t => t.ExecutedAt)
.Take(1000)
.ToListAsync(ct);
if (trades.Count > 0)
{ {
var estScores = await estimator.CalculateScoresAsync(trader, trades, ct);
var analyticsObj = trader.Analytics ?? new Predictalytics.Domain.Entities.TraderAnalytics { TraderId = trader.Id }; var analyticsObj = trader.Analytics ?? new Predictalytics.Domain.Entities.TraderAnalytics { TraderId = trader.Id };
// Persist advanced copyability and quality scores derived from tape replay analyticsObj.CopytradingScore = 0;
analyticsObj.CopytradingScore = estScores.CombinedScore; analyticsObj.CopytradingQualityScore = 0;
analyticsObj.CopytradingQualityScore = estScores.QualityScore; analyticsObj.CopytradingCopyabilityScore = 0;
analyticsObj.CopytradingCopyabilityScore = estScores.CopyabilityScore;
trader.Analytics = analyticsObj; trader.Analytics = analyticsObj;
// Only stamp if there were actually trades to analyze 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)
.Include(t => t.DbMarket)
.Include(t => t.Context)
.Where(t => t.TraderId == id && t.DbMarketId != null)
.OrderByDescending(t => t.ExecutedAt)
.Take(1000)
.ToListAsync(ct);
tradesCount = trades.Count;
if (trades.Count > 0)
{
var estScores = await estimator.CalculateScoresAsync(trader, trades, ct);
var analyticsObj = trader.Analytics ?? new Predictalytics.Domain.Entities.TraderAnalytics { TraderId = trader.Id };
// Persist advanced copyability and quality scores derived from tape replay
analyticsObj.CopytradingScore = estScores.CombinedScore;
analyticsObj.CopytradingQualityScore = estScores.QualityScore;
analyticsObj.CopytradingCopyabilityScore = estScores.CopyabilityScore;
trader.Analytics = analyticsObj;
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; trader.LastAnalyzedAt = DateTime.UtcNow;
await traderRepo.UpdateAsync(trader, ct); await traderRepo.UpdateAsync(trader, ct);
} }