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
+560
View File
@@ -0,0 +1,560 @@
# 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 A — Code-Fixes
### A1. Trader-Detailseite: Buttons auf das Job-System umstellen
**Problem:**
- `manualUpdateTrader` in `src/Predictalytics.Api/wwwroot/js/app.js` (~Zeile 110) baut die URL mit **Backslashes**:
`` fetch(`\api\traders\${id}\refresh`) `` — in JS-Template-Literals ist `\t` ein Tab und `\${id}` unterdrückt die
Interpolation. Der Request geht als Müll-URL raus.
- Der „Analyze"-Button der Detailseite (~Zeile 441) ruft `POST /api/traders/{id}/force-analyze` — läuft **synchron** im
API-Request, legt **keinen** `BackgroundJob` an (der Alert behauptet es aber) und führt nur die PnL-Engine aus,
weder `CopytradingEstimator` noch KI.
**Fix:**
- `btn-sync-trader` → `POST /api/jobs/sync/{id}`, `btn-analyze-trader` → `POST /api/jobs/analyze/{id}`
(bestehende Funktionen `queueHistorySync(id)` / `queueTraderAnalysis(id)` wiederverwenden).
- Alert-Texte ehrlich machen (Job-Id anzeigen oder auf die Jobs-Seite verweisen).
- Die Endpoints `/{id}/force-analyze` und `/{id}/refresh` entweder entfernen oder intern auf Job-Enqueue umbauen —
es darf nur noch **einen** Auslöse-Pfad geben.
### A2. TradeReconciliationWorker: Bulk-UPDATE batchen, Fehler pro Markt behandeln
**Problem:** Das eine große `UPDATE Trades ... INNER JOIN ... WHERE MarketOutcomeId IS NULL` läuft über die gesamte
Tabelle, hält minutenlang Locks und produziert Deadlocks mit den Insert-Workern. Außerdem verwirft der eine
try/catch um den ganzen Batch bei jedem Einzelfehler (z. B. ein fehlgeschlagener `GetMarketAsync`) die komplette Restarbeit.
**Fix:**
- UPDATE in Batches. Achtung: MySQL erlaubt kein `LIMIT` bei Multi-Table-UPDATE — Pattern mit Subquery verwenden:
```sql
UPDATE Trades t
JOIN (
SELECT t2.Id, o.Id AS OutcomeId, o.Label, m.Id AS MarketDbId
FROM Trades t2
JOIN MarketOutcomes o ON t2.AssetId = o.TokenId
JOIN Markets m ON o.MarketId = m.Id
WHERE t2.MarketOutcomeId IS NULL AND t2.AssetId != ''
LIMIT 5000
) x ON t.Id = x.Id
SET t.MarketOutcomeId = x.OutcomeId, t.Outcome = x.Label, t.DbMarketId = x.MarketDbId;
```
In einer Schleife ausführen, bis 0 Zeilen betroffen sind (mit kurzem Delay zwischen den Batches).
- `GetMarketAsync`-Fehler pro Markt fangen und loggen — die restlichen Märkte des Batches weiterverarbeiten.
- Das Checkpoint-Reset (`LastAppliedTradeId = 0`) weiterhin **nur** für Positionen mit `IsHistoryPruned = 0`
(ist bereits so umgesetzt — nicht regressieren, Test `PrunedPositionWithResetCheckpoint_DoesNotDoubleCount` wacht darüber).
### A3. Deadlock-Retry in `TradeRepository.AddRangeAsync`
`MySqlException` mit `Number == 1213` (Deadlock) oder `1205` (Lock wait timeout) → bis zu 3 Versuche mit Backoff
(250 ms / 500 ms / 1 s). Chunk-Größe von 1000 auf 500 Zeilen reduzieren. Bei endgültigem Fehlschlag: Fehler loggen
inkl. Anzahl verlorener Zeilen.
### A4. Saubere Cancellation in allen Worker-Loops
**Problem:** Beim Stoppen des Servers wirft jede laufende Operation `OperationCanceledException`; der
`TraderAnalyticsWorker` fängt das **pro Trader** als ERROR und nudelt durch den restlichen 500er-Batch
(→ hunderte Fehlerlog-Einträge pro Shutdown, verzögerter Stopp).
**Fix (in TraderAnalyticsWorker, PollingWorker, TradeHistoryWorker, TradeReconciliationWorker, TradeContextEnrichmentWorker):**
- Vor jeder Batch-Iteration: `if (ct.IsCancellationRequested) break;`
- `catch (OperationCanceledException) when (ct.IsCancellationRequested)` separat behandeln:
als Information loggen („shutting down"), Schleife beenden — **nicht** als Error.
### A5. Hängengebliebene Jobs wiederbeleben
**Problem:** Jobs, die beim Shutdown `InProgress` waren, bleiben für immer stecken (`GetNextPendingJobAsync` holt nur `Pending`).
**Fix:** Beim Start der Job-verarbeitenden Worker (oder einmal pro Zyklus): Jobs mit `Status = InProgress` und
`StartedAt < UtcNow - 15min` zurück auf `Pending` setzen (Log-Hinweis).
### A6. Deep-Resync-Fähigkeit (Voraussetzung für die Datenreparatur in Teil B)
**Problem:** `PolymarketApiClient.GetTradesAsync` macht genau **einen** Request (`/activity?user=X&limit=1000`,
keine Pagination; die API cappt vermutlich ohnehin bei 500). Der „INITIAL FULL sync" holt also nur die jüngsten
~5001000 Aktivitäten. Für die Reparatur der Retention-/Kompaktierungs-Opfer brauchen wir die **komplette** Historie.
**Fix:**
- Neue Methode `GetTradesPagedAsync(wallet, ...)` mit **Timestamp-basierter Pagination**: erste Seite normal laden,
Folgeseiten mit `&end=<ältester Timestamp der Vorseite - 1>` bis eine leere Seite kommt. (Timestamp-Pagination ist
robuster als `offset`, da Offset-Limits der API umgangen werden.) `limit=500` verwenden. Jede Seite über den
vorhandenen `IRateLimiter` drosseln.
- Neuer `JobType.DeepResync` (Migration für Enum nicht nötig, Enum ist int): Der `TradeHistoryWorker` behandelt ihn wie
`HistorySync`, lädt aber ALLE Seiten.
- **Vor** dem Import im DeepResync-Pfad für den Trader aufräumen (sonst Doppelzählung!):
1. `DELETE FROM Trades WHERE TraderId = @id AND PlatformTradeId LIKE 'COMPACT_%'`
(Re-Import bringt die Original-Trades zurück; die Aggregate dürfen nicht zusätzlich existieren),
2. alle `TraderPositions` des Traders löschen (**inklusive** `IsHistoryPruned = 1` — die Konserve wird durch den
vollständigen Re-Import ersetzt),
3. nach erfolgreichem Import: `IsInitialImportComplete = true`, `LastTradesUpdatedAt = now`, `LastAnalyzedAt = NULL`.
- Endpoints:
- `POST /api/jobs/deep-resync/{traderId}` (einzeln),
- `POST /api/jobs/deep-resync-pruned?take=25` — enqueued DeepResync-Jobs für Trader mit `IsHistoryPruned`-Positionen
oder `COMPACT_`-Trades, Watchlist zuerst, dann nach `TotalTrades` absteigend.
- WebUI: Button „Deep Resync" auf der Jobs-Seite neben „Analyze Backlog".
### A7. Retention pausierbar machen
Neues Config-Flag `RetentionSettings:Enabled` (Default `true`), das der `TradeRetentionWorker` pro Zyklus prüft.
Während der Datenreparatur steht es auf `false` — sonst prunt/kompaktiert die tägliche Runde die frisch
re-importierten Alt-Trades wieder weg, bevor die Engine sie eingerechnet hat.
### A8. ⚠️ NEU (2026-07-10, höchste Priorität): ResolutionOutcome existiert in der Gamma-API nicht — alle Gewinner werden als Totalverlust gebucht
**Empirisch gegen die Live-API verifiziert:** Die Antwort von `gamma-api.polymarket.com/markets` enthält
**weder** ein Feld `resolution_outcome` (so mappt es `GammaMarketResponse` aktuell) **noch** `resolutionOutcome`
**noch** `resolved`. Folgen im Bestand und in jeder Neuberechnung:
- `Market.ResolutionOutcome` ist für **jeden** Markt `NULL` → `MarketOutcomeHelper.IsWinningOutcome` liefert immer
`false` → jeder Redeem und jeder virtuelle Payout bucht Auszahlung **0** → **jeder aufgelöste Markt ist ein
Totalverlust**. Das erzeugt exakt das Live-Bild: WinRate 0 %, Quality Edge 0.0, negative Total-PnL.
- `IsResolved = raw.Resolved || raw.Closed` degeneriert zu `IsResolved = closed`. Märkte, die für den Handel
geschlossen, aber noch nicht UMA-aufgelöst sind, werden **vorzeitig** zu Payout 0 ausgebucht.
**Wie man den Gewinner wirklich erkennt** (Live-API-Beispiele): Nach der Auflösung rasten die `outcomePrices`
auf `["1","0"]` / `["0","1"]` ein (liegen bei uns bereits in `MarketOutcome.CurrentPrice`), und es gibt das Feld
`umaResolutionStatus` (String, `"resolved"` bei aufgelösten Märkten; bei sehr alten Märkten fehlt es).
**Fix (drei Teile):**
1. **Model:** In `GammaMarketResponse` das tote `resolution_outcome`-Mapping entfernen,
`[JsonPropertyName("umaResolutionStatus")] public string? UmaResolutionStatus` ergänzen.
2. **Mapper (`MapGammaMarket`):**
- Preise parsen, dann: `pricesSnapped = alle Outcome-Preise ≤ 0.02 oder ≥ 0.98` (und mindestens ein Preis ≥ 0.98).
- `IsResolved = raw.UmaResolutionStatus == "resolved" || (raw.Closed && pricesSnapped)`.
- `ResolutionOutcome = Label des Outcomes mit Preis ≥ 0.98` (nur wenn `IsResolved`; sonst `NULL`).
3. **Engine-Absicherung (Defense in depth, weil der Bestand NULL-Werte enthält):** Redeem-Buchung und virtueller
Payout dürfen nur settlen, wenn das Ergebnis entscheidbar ist: `ResolutionOutcome` gesetzt **oder** ein
Outcome-Preis des Marktes ≥ 0.98 (dann gilt das Outcome mit Preis ≥ 0.98 als Gewinner, z. B. via erweitertem
`MarketOutcomeHelper`). Ist der Markt „resolved", aber nichts entscheidbar (Preise nicht eingerastet) →
**Position offen lassen** (kein Payout zu 0!).
**Abnahme:** Zwei neue rote Invarianten-Tests in `PositionPnLEngineTests.cs` müssen grün werden, ohne die
Assertions zu ändern:
- `RecalculateTraderPositionsAsync_ResolvedMarketWithoutResolutionOutcome_PaysWinnerViaSnappedPrice`
(aktuell: RealizedPnl 40 statt +60)
- `RecalculateTraderPositionsAsync_ClosedButUnresolvedMarket_DoesNotBookPrematurePayout`
(aktuell: Position wird zu 0 ausgebucht statt offen zu bleiben)
### A9. Trader-Namen aus der Activity-API übernehmen (Suche nach Benutzername)
**Problem:** Manuell hinzugefügte (und über Markt-Trades entdeckte) Trader behalten für immer den
Platzhalter-Namen `0x2005d16a...` — die Suche findet sie nur über die Adresse, nicht über den Polymarket-Namen
(Beispiel: `0x2005d16a84ceefa912d4e380cd32e7ff827875ea` heißt auf Polymarket „RN1").
**Empirisch verifiziert:** Jede Zeile der `/activity`-Antwort enthält bereits `name` („RN1") und `pseudonym`
(„Scary-Edible") — die Felder werden nur nicht gemappt und damit bei jedem Sync weggeworfen.
**Fix:**
1. `PolymarketTradeResponse`: `[JsonPropertyName("name")] public string? Name` und
`[JsonPropertyName("pseudonym")] public string? Pseudonym` ergänzen.
2. `Trade`: transientes Feld `[NotMapped] public string? TransientDisplayName` (analog `TransientWallet`);
im `PolymarketProvider`-Mapping mit `name`, Fallback `pseudonym`, befüllen.
3. `PollingWorker` und `TradeHistoryWorker`: nach dem Fetch, wenn ein nicht-leerer `TransientDisplayName`
vorliegt und vom aktuellen `DisplayName` abweicht → `trader.DisplayName` aktualisieren
(die Plattform ist die Quelle der Wahrheit; Platzhalter wie `0x…` heilen sich damit von selbst).
4. `DiscoveryService.ImportTraderAsync` (manuelles Hinzufügen): direkt beim Import die erste Activity-Seite
abrufen und den Namen setzen, statt des Wallet-Präfixes.
5. Die Suche (`TraderRepository.SearchAsync`) durchsucht `DisplayName` bereits — funktioniert danach automatisch
für Name **und** Adresse.
**Empfohlener Beifang im selben Handgriff:** Die Antwort enthält auch `usdcSize` (echter Cash-Betrag — wichtig für
korrekte Split/Merge/Redeem-Buchungen) und `outcomeIndex` (robustes Outcome-Matching ohne Label-Vergleich).
Mindestens im Response-Model mit erfassen; Persistierung von `usdcSize` auf `Trade` (Migration) als eigener
kleiner Folge-Task.
### A10. Watchlist end-to-end reparieren + `api()`-Helper-Bug (betrifft auch die KI-Analyse!)
**Problem 1 — der zentrale JS-Helper verwirft alle Fetch-Optionen:**
```js
// app.js Zeile 133 — options-Parameter fehlt komplett:
async function api(endpoint) {
const res = await fetch(`${API_BASE}${endpoint}`); // ← { method: 'POST' } wird ignoriert!
```
Jeder Aufruf der Form `api(url, { method: 'POST'|'DELETE' })` degradiert still zu einem **GET** → 404 →
der Fehler wird im catch geschluckt (`return null`). Betroffen: **Watchlist-Toggle** (Zeile ~449) und
**KI-Analyse-Button** (Zeile ~605). Deshalb „passiert nichts" beim Watchlist-Button — und deshalb steht überall
„Not analyzed yet".
**Problem 2 — Backslash im Route-Template (gleiche Tippfehler-Familie wie in app.js):**
`TraderEndpoints.cs` Zeile ~52: `group.MapPost("\{id:int}/ai-analysis", ...)` — die Route ist mit dem
Backslash unerreichbar. Der KI-Analyse-Endpoint ist damit **serverseitig ebenfalls tot** (doppelt kaputt).
**Problem 3 — es gibt keine Watchlist-Ansicht:** Der Toggle-Button existiert, aber nirgendwo im WebUI kann man
die beobachteten Trader sehen. `WatchlistService.GetAllAsync` existiert im Backend, hat aber weder Endpoint noch UI.
**Fix:**
1. `api()`-Helper reparieren:
```js
async function api(endpoint, options = {}) {
try {
const res = await fetch(`${API_BASE}${endpoint}`, options);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : true; // leere 200er (Results.Ok()) nicht crashen lassen
} catch (err) { console.error(`API Error [${endpoint}]:`, err); return null; }
}
```
2. Route-Template fixen: `"\{id:int}/ai-analysis"` → `"/{id:int}/ai-analysis"`.
**Danach das gesamte Projekt nach weiteren Backslash-Pfaden absuchen** (`grep -rn '"\\{' src/` und
`grep -n '\\\\api' wwwroot/js/app.js`) — das ist jetzt der dritte Fall dieser Fehlerklasse.
3. Neuer Endpoint `GET /api/watchlist`: liefert Watchlist-Einträge mit Trader-Kerndaten
(TraderId, DisplayName, Label, Notes, CreatedAt, TotalPnl, WinRate, CopytradingScore).
4. WebUI: Nav-Punkt „Watchlist" + Seite mit Tabelle (Spalten wie Traders-Liste, plus Label/Notes und
Remove-Button; Zeilenklick öffnet die Detailseite). Der Toggle auf der Detailseite muss nach dem Klick
sichtbar den Zustand wechseln („Watchlist (Add)" ↔ „Watchlist (Remove)").
---
## Teil B — Datenreparatur ohne DB-Reset (Reihenfolge strikt einhalten)
**Warum kein Reset nötig ist:** `Trades` = Rohdaten, größtenteils intakt. `TraderPositions`, `TraderAnalytics`,
`TraderDailySnapshots`, `TraderScores`, `TraderCategoryPerformances` = abgeleitet, lokal neu berechenbar.
`Markets/Events` = unvollständig (Erbe des 10%-Sampling-Bugs), aber per Marktsync günstig nachladbar.
Nur Pruned-/Compacted-Trader brauchen API-Re-Import — das ist eine kleine Teilmenge, nicht die ganze Import-Woche.
### B0. Diagnose (Umfang bestimmen — SQL führt Richard selbst aus)
```sql
-- Wie viele Trader brauchen Deep-Resync?
SELECT COUNT(*) AS PrunedPositions, COUNT(DISTINCT TraderId) AS BetroffeneTrader
FROM TraderPositions WHERE IsHistoryPruned = 1;
SELECT COUNT(DISTINCT TraderId) FROM Trades WHERE PlatformTradeId LIKE 'COMPACT_%';
-- Reconciliation-Backlog und Snapshot-Bestand
SELECT COUNT(*) FROM Trades WHERE MarketOutcomeId IS NULL;
SELECT COUNT(*) FROM TraderDailySnapshots;
```
### B1. Vorbereitung
Teil A deployen → Worker stoppen → **DB-Dump als Sicherung** → `RetentionSettings:Enabled = false`.
### B2. Voll-Marktsync
Manuellen Market-Sync (inkl. geschlossener Märkte) einmal komplett durchlaufen lassen — schließt die Markt-Lücken,
an denen die Trade-Verlinkung bisher scheiterte, **und befüllt nach A8 erstmals `ResolutionOutcome`/korrektes
`IsResolved` für den gesamten Marktbestand**. Kostet nur Events-Endpoint-Requests (einige hundert), keine Import-Woche.
Optional als Sofort-Backfill vor dem Sync (nutzt die bereits gespeicherten, eingerasteten Preise):
```sql
UPDATE Markets m
JOIN MarketOutcomes o ON o.MarketId = m.Id AND o.CurrentPrice >= 0.98
SET m.ResolutionOutcome = o.Label
WHERE m.IsResolved = 1 AND (m.ResolutionOutcome IS NULL OR m.ResolutionOutcome = '');
```
### B3. SQL-Reparatur der abgeleiteten Daten (Richard führt aus, Worker sind aus)
```sql
-- Vergiftete Fenster-Basis komplett verwerfen (heilt über den Fallback + neue Snapshots)
TRUNCATE TABLE TraderDailySnapshots;
-- Abgeleitete Kategorien-Statistik neu aufbauen lassen
DELETE FROM TraderCategoryPerformances;
-- Positionen mit vollständiger lokaler Historie löschen → Engine baut sie mit gefixtem Code neu
DELETE FROM TraderPositions WHERE IsHistoryPruned = 0;
-- (IsHistoryPruned = 1 absichtlich behalten: Konserve bis zum Deep-Resync in B5)
-- Analytics nullen
UPDATE TraderAnalytics SET OverallPnL=0, PnL30d=0, PnL7d=0, PnL24h=0,
OverallWinRate=0, WinRate30d=0, WinRate7d=0, WinRate24h=0,
CurrentBalance=0, EstimatedBankroll=0, Trades30d=0,
CopytradingScore=0, CopytradingQualityScore=0, CopytradingCopyabilityScore=0;
-- Re-Analyse für alle triggern
UPDATE Traders SET LastAnalyzedAt = NULL;
-- Optional (einmalig, teuer — außerhalb der Stoßzeiten): Zähler geradeziehen
UPDATE Traders t SET TotalTrades = (SELECT COUNT(*) FROM Trades tr WHERE tr.TraderId = t.Id);
```
### B4. Worker starten, Backlog abarbeiten lassen
Reconciliation (jetzt gebatcht) verlinkt die Orphans; der `TraderAnalyticsWorker` rechnet alle Trader neu
(Fortschritt über Jobs-Seite/Analyze-Backlog-Button sichtbar). Die Fenster-PnL läuft anfangs über den Fallback und
gewinnt mit jedem Tag Snapshot-Präzision — nach 30 Tagen voll da. Das ist korrekt und erwartbar.
### B5. Deep-Resync der betroffenen Trader
Für alle Trader aus B0 (Pruned/Compacted): `POST /api/jobs/deep-resync-pruned` in Häppchen (z. B. 25er-Batches),
über Tage verteilt — der RateLimiter drosselt automatisch. Watchlist-Trader zuerst.
Bis ein Trader dran war, zeigt er die (möglicherweise leicht verzerrte) Pruned-Konserve — akzeptierter Zwischenzustand.
### B6. Retention wieder aktivieren
`RetentionSettings:Enabled = true`. Ab jetzt entsteht die Pruned-Konserve auf Basis der **korrekten** Engine —
zukünftiges Pruning ist damit verlustfrei im Sinne der PnL-Summen.
### B7. Verifikation
1. Invarianten-SQL:
```sql
-- Fenster-PnL darf für Trader ohne Trades im Fenster nicht = Lifetime sein
SELECT COUNT(*) FROM TraderAnalytics a
WHERE ABS(a.PnL30d) > 0 AND a.Trades30d = 0;
```
2. Plausibilitäts-Stichprobe gegen Polymarkets eigene Zahlen: kleiner Dev-Endpoint
`GET /api/dev/verify-positions/{traderId}`, der `GetTraderPositionsAsync` (Polymarkets `/positions` liefert
deren berechnete `size`/`avgPrice`/`percentPnl`) mit unseren `TraderPositions` vergleicht und Abweichungen
> 5 % listet. 1020 aktive Trader stichproben.
3. 24 h Logs beobachten: keine 1213-Deadlocks, keine ERR-Kaskaden bei Shutdown, Jobs-Seite zeigt Durchsatz.
---
## Teil C — Abnahmekriterien (gesamt)
1. `dotnet test`: **18 grün + 1 skip** (inkl. der beiden A8-Tests), Assertions unverändert.
2. „Sync"/„Analyze" auf der Detailseite erzeugen sichtbare Einträge auf der Jobs-Seite, die auch abgearbeitet werden.
3. Nach B3/B4: kein Trader mehr mit `|PnL30d| > 0` bei `Trades30d = 0`; PnL30d/Total-Verhältnisse plausibel.
4. Quality Edge / Copyability auf der Detailseite ≠ 0 für analysierte Trader mit verlinkten Trades.
5. 24 h Betrieb ohne Deadlock-Errors und ohne Shutdown-Fehlerkaskaden.
6. Die Suche nach „RN1" findet den Trader `0x2005d16a84ceefa912d4e380cd32e7ff827875ea` (nach dessen nächstem Sync). *(A9)*
7. Watchlist: Toggle auf der Detailseite wechselt sichtbar den Zustand; die neue Watchlist-Seite listet die
beobachteten Trader; Remove funktioniert. *(A10)*
8. „Run Deep Analysis" (KI) füllt die AI Strategy Analysis auf der Detailseite tatsächlich. *(A10)*
---
### D1. `AggregatedCount`-Spalte (Grundlage für D2/D3)
- Migration: `Trades.AggregatedCount INT NULL` (NULL = einzelner Roh-Trade).
- Kompaktierung im `TradeRetentionWorker`: schreibt `AggregatedCount = Anzahl der ersetzten Trades`
(heute geht die Original-Anzahl verloren!) und summiert beim erneuten Kompaktieren
bestehende Aggregate (`Sum(t.AggregatedCount ?? 1)`).
- Engine: `trader.TotalTrades = trades.Sum(t => t.AggregatedCount ?? 1)` (ersetzt `trades.Count`),
`analytics.Trades30d` analog.
- **Test:** Kompaktierung von 5 Trades → 1 Aggregat mit `AggregatedCount = 5`; `TotalTrades` bleibt nach
Recalc 5, nicht 1.
### D2. TraderTraits — heuristische Strategie-Merkmale (ohne KI)
- Neue Tabelle `TraderTraits`: Id, TraderId (FK, Cascade), Trait (string ≤ 64), Value (decimal, Messwert),
ComputedAt. Unique-Index (TraderId, Trait). Pro Analyse-Lauf upserten, nicht mehr zutreffende Traits löschen.
- Berechnung als **pure Funktion** `TraderTraitCalculator.Compute(trader, trades, positions)` →
Liste (Trait, Value); Aufruf im `TraderAnalyticsWorker` nach Engine + Estimator aus den **bereits geladenen**
Daten — keine zusätzlichen API-Calls.
- Traits v1 (Trait vergeben, wenn Bedingung erfüllt; Value = Messgröße):
| Trait | Regel |
|---|---|
| `sub_second_cadence` | Median-Intervall < 2 s bei ≥ 50 Trades (Value = Median in s) |
| `always_on_24_7` | größte Inaktivitätslücke der letzten 7 Tage < 4 h bei ≥ 200 Trades/7d |
| `uniform_sizes` | Variationskoeffizient der Size (letzte 200 Trades) < 0,1 |
| `round_amounts` | > 60 % der Amounts ∈ {1,5,10,20,25,50,100,250,500,1000} ± 1 % |
| `uses_split_merge` | Anteil Split+Merge > 10 % |
| `both_sides_same_market` | Yes- UND No-Trades in > 20 % der Märkte |
| `resolution_farming` | > 30 % der Buys mit Price ≥ 0,93 UND < 48 h vor `ClosedAt` (min. 10 Buys) |
| `longshot_buyer` | > 30 % der Buys mit Price ≤ 0,10 |
| `scalper` | mediane Haltedauer < 1 h |
| `holds_to_resolution` | > 70 % der aufgelösten Positionen ohne vorherigen Sell |
| `fresh_wallet` | erster Trade < 30 Tage (Value = Alter in Tagen) |
| `stable_stake_fraction` | CV von Amount/EstimatedBankroll < 0,5 (nur bei Bankroll > 0) |
| `possible_insider` | siehe D2b — statistische Überraschung der Ergebnisse unter Marktpreisen |
- Schwellwerte als Konstanten im Calculator (v1 hart kodiert ist ok).
- API/UI: `TraderDto`/`TraderDetailDto` um `Traits` (string-Liste) erweitern; Detailseite zeigt Chips
unter dem Strategy-Feld.
- **Trait-Filter & -Suche (verbindlich, nicht optional):**
- `GET /api/traders` bekommt einen `trait`-Query-Parameter (mehrfach angebbar = UND-Verknüpfung),
serverseitig als `EXISTS`-Filter auf `TraderTraits` — nicht im Speicher filtern.
- Neuer Endpoint `GET /api/traits`: alle vorhandenen Traits mit Trader-Anzahl (für das Dropdown).
- Traders-Liste: Trait-Dropdown neben dem Platform-Filter, aktive Filter als entfernbare Chips.
- Die Trait-Chips auf der Detailseite sind klickbar und springen in die entsprechend gefilterte Liste.
- KI-Integration: `AiStrategyAnalysisService`-Prompt bekommt die Merkmalsliste; Trade-Beispiele von 50 auf
15 repräsentative reduzieren (5 größte, 5 jüngste, 5 zufällige) — die KI verifiziert Hypothesen statt zu raten.
- **Tests:** pure-Function-Tests je Trait, mindestens Positiv- UND Negativfall für `resolution_farming`,
`sub_second_cadence`, `uniform_sizes`, `both_sides_same_market`.
### D2b. Trait `possible_insider` — Insider-Verdacht erkennen (ergänzt 2026-07-11)
**Kernidee (wichtig, nicht vereinfachen):** Hohe Winrate allein ist KEIN Insider-Signal — die haben auch
Resolution-Farmer (Einstieg ≥ 0,90) und Glückspilze. Verdächtig ist, wenn die Ergebnisse unter der Annahme
„der Marktpreis war korrekt" statistisch unplausibel sind: **gewonnene Longshots zählen, gewonnene Favoriten
nicht.** Ein Trader, der fünfmal bei 0,15 kauft und fünfmal gewinnt, hat unter Marktpreisen eine
Wahrscheinlichkeit von 0,15^5 ≈ 0,00008 — DAS ist der Fingerabdruck.
**v1 (aus vorhandenen Daten, im `TraderTraitCalculator`):**
Grundfilter (alle müssen erfüllt sein):
- 5 ≤ abgeschlossene Märkte ≤ 100 (seltene Wetter, keine Vieltrader),
- Ø-Einsatz ≥ 500 USD **oder** ≥ 10 % der `EstimatedBankroll`,
- Trade-Frequenz < 5/Tag,
- Trait `resolution_farming` NICHT gesetzt (Ausschluss der harmlosen 95%-Winrate-Klasse).
Kernmetrik „Markt-Überraschung": über alle abgeschlossenen Märkte des Traders, mit p = Einstiegs-VWAP des
gehandelten Outcomes:
```
L = Π p(gewonnene Märkte) × Π (1 p)(verlorene Märkte)
Value = log10(L)
```
Trait vergeben, wenn: WinRate ≥ 85 % UND mittlerer Einstiegs-VWAP der GEWONNENEN Märkte ≤ 0,70 UND
Value ≥ 3 (d. h. L ≤ 1:1000 unter Marktpreisen). `Value` dient als Ranking („wie verdächtig") in der
gefilterten Liste — absteigend sortieren.
**v2 (späterer Ausbau, wenn Enrichment-Daten breiter vorliegen):**
- **Kauft VOR der News, nicht danach:** Anteil der Buys, nach denen der Preis binnen 2448 h ≥ 15 ct in
Trader-Richtung lief, während er VOR dem Kauf flach war (`PreTradePrice1m` ≈ Einstieg). Das trennt Insider
von News-Snipern, die erst nach der Bewegung einsteigen.
- **Einweg-Wallet:** erster Trade < 30 Tage, ≤ 3 Märkte insgesamt, nach dem Gewinn inaktiv
(Kombination mit `fresh_wallet`).
- **Themen-Konzentration:** ≥ 80 % des Volumens in einem einzigen Event oder engen Themencluster.
- **Zeitkritische Aggressivität:** hoher Taker-Anteil + hohe akzeptierte Slippage (wer weiß, dass morgen die
News kommt, kauft das Orderbuch leer statt zu limiten).
- **Nischenmärkte:** Einsatz groß relativ zu Marktliquidität/-volumen zum Kaufzeitpunkt.
**Produkt-Hinweis (Ausblick, nicht Teil dieses Auftrags):** `possible_insider`-Wallets + Watchlist + Alert
bei neuem Trade (der `AlertService` existiert) ergibt einen „Insider-Follow-Feed" — für Copytrading sind
diese Konten die wertvollsten Signalgeber überhaupt, auch wenn sie selten handeln.
**Tests:** Positivfall (5 gewonnene Longshots à 0,15, hohe Einsätze → Trait mit Value ≈ 4,1), Negativfälle:
Resolution-Farmer (Winrate 95 %, Einstiege 0,95 → KEIN Trait), Vieltrader mit 90 % Winrate (Frequenzfilter),
Kleinwetter (Einsatzfilter).
### D2c. Markt-Rendite-Metriken: Win-Edge UND Loss-Edge (ergänzt 2026-07-11)
**Motivation:** Die Winrate allein sagt nichts über das Auszahlungsprofil. Ein Arb-Grinder gewinnt 95 % seiner
Märkte mit je +2 %, ein Longshot-Halter gewinnt 25 % mit je +400 % — beide können profitabel sein, aber sie
sind völlig verschiedene Strategien und völlig verschieden zu kopieren. Die Rendite-Verteilung der
abgeschlossenen Märkte ist damit gleichzeitig Strategie-Fingerabdruck UND Grundlage für Position-Sizing
beim Copytrading.
**Zwei verbindliche Design-Entscheidungen:**
1. **Median führend, Durchschnitt zusätzlich.** Ein einziger 10x-Ausreißer verzerrt den Durchschnitt komplett;
der Median beschreibt den typischen Win. Die Differenz Durchschnitt↔Median ist selbst ein Signal
(Schiefe = seltene Lottogewinne vs. konstantes Mahlen).
2. **Die Verlustseite ist Pflicht, nicht optional.** „90 % Winrate mit +5 % pro Win" ist wertlos, wenn die
10 % Verluste je 100 % sind (genau das Profil von Resolution-Farmern!). Ohne Loss-Edge ist die Metrik
aktiv irreführend.
**Berechnung (in der Engine, im selben Durchlauf wie `CalculateMarketWinRates` — dort wird pro
geschlossenem Markt bereits `marketPnl` ermittelt):**
- Pro geschlossenem Markt: `invested` = Summe der Buy-Amounts (inkl. Split-Kosten) über die Outcomes des
Markts; `ReturnPct = marketPnl / invested × 100`. Märkte mit `invested <= 0` (z. B. Buy-Historie durch
Retention gepruned) überspringen — nicht mit 0 verfälschen.
- Aggregation über Wins (ReturnPct > 0) und Losses (ReturnPct < 0) getrennt.
**Neue Felder auf `TraderAnalytics` (Migration):**
- `MedianWinReturnPct`, `AvgWinReturnPct`
- `MedianLossReturnPct`, `AvgLossReturnPct` (negativ gespeichert)
- `ProfitFactor` (Σ Markt-Gewinne / Σ |Markt-Verluste|; `NULL` wenn keine Verluste)
**DTOs + UI:** Detailseite bekommt Karten „Median Win / Median Loss" (z. B. „+6,2 % / 38 %") und
„Profit Factor"; die Expectancy pro Markt (`WinRate×MedianWin (1WinRate)×|MedianLoss|`) kann das UI
daraus ableiten, wird nicht gespeichert.
**Abgeleitete Traits (in den `TraderTraitCalculator`, ergänzt die D2-Tabelle):**
| Trait | Regel |
|---|---|
| `thin_margin_wins` | Median-Win-Rendite < 10 % bei ≥ 20 gewonnenen Märkten (Arb-/Grinder-Profil) |
| `high_payoff_wins` | Median-Win-Rendite > 100 % bei ≥ 5 gewonnenen Märkten (Conviction-/Longshot-Profil) |
**Tests (pure Berechnung):**
- Bekannte Markt-Menge → korrekte Median-/Avg-/ProfitFactor-Werte für Win- UND Loss-Seite.
- Ausreißer-Robustheit: ein einzelner +1000 %-Win verschiebt den Median nicht.
- Markt mit gepruntem Buy (invested = 0) fließt nicht ein.
- Resolution-Farmer-Profil (viele kleine Wins, seltene 100 %-Losses) liefert ProfitFactor nahe 1 —
der Fall, den die Winrate allein verschleiert.
## Teil E — Master-Auswahl-Metriken & generischer Profil-Endpoint (ergänzt 2026-07-11)
> Hintergrund: Ein externer Konsument (Copytrading-Client) braucht eine belastbare
> Master-Trader-Auswahl (Prüfplan liegt in `PREDICTALYTICS-PRUEFPLAN-Master-Auswahl.md` im
> externen Repo). **Bewusste Architektur-Entscheidung: Die API bleibt generisch.**
> Kein kunden-spezifisches Format, kein „HOLDER/STOPLOSS"-Vokabular in der API —
> wir exponieren neutrale Metriken, der Konsument mappt selbst.
> Abgleich: M2/M8 existieren (D2c/CategoryPerformances), M3/M9 teilweise als Traits (D2),
> M1/M5/M6 teilweise; **komplett neu sind M4, M7, Out-of-Sample-Fenster, Kopierbarkeits-
> Aggregate und die Korrelations-Sicht** — das ist Teil E.
### E1. Generischer Profil-Endpoint `GET /api/traders/{id}/profile`
Ein Aufruf liefert das vollständige Analyse-Profil (statt 4+ Einzel-Calls):
Stammdaten + alle Analytics-Felder + Traits (D2) + Rendite-Metriken (D2c) +
Fenster-Blöcke (E2) + Fingerprint-Verteilungen (E3) + Kopierbarkeits-Aggregate (E5).
Nur persistierte Werte ausliefern (kein On-the-fly-Rechnen wie im Deep-Dive) —
der Endpoint muss schnell und pollbar sein. In `docs/API.md` dokumentieren.
### E2. Fenster-Metriken als Struktur + Out-of-Sample-Vergleich
**Problem:** Die 24h/7d/30d-Felder sind Einzelspalten; der Prüfplan braucht zwei frei
definierte Vergleichsfenster (z. B. Tag 180…−60 vs. 60…heute), um Glücks-Wallets
auszusortieren (nur wer in BEIDEN Fenstern liefert, ist ein Kandidat).
**Fix:**
- Neue Tabelle `TraderWindowMetrics`: TraderId, WindowStart, WindowEnd, ClosedMarkets,
WinRate, AvgReturnPct (M1: Ø realisierte Rendite je Markt), MedianWinReturnPct,
MedianLossReturnPct, ProfitFactor, ComputedAt. Unique (TraderId, WindowStart, WindowEnd).
- Berechnung im Analytics-Lauf für zwei konfigurierbare Fenster
(`AnalysisWindows`-Sektion in appsettings, Default: 180…−60 und 60…0 Tage).
Wiederverwendet die D2c-Logik mit Zeitfilter auf den Markt-Abschlusszeitpunkt.
- Im Profil (E1) als `windows[]`-Array. **Achtung Retention:** Fenster A reicht weiter
zurück als 90 Tage — Berechnung muss mit fehlender Historie ehrlich umgehen
(`closedMarkets` klein → Konsument sieht die dünne Stichprobe). Nach der
Retention-Verlängerung auf 180 Tage (D3) wird Fenster A tragfähig.
### E3. Fingerprint-Verteilungen (persistiert, im Profil)
Im Analytics-Lauf berechnen und als JSON-Spalte(n) auf `TraderAnalytics` oder eigene
Tabelle persistieren:
- **Preisband-Profil (M7):** Einsatz-Anteil je 10-¢-Einstiegspreisband über Buys,
**plus realisierte Winrate je Band** (macht Glück von System unterscheidbar:
„kauft 9095-¢-Shares, gewinnt 97 %" = +Edge sichtbar pro Band).
- **Haltedauer:** Median (nicht nur Ø) Stunden Kauf→Exit/Auflösung (M6).
- **Positionsgrößen:** P50/P90-Amount (M9-Basis).
- Trade-Frequenz je Woche (aus Trades30d ableitbar, im Profil ausgeben).
### E4. Exit-Verhalten klassifizieren (M4) — generisch als Trait + Kennzahl
- Kennzahl `stop_loss_ratio`: Anteil der Sells, die nach einem Preisrückgang von
≥ 10 % unter den Einstands-AvgCost erfolgen (Sell-Preis ≤ 0,9 × AvgCost),
bezogen auf alle geschlossenen Positionen. Braucht KEINE Preis-Historie —
Sell-Preis vs. AvgCost der Position reicht als v1-Näherung.
- Traits: `sells_at_loss` (Ratio > 0,15) ergänzt das vorhandene `holds_to_resolution`.
Ein Konsument bildet daraus selbst HOLDER (`holds_to_resolution` ∧ ¬`sells_at_loss`),
STOPLOSS, MIXED.
### E5. Kopierbarkeits-Aggregate (im Profil)
- `medianMarketVolumeUsd`: Median des `Market.Volume` (bzw. Volume24h) der vom Trader
gehandelten Märkte — handelt er in Kleinstmärkten, bewegt der Kopierer den Preis.
- `medianPostFillDriftPct`: Median-Preisänderung nach seinen Buys (aus vorhandenem
`TradeContext.PriceAfter1m`/`FollowerFillPrice60s`, nur enriched Trades; Anzahl
der Datenpunkte mit ausgeben).
- `netEdgeAfterFeesPct`: `AvgReturnPct` (E2, Fenster B) minus kategorie-gewichteter
Taker-Fee (aus `Market.FeeRateBps` — wird bereits erfasst!) minus konfigurierbarem
Spread-Aufschlag (`CopyCostSettings:SpreadPct`, Default 2,0). Generisch als
„Netto-Edge nach Kopierkosten" benannt.
### E6. Korrelations-/Portfolio-Sicht
- Neuer Endpoint `GET /api/traders/correlation?ids=1,2,3` (oder `?top=20`):
paarweise Jaccard-Ähnlichkeit über die gehandelten ConditionIds (Fenster B) +
Kategorie-Mix-Cosinus. Antwort: Matrix + je Paar die Overlap-Zahl.
- Kein „Portfolio-Empfehlungs"-Endpoint in v1 — die Auswahl-Logik (max. 2 je
Kategorie etc.) gehört zum Konsumenten. Wir liefern die Korrelationsdaten.
### E7. Martingale-Erkennung (M9) als Trait
`martingale_pattern`: mittleres Verhältnis Einsatz(nach verlorenem Markt) /
Einsatz(nach gewonnenem Markt) über die Sequenz der abgeschlossenen Märkte;
Trait ab Verhältnis ≥ 1,5 bei ≥ 20 Märkten. (Einsatz = investiertes Kapital je
Markt aus D2c.)
### E8. Abnahme Teil E
1. Bestehende Tests grün (Assertions unverändert) + neue Tests: Fenster-Metriken
(bekannte Markt-Menge, zwei Fenster → korrekte Werte je Fenster), Preisband-
Winrate (Band-Zuordnung + Ränder 0,895/0,90), `stop_loss_ratio`
(Positiv-/Negativfall), Jaccard-Berechnung, Martingale (steigende Einsätze nach
Losses → Trait; konstante → kein Trait).
2. `GET /api/traders/{id}/profile` liefert für einen analysierten Trader alle Blöcke
gefüllt; Antwortzeit < 200 ms (nur persistierte Daten).
3. `docs/API.md` um Profile-/Correlation-Endpoint ergänzt.
4. Kein kunden-spezifisches Vokabular in API/DTOs.
Reihenfolge: E1+E3 zuerst (Profil mit vorhandenen + Fingerprint-Daten), dann E2
(Fenster), E4/E5/E7 (Kennzahlen), E6 zuletzt. Teil E setzt D1/D2/D2c voraus.