Files
Predictalytics/docs/archiv/FIXPLAN-DONE.md
RichardandClaude Opus 5 6975720dc6 Eine Roadmap statt neun Plandokumente; alte Plaene ins Archiv
Die offenen Punkte lagen ueber neun Dokumente verstreut, teils widersprechend,
teils mit Punkten, die laengst umgesetzt waren. ROADMAP.md fuehrt sie zusammen:
sechs Stufen, jeder Punkt gegen den Code geprueft.

Markierung ueber eine Legende, damit Konzepte nicht mit Aufgaben verwechselt
werden: dringend, eingeplant, Backlog, Konzept (durchdacht, aber bewusst nicht
eingeplant), liegt beim Nutzer, verworfen.

Stufen: 0 Sofort (OpenRouter-Key) - 1 Aufraeumen abschliessen (Merge nach main,
Nullable-Warnungen, Startup-Backfill) - 2 Portierung abschliessen (Linux-Erstlauf
und Verifikation, dann Deployment/CI) - 3 Analytik schaerfen (Strategie-
Klassifikation, Backtest-Harness, Track-Record im Score, Ranking) - 4 Daten-
haushalt (SQL des Nutzers) - 5 Ingest skalieren - 6 Monetarisierung vorbereiten.

Ein Anhang haelt fest, was geprueft und bewusst NICHT auf die Roadmap kam, damit
es nicht versehentlich wieder als Aufgabe auftaucht (Azuro/Limitless, die
Marketing-Seiten, Kaltarchiv, EF Core 10).

Archiv: FIXPLAN-DONE, FIXPLAN-G-Speicher, FIXPLAN-TODO, FIXPLAN-UI-Ranglisten,
UMSETZUNGSPLAN sowie ANALYSE-Linux-Portierung, PLAN-Linux-Portierung,
PLAN-Architektur-WebUI-Backend und PLAN-DatenIngest-Skalierung liegen jetzt
unter docs/archiv/ mit einer README, die jedes Dokument einordnet. Sie bleiben
als Begruendungs- und Detailquelle - die Roadmap nennt jeden Punkt knapp, die
Herleitung steht dort.

Dabei aufgefallen: die beiden nie gepflegten Plaene (Architektur, DatenIngest)
zeigten 40 offene Punkte, von denen die Haelfte umgesetzt war - Read/Control-
Split, /api/capabilities, CORS-Whitelist, Egress-Kanaele mit Cooldown und
Per-Kanal-Limiter, Ingest-Tiering. Nachgeprueft, abgehakt und mit Statusblock
eingeordnet, sonst waere das Archiv selbst eine Fehlerquelle.

Was dabei nur teilweise umgesetzt war, ist als Roadmap 5.3 aufgenommen
(Health-Statistik je Kanal, multi-homed pruefen, DB-Guard, Plausibilitaets-
pruefung), ebenso der nie durchgefuehrte Audit auf versteckte Writes in
Read-Endpunkten (6.1). Header-Rotation ist als verworfen markiert statt offen
zu bleiben: Verschleierung gegenueber Polymarket riskiert genau den Zugang, auf
dem das Projekt aufsetzt.

STATUS.md beschreibt jetzt nur noch den Ist-Stand und verweist fuer die offenen
Punkte auf die Roadmap, damit nichts doppelt gepflegt wird. CLAUDE.md nennt
beide Einstiege.

Build gruen, 126 Tests gruen, keine toten Links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:46:00 +02:00

561 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.