Files
Predictalytics/FIXPLAN-2026-07-09.md
T
RichardandClaude Fable 5 d2f3ec2bd0 Fix category mapping and TotalTrades drift, plan traits/tiering (Teil D)
- MarketCategoryMapper: classify from question text (the Gamma /markets
  endpoint delivers neither category nor event tags, so on-demand markets
  had no signal at all), match short tokens on word boundaries ("eth" no
  longer hits inside "whether", "pop" not inside "popular"), widen the
  keyword lists across all categories.
- UpdateMarketFields: never overwrite a tag-derived category with an
  uninformative "Other" from the on-demand path.
- PositionPnLEngine: sync Trader.TotalTrades to the actual replayed row
  count — the worker-side increment counters drift (INSERT IGNORE,
  deletions, historic imports) and produced Trades30d > TotalTrades.
- Tests: 14 new (mapper classification + word-boundary regression,
  TotalTrades sync + Trades30d invariant, category update guard via
  SQLite) — suite now 32 green + 1 skip.
- FIXPLAN Teil D for the larger rebuilds (AggregatedCount column,
  TraderTraits heuristics, IngestMode tiering for ultra-HF traders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:09:12 +02:00

411 lines
26 KiB
Markdown
Raw 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)*
---
## Teil D — Ausbaustufe: Merkmals-Tags & HF-Trader-Tiering (ergänzt 2026-07-11)
> **Bereits direkt erledigt (nicht Teil dieses Auftrags):** `TotalTrades` wird jetzt von der Engine aus dem
> echten Row-Count gesetzt; der Kategorie-Mapper klassifiziert zusätzlich über den Frage-Text und matcht kurze
> Tokens nur an Wortgrenzen; `UpdateMarketFields` überschreibt gute Kategorien nicht mehr mit "Other".
> Teststand: **32 grün + 1 Skip** — das ist die neue Basis, Assertions unverändert lassen.
### D1. `AggregatedCount`-Spalte (Grundlage für D2/D3)
- Migration: `Trades.AggregatedCount INT NULL` (NULL = einzelner Roh-Trade).
- 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) |
- 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; Traders-Liste bekommt einen Trait-Filter.
- 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`.
### D3. Trader-Tiering (`IngestMode`) — Umgang mit Ultra-HF-Tradern (RN1, Swisstony)
**Hintergrund:** Ultra-HF-Trader werden heute schon NICHT vollständig erfasst (PollingWorker: 100 Trades/60 s
gegen 300+/min) — das Trade-Replay-PnL ist für diese Klasse bereits falsch und frisst nur Speicher.
- Enum `IngestMode { Full = 0, Aggregated = 1, SnapshotOnly = 2 }` + Spalte auf `Trader` (Default Full), Migration.
- **Klassifizierung** im `TradeHistoryWorker` nach jedem Fetch: Zeitspanne der letzten 500 Trades →
Trades/Tag-Schätzung. > 5.000/Tag → SnapshotOnly; > 100/Tag → Aggregated. Hysterese: Rückstufung Richtung
Full erst nach 7 Tagen unter der halben Schwelle (kein Flattern).
- **SnapshotOnly (Tier C):**
- Polling/History-Worker überspringen den Trade-Import komplett.
- Stündlich: `GetTraderPositionsAsync` (der ungenutzte `/positions`-Endpoint!) → `TraderPositions` upserten
(size→SharesHeld, avgPrice→AvgCost, cashPnl→RealizedPnl); `OverallPnL` aus Positions +
`GetLeaderboardAsync`-PnL für die Zeitfenster; `TraderDailySnapshot` weiter schreiben (Equity-Kurve bleibt).
- Wöchentliche „Biopsie": einmal 500 Trades via /activity ziehen, NUR durch den `TraderTraitCalculator`
schicken, NICHT persistieren.
- Engine überspringt Trade-Replay für SnapshotOnly; Estimator/Enrichment überspringen; CopytradingScore = 0
mit Trait `not_copyable_hf`.
- **Aggregated (Tier B):** Aggregation beim Import statt nachträglicher Kompaktierung: Bucket
(TraderId, MarketOutcomeId, Side, Stunde) mit VWAP-Preis, Summen-Size/-Amount, `AggregatedCount`; gespeichert
als normale Trade-Zeile mit `PlatformTradeId = "AGG_{traderId}_{outcomeId}_{side}_{yyyyMMddHH}"`, laufende
Stunde per Upsert aktualisieren. Average-Cost-Engine bleibt damit verlustfrei.
- Danach: `RetentionDays` für Full-Trader auf 180 erhöhen (Config) — die Bots stellen nicht mehr die Masse,
und längerer Track-Record nützt genau den kopierbaren Tradern.
- **Tests:** Klassifizierungs-Schwellen + Hysterese als pure Funktion; PollingWorker importiert für
SnapshotOnly-Trader nichts; Aggregations-Upsert ist idempotent (2× dieselbe Stunde → 1 Zeile, korrekte Summen
und `AggregatedCount`).
### D4. Abnahme Teil D
1. `dotnet test`: alle bestehenden **32 + 1 Skip** bleiben grün (Assertions unverändert) + die neuen D-Tests.
2. RN1/Swisstony stehen nach der Einstufung auf SnapshotOnly: PnL gefüllt (aus /positions/Leaderboard),
Traits gesetzt, **keine neuen Trade-Zeilen** mehr in der DB.
3. Detailseite zeigt Trait-Chips; Traders-Liste filterbar nach Trait.
4. Tägliches DB-Wachstum sichtbar reduziert (DB-Size-Anzeige im WinForms-Statusbar beobachten).
Reihenfolge: **D1 → D2 → D3** (bei D3 zuerst Tier C, dann Tier B).