Win rate alone hides the payoff profile: an arb grinder (95% wins at +2%) and a longshot holder (25% wins at +400%) need entirely different copy strategies. D2c adds median+avg return of won AND lost closed markets, profit factor, UI cards, and two derived traits (thin_margin_wins, high_payoff_wins). Median leads (outlier-robust), the loss side is mandatory — high win rates with rare total losses (resolution farming) are exactly what the win rate alone obscures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
32 KiB
Fix- und Datenreparatur-Plan (Stand 2026-07-09, Übergabe an Gemini)
Abnahmekriterium für alle Code-Änderungen:
dotnet test src/Predictalytics.Application.Testsmuss 16 grün + 1 übersprungen liefern (der SkipCheckpointResetAndReplay_DoesNotDoubleCountBalanceist 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:
- Die Buttons der Trader-Detailseite nutzen alte, Job-lose Endpoints (die Listen-Buttons nutzen bereits das Job-System).
- 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,0KbeiTotalPnL = 158,5K, weil der Basis-Snapshot-85,5Kenthält (korrupter Altwert). Kein Code-Fix ändert das — die Daten müssen einmalig repariert werden. - 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:
manualUpdateTraderinsrc/Predictalytics.Api/wwwroot/js/app.js(~Zeile 110) baut die URL mit Backslashes:fetch(`\api\traders\${id}\refresh`)— in JS-Template-Literals ist\tein 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 keinenBackgroundJoban (der Alert behauptet es aber) und führt nur die PnL-Engine aus, wederCopytradingEstimatornoch KI.
Fix:
btn-sync-trader→POST /api/jobs/sync/{id},btn-analyze-trader→POST /api/jobs/analyze/{id}(bestehende FunktionenqueueHistorySync(id)/queueTraderAnalysis(id)wiederverwenden).- Alert-Texte ehrlich machen (Job-Id anzeigen oder auf die Jobs-Seite verweisen).
- Die Endpoints
/{id}/force-analyzeund/{id}/refreshentweder 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
LIMITbei Multi-Table-UPDATE — Pattern mit Subquery verwenden:In einer Schleife ausführen, bis 0 Zeilen betroffen sind (mit kurzem Delay zwischen den Batches).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; GetMarketAsync-Fehler pro Markt fangen und loggen — die restlichen Märkte des Batches weiterverarbeiten.- Das Checkpoint-Reset (
LastAppliedTradeId = 0) weiterhin nur für Positionen mitIsHistoryPruned = 0(ist bereits so umgesetzt — nicht regressieren, TestPrunedPositionWithResetCheckpoint_DoesNotDoubleCountwacht 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 alsoffset, da Offset-Limits der API umgangen werden.)limit=500verwenden. Jede Seite über den vorhandenenIRateLimiterdrosseln. - Neuer
JobType.DeepResync(Migration für Enum nicht nötig, Enum ist int): DerTradeHistoryWorkerbehandelt ihn wieHistorySync, lädt aber ALLE Seiten. - Vor dem Import im DeepResync-Pfad für den Trader aufräumen (sonst Doppelzählung!):
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),- alle
TraderPositionsdes Traders löschen (inklusiveIsHistoryPruned = 1— die Konserve wird durch den vollständigen Re-Import ersetzt), - 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 mitIsHistoryPruned-Positionen oderCOMPACT_-Trades, Watchlist zuerst, dann nachTotalTradesabsteigend.
- 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.ResolutionOutcomeist für jeden MarktNULL→MarketOutcomeHelper.IsWinningOutcomeliefert immerfalse→ 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.Closeddegeneriert zuIsResolved = 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):
- Model: In
GammaMarketResponsedas toteresolution_outcome-Mapping entfernen,[JsonPropertyName("umaResolutionStatus")] public string? UmaResolutionStatusergänzen. - 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 wennIsResolved; sonstNULL).
- Preise parsen, dann:
- 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:
ResolutionOutcomegesetzt oder ein Outcome-Preis des Marktes ≥ 0.98 (dann gilt das Outcome mit Preis ≥ 0.98 als Gewinner, z. B. via erweitertemMarketOutcomeHelper). 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:
PolymarketTradeResponse:[JsonPropertyName("name")] public string? Nameund[JsonPropertyName("pseudonym")] public string? Pseudonymergänzen.Trade: transientes Feld[NotMapped] public string? TransientDisplayName(analogTransientWallet); imPolymarketProvider-Mapping mitname, Fallbackpseudonym, befüllen.PollingWorkerundTradeHistoryWorker: nach dem Fetch, wenn ein nicht-leererTransientDisplayNamevorliegt und vom aktuellenDisplayNameabweicht →trader.DisplayNameaktualisieren (die Plattform ist die Quelle der Wahrheit; Platzhalter wie0x…heilen sich damit von selbst).DiscoveryService.ImportTraderAsync(manuelles Hinzufügen): direkt beim Import die erste Activity-Seite abrufen und den Namen setzen, statt des Wallet-Präfixes.- Die Suche (
TraderRepository.SearchAsync) durchsuchtDisplayNamebereits — 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:
// 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:
api()-Helper reparieren: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; } }- Route-Template fixen:
"\{id:int}/ai-analysis"→"/{id:int}/ai-analysis". Danach das gesamte Projekt nach weiteren Backslash-Pfaden absuchen (grep -rn '"\\{' src/undgrep -n '\\\\api' wwwroot/js/app.js) — das ist jetzt der dritte Fall dieser Fehlerklasse. - Neuer Endpoint
GET /api/watchlist: liefert Watchlist-Einträge mit Trader-Kerndaten (TraderId, DisplayName, Label, Notes, CreatedAt, TotalPnl, WinRate, CopytradingScore). - 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)
-- 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):
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)
-- 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
- Invarianten-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; - Plausibilitäts-Stichprobe gegen Polymarkets eigene Zahlen: kleiner Dev-Endpoint
GET /api/dev/verify-positions/{traderId}, derGetTraderPositionsAsync(Polymarkets/positionsliefert deren berechnetesize/avgPrice/percentPnl) mit unserenTraderPositionsvergleicht und Abweichungen5 % listet. 10–20 aktive Trader stichproben.
- 24 h Logs beobachten: keine 1213-Deadlocks, keine ERR-Kaskaden bei Shutdown, Jobs-Seite zeigt Durchsatz.
Teil C — Abnahmekriterien (gesamt)
dotnet test: 18 grün + 1 skip (inkl. der beiden A8-Tests), Assertions unverändert.- „Sync"/„Analyze" auf der Detailseite erzeugen sichtbare Einträge auf der Jobs-Seite, die auch abgearbeitet werden.
- Nach B3/B4: kein Trader mehr mit
|PnL30d| > 0beiTrades30d = 0; PnL30d/Total-Verhältnisse plausibel. - Quality Edge / Copyability auf der Detailseite ≠ 0 für analysierte Trader mit verlinkten Trades.
- 24 h Betrieb ohne Deadlock-Errors und ohne Shutdown-Fehlerkaskaden.
- Die Suche nach „RN1" findet den Trader
0x2005d16a84ceefa912d4e380cd32e7ff827875ea(nach dessen nächstem Sync). (A9) - Watchlist: Toggle auf der Detailseite wechselt sichtbar den Zustand; die neue Watchlist-Seite listet die beobachteten Trader; Remove funktioniert. (A10)
- „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):
TotalTradeswird 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: schreibtAggregatedCount = 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)(ersetzttrades.Count),analytics.Trades30danalog. - Test: Kompaktierung von 5 Trades → 1 Aggregat mit
AggregatedCount = 5;TotalTradesbleibt 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 imTraderAnalyticsWorkernach 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/TraderDetailDtoumTraits(string-Liste) erweitern; Detailseite zeigt Chips unter dem Strategy-Feld. - Trait-Filter & -Suche (verbindlich, nicht optional):
GET /api/tradersbekommt einentrait-Query-Parameter (mehrfach angebbar = UND-Verknüpfung), serverseitig alsEXISTS-Filter aufTraderTraits— 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_farmingNICHT 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 24–48 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:
- 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).
- 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 mitinvested <= 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,AvgWinReturnPctMedianLossReturnPct,AvgLossReturnPct(negativ gespeichert)ProfitFactor(Σ Markt-Gewinne / Σ |Markt-Verluste|;NULLwenn 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 − (1−WinRate)×|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.
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 aufTrader(Default Full), Migration. - Klassifizierung im
TradeHistoryWorkernach 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!) →TraderPositionsupserten (size→SharesHeld, avgPrice→AvgCost, cashPnl→RealizedPnl);OverallPnLaus Positions +GetLeaderboardAsync-PnL für die Zeitfenster;TraderDailySnapshotweiter schreiben (Equity-Kurve bleibt). - Wöchentliche „Biopsie": einmal 500 Trades via /activity ziehen, NUR durch den
TraderTraitCalculatorschicken, 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 mitPlatformTradeId = "AGG_{traderId}_{outcomeId}_{side}_{yyyyMMddHH}", laufende Stunde per Upsert aktualisieren. Average-Cost-Engine bleibt damit verlustfrei. - Danach:
RetentionDaysfü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
dotnet test: alle bestehenden 32 + 1 Skip bleiben grün (Assertions unverändert) + die neuen D-Tests.- RN1/Swisstony stehen nach der Einstufung auf SnapshotOnly: PnL gefüllt (aus /positions/Leaderboard), Traits gesetzt, keine neuen Trade-Zeilen mehr in der DB.
- Detailseite zeigt Trait-Chips; Traders-Liste filterbar nach Trait. 3b. Detailseite zeigt Median-Win/-Loss-Rendite und Profit Factor; die D2c-Werte sind für Trader mit abgeschlossenen Märkten gefüllt.
- Tägliches DB-Wachstum sichtbar reduziert (DB-Size-Anzeige im WinForms-Statusbar beobachten).
Reihenfolge: D1 → D2/D2b/D2c → D3 (D2c ist klein und gehört in denselben Engine-Durchlauf wie die Winrate; bei D3 zuerst Tier C, dann Tier B).