Files
Predictalytics/docs/archiv/FIXPLAN-DONE.md
T
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

35 KiB
Raw Blame History

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-traderPOST /api/jobs/sync/{id}, btn-analyze-traderPOST /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:
    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 NULLMarketOutcomeHelper.IsWinningOutcome liefert immer false → jeder Redeem und jeder virtuelle Payout bucht Auszahlung 0jeder 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:

// 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:
    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)

-- 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 SicherungRetentionSettings: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

  1. 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;
    
  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.