# 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 ~500–1000 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. 10–20 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)*