# FIXPLAN Teil G — Speicher-Budget & Kategorie-Edge (für Gemini 3.5 Flash) > Eigenständiger Plan. Von oben nach unten abarbeiten. Nach **jeder** Aufgabe bauen + testen + committen. --- ## 0. Regeln für den Umsetzer (WICHTIG, zuerst lesen) 1. **Test-Baseline:** `dotnet test src/Predictalytics.Application.Tests` liefert aktuell **39 erfolgreich, 1 übersprungen**. Nach jeder Aufgabe muss gelten: **alle bisher grünen Tests bleiben grün** + die neuen Tests der Aufgabe sind grün. Die eine übersprungene bleibt übersprungen. 2. **Bestehende Test-Assertions NIEMALS ändern.** Wird ein alter Test rot, ist DEIN Code falsch. 3. **Arbeite eine Aufgabe komplett fertig** (Code + Test + `dotnet build` + `dotnet test` grün), dann `git commit`, dann erst die nächste. 4. **Migrationen:** mit `dotnet ef migrations add -p src/Predictalytics.Infrastructure -s src/Predictalytics.Api` erzeugen. Die Design-Time-Factory liest die Ziel-DB aus Umgebungsvariablen (`PREDICTALYTICS_DB_SERVER/_NAME/_USER/_PASSWORD`). **Keine** SQL-DDL von Hand schreiben — immer EF-Migrationen. Neue DB-Spalten immer **nullable** oder mit Default anlegen. 5. **Neue Entity-Felder** müssen auch in `src/Predictalytics.Infrastructure/Data/AppDbContext.cs` konfiguriert werden, falls dort für die Entity bereits ein `mb.Entity<...>(e => {...})`-Block steht. 6. **Zahlen:** Prozente auf 0–100-Skala, Beträge in USD, Zeiten in UTC. 7. Wenn eine Anweisung unklar ist: die **kleinste, sicherste** Variante wählen, nichts „drumherum" umbauen. --- ## Aufgabe G1 — Speicher-Governor (budget-basierte Retention) **Ziel:** Die Datenbank soll unter einer konfigurierbaren Grenze (Default 90 GB) bleiben. Nähert sie sich der Grenze, wird das Retention-Fenster automatisch verkürzt. ### G1.1 — Reine Rechenfunktion (zuerst, voll testbar) Neue Datei `src/Predictalytics.Application/Services/StorageGovernor.cs`: ```csharp namespace Predictalytics.Application.Services; /// /// Pure helper: shrinks the retention window as the database approaches its size budget. /// No DB access here — fully unit-testable. /// public static class StorageGovernor { /// /// Returns the retention window (in days) to actually use. /// - Below 80% of the budget: the configured window (no tightening). /// - Between 80% and 100%: linearly interpolated down towards minRetentionDays. /// - At or above 100%: minRetentionDays. /// public static int ComputeEffectiveRetentionDays( double currentSizeGb, double maxSizeGb, int configuredRetentionDays, int minRetentionDays) { if (maxSizeGb <= 0 || configuredRetentionDays <= minRetentionDays) return configuredRetentionDays; double softStart = 0.80 * maxSizeGb; if (currentSizeGb <= softStart) return configuredRetentionDays; if (currentSizeGb >= maxSizeGb) return minRetentionDays; // linear interpolation between softStart (=configured) and maxSizeGb (=min) double t = (currentSizeGb - softStart) / (maxSizeGb - softStart); // 0..1 double days = configuredRetentionDays - t * (configuredRetentionDays - minRetentionDays); return (int)System.Math.Round(days); } } ``` ### G1.2 — Test Neue Datei `src/Predictalytics.Application.Tests/Services/StorageGovernorTests.cs`: ```csharp using Predictalytics.Application.Services; using Xunit; namespace Predictalytics.Application.Tests.Services; public class StorageGovernorTests { [Theory] [InlineData(10.0, 90.0, 90, 30, 90)] // far below budget -> full window [InlineData(72.0, 90.0, 90, 30, 90)] // exactly at 80% -> still full [InlineData(90.0, 90.0, 90, 30, 30)] // at budget -> min window [InlineData(100.0, 90.0, 90, 30, 30)] // over budget -> min window [InlineData(81.0, 90.0, 90, 30, 87)] // just into the zone -> slightly tightened public void ComputeEffectiveRetentionDays_Interpolates( double sizeGb, double maxGb, int configured, int min, int expected) { Assert.Equal(expected, StorageGovernor.ComputeEffectiveRetentionDays(sizeGb, maxGb, configured, min)); } [Fact] public void HalfwayIntoZone_IsBetweenMinAndConfigured() { // 85 GB of 90 (softStart 72) -> t ~0.72 -> days between 30 and 90 var days = StorageGovernor.ComputeEffectiveRetentionDays(85.0, 90.0, 90, 30); Assert.InRange(days, 31, 89); } } ``` ### G1.3 — In den Worker einbauen In `src/Predictalytics.Worker/Services/TradeRetentionWorker.cs`, Methode `RunOptimizationAsync`: - Zwei neue Config-Werte lesen (neben dem bestehenden `RetentionSettings:RetentionDays`): ```csharp var maxSizeGb = _config.GetValue("RetentionSettings:MaxDatabaseSizeGb", 90.0); var minRetention = _config.GetValue("RetentionSettings:MinRetentionDays", 30); ``` - Aktuelle DB-Größe per Scalar-Query ermitteln (Pomelo/MySQL): ```csharp double currentSizeGb = 0; try { currentSizeGb = (await db.Database.SqlQueryRaw( "SELECT COALESCE(SUM(data_length + index_length),0) / 1073741824.0 AS Value " + "FROM information_schema.tables WHERE table_schema = DATABASE()").ToListAsync(ct)).FirstOrDefault(); } catch (Exception ex) { _logger.LogWarning(ex, "Could not read DB size; using configured retention."); } ``` (Hinweis: `SqlQueryRaw` braucht `using Microsoft.EntityFrameworkCore;`. Die Spalte muss `Value` heißen, damit das Scalar-Mapping funktioniert.) - Das effektive Fenster berechnen und **statt** `retentionDays` verwenden: ```csharp var effectiveRetentionDays = StorageGovernor.ComputeEffectiveRetentionDays( currentSizeGb, maxSizeGb, retentionDays, minRetention); if (effectiveRetentionDays != retentionDays) _logger.LogWarning("Storage governor: DB {Size:F1} GB, retention tightened {From}d -> {To}d", currentSizeGb, retentionDays, effectiveRetentionDays); var retentionCutoff = utcNow.Date.AddDays(-effectiveRetentionDays); ``` Den bestehenden `retentionCutoff` durch diese Berechnung ersetzen. Der Rest der Methode bleibt. ### G1.4 — Config-Defaults In `src/Predictalytics.WinFormsHost/appsettings.json` unter `RetentionSettings` (Objekt anlegen falls fehlt) ergänzen: `"MaxDatabaseSizeGb": 90, "MinRetentionDays": 30`. (Nur Defaults; nichts Bestehendes löschen.) **G1 fertig, wenn:** neue Tests grün, alte grün, Build grün. Commit: „G1: storage governor (budget-based retention)". --- ## Aufgabe G2 — Retention räumt auch `Aggregated`-Trader auf **Problem:** In `TradeRetentionWorker.RunOptimizationAsync` filtert das Pruning heute auf `t.Trader.IngestMode == IngestMode.Full`. Dadurch werden die (stündlich aggregierten) Trades von `Aggregated`-Tradern **nie** gelöscht und wachsen unbegrenzt. `SnapshotOnly`-Trader haben ohnehin kaum Trades. `Watchlist`-Trader bleiben ausgenommen. **Fix:** Beide Vorkommen von ```csharp && t.Trader.IngestMode == IngestMode.Full ``` ändern zu ```csharp && t.Trader.IngestMode != IngestMode.SnapshotOnly ``` (damit werden `Full` **und** `Aggregated` geprunt; `SnapshotOnly` weiterhin nicht, Watchlist weiterhin ausgenommen). **Test** (`src/Predictalytics.Application.Tests/Services/`, neue Datei oder in `TradeRetentionWorkerTests`): Baue mit dem vorhandenen SQLite-Muster (siehe `TradeRetentionWorkerTests`) einen `Aggregated`-Trader mit einem alten Trade (älter als Retention, kein Watchlist-Eintrag) und rufe `RunOptimizationAsync` per Reflection auf. Danach: der alte Trade ist gelöscht. ```csharp // Trader: IngestMode = IngestMode.Aggregated, keine WatchlistEntries // Trade: ExecutedAt = baseDate.AddDays(-200), MarketOutcomeId gesetzt // nach RunOptimizationAsync: Assert.Empty(db.Trades ... für diesen Trade) ``` **G2 fertig, wenn:** neuer Test grün, alte grün. Commit: „G2: retention prunes Aggregated tier too". --- ## Aufgabe G3 — `usdcSize` + `outcomeIndex` persistieren (günstig, optional zuerst überspringbar) > ⚠️ Diese Aufgabe fasst den **Massen-Insert-Pfad** an. Wenn du dir bei der Parameter-Indizierung > unsicher bist, überspringe G3 und mach G4 zuerst — G3 ist nicht kritisch. Der Test unten fängt > einen kaputten Insert ab. **Ziel:** Die Felder `usdcSize` (exakter Cash-Betrag) und `outcomeIndex` (0/1) werden von der API bereits geparst (`PolymarketTradeResponse.UsdcSize`, `PolymarketTradeResponse.OutcomeIndex`), aber nicht gespeichert. ### G3.1 — Entity + Config + Migration - In `src/Predictalytics.Domain/Entities/Trade.cs` zwei nullable Felder ergänzen: ```csharp public decimal? UsdcSize { get; set; } public int? OutcomeIndex { get; set; } ``` - Migration `AddTradeUsdcAndOutcomeIndex` erzeugen. ### G3.2 — Im Provider mappen In `src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs` in **beiden** Trade-Mappings (`GetTraderTradesAsync` ~Zeile 42, `GetMarketTradesAsync` ~Zeile 80) im `new Trade { ... }` ergänzen: ```csharp UsdcSize = (decimal)r.UsdcSize, OutcomeIndex = r.OutcomeIndex, ``` ### G3.3 — Massen-Insert erweitern (VORSICHT) In `src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs`, Methode `AddRangeAsync`: Der handgeschriebene `INSERT IGNORE` listet die Spalten explizit auf. Du musst **drei** Dinge synchron ändern: 1. Die Spaltenliste im Header-String um `, UsdcSize, OutcomeIndex` **am Ende** ergänzen. 2. Die pro Zeile erzeugte Klammer hat aktuell **15** Parameter (`@p{pIdx}`..`@p{pIdx+14}`) → auf **17** erhöhen (`@p{pIdx+15}`, `@p{pIdx+16}`), und den Stride `int pIdx = i * 15;` auf `i * 17` ändern. 3. Nach den bestehenden `parameters.Add(...)`-Zeilen pro Trade **zwei** neue in gleicher Reihenfolge: ```csharp parameters.Add(t.UsdcSize ?? (object?)null); parameters.Add(t.OutcomeIndex ?? (object?)null); ``` Wenn die Spaltenanzahl im Header nicht exakt zur Parameteranzahl pro Zeile passt, schlägt der Insert fehl. ### G3.4 — Test (Sicherheitsnetz gegen kaputten Insert) Neue Datei `src/Predictalytics.Application.Tests/Services/TradeRepositoryInsertTests.cs` — SQLite, füge über `AddRangeAsync` einen Trade mit `UsdcSize=12.5m, OutcomeIndex=1` ein und lies ihn zurück: ```csharp // Assert: der zurückgelesene Trade hat UsdcSize == 12.5m und OutcomeIndex == 1 ``` (Falls `AddRangeAsync` MySQL-spezifisches `INSERT IGNORE` nutzt, das SQLite nicht kennt: dann diesen Test mit der In-Memory- oder MySQL-Testinfrastruktur ausführen, ODER den Test auf das Provider-Mapping beschränken — Hauptsache, die Persistenz der zwei Felder ist einmal geprüft.) **G3 fertig, wenn:** Test grün, alte grün. Commit: „G3: persist usdcSize + outcomeIndex on Trade". --- ## Aufgabe G4 — Per-Kategorie-Edge (hoher Analysewert, speicher-günstig) **Ziel:** Pro Trader und Kategorie soll sichtbar werden, **wie viel Edge** der Trader dort hat (Rendite in % des eingesetzten Kapitals). Das beantwortet „in welcher Kategorie ist er gut" und ist direkt für Copytrading nutzbar. Voraussetzung ist der Kategorie-Fix aus Teil F — G4 funktioniert aber auch schon davor, nur mit vielen `Other`-Einträgen. ### G4.1 — Entity + Migration In `src/Predictalytics.Domain/Entities/TraderCategoryPerformance.cs`: - Neues gespeichertes Feld: `public decimal TotalInvested { get; set; }` (Summe der Buy-Amounts in abgeschlossenen Märkten dieser Kategorie). - Neue **berechnete** Property (nicht gespeichert): ```csharp public decimal AvgReturnPct => TotalInvested > 0 ? TotalPnL / TotalInvested * 100m : 0m; ``` - Migration `AddCategoryTotalInvested`. ### G4.2 — Berechnung in der Engine In `src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs`, Methode `CalculateCategoryPerformances`: dort wird pro abgeschlossenem Markt bereits `marketPnl` bestimmt und `perf.TotalPnL += marketPnl` gesetzt. **An derselben Stelle** zusätzlich das investierte Kapital summieren: ```csharp // invested = Summe der Buy-Amounts der Trades dieses Markts (nur Buy-Seite) var invested = marketGroup.Where(t => t.Side == TradeSide.Buy).Sum(t => t.Amount); perf.TotalInvested += invested; ``` Und beim Upsert der bestehenden Kategorien-Zeile (im `RecalculateTraderPositionsAsync`, wo `existing.TotalPnL = kvp.Value.TotalPnL;` etc. gesetzt wird) ergänzen: ```csharp existing.TotalInvested = kvp.Value.TotalInvested; ``` ### G4.3 — DTO + API In `src/Predictalytics.Application/DTOs/TraderDto.cs` das Record `TraderCategoryPerformanceDto` um `decimal AvgReturnPct` erweitern (am Ende anhängen). In `src/Predictalytics.Application/Services/AnalyticsService.cs` beim Erzeugen des DTOs (`new TraderCategoryPerformanceDto(...)`) `p.AvgReturnPct` mitgeben. ### G4.4 — Test Neue Datei `src/Predictalytics.Application.Tests/Services/CategoryEdgeTests.cs` (SQLite-Muster wie in `MarketRepositoryTests`): ein Trader, ein aufgelöster Markt einer Kategorie, Buy 100 @ 0.40 (=40 USD), Gewinn-Auflösung (Payout 1.0) → RealizedPnl 60, TotalInvested 40 → nach Recalc muss die `TraderCategoryPerformance` dieser Kategorie `TotalInvested == 40` und `AvgReturnPct == 150` haben. Zweiter Fall: Kategorie mit `TotalInvested == 0` → `AvgReturnPct == 0` (keine Division durch 0). **G4 fertig, wenn:** Test grün, alte grün. Commit: „G4: per-category edge (AvgReturnPct)". --- ## Anhang — Einmalige DB-Optimierungen (führt der NUTZER als SQL aus, NICHT als Code) > Diese DDL macht Richard selbst auf der Live-DB (er betreibt DB-SQL ohnehin selbst). **Nicht** als > EF-Migration umsetzen. Vorher Backup. Nacheinander, außerhalb der Stoßzeiten. **A) InnoDB-Kompression auf der größten Tabelle (verlustfrei, ~40–60 % kleiner):** ```sql ALTER TABLE Trades ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8; ``` **B) Monats-Partitionierung von `Trades` (macht Retention zu instant `DROP PARTITION`):** > Voraussetzung: alle Unique-/Primary-Keys müssen die Partitionsspalte (`ExecutedAt`) enthalten. > Das ist ein größerer Umbau der Schlüssel — nur mit sorgfältigem Test auf der DEV-DB > (`lqf7.your-database.de`) durchführen. Grobskizze: ```sql -- Beispielhaft; exakte Schlüsselanpassung vorher auf DEV testen! ALTER TABLE Trades PARTITION BY RANGE (TO_DAYS(ExecutedAt)) ( PARTITION p_2026_06 VALUES LESS THAN (TO_DAYS('2026-07-01')), PARTITION p_2026_07 VALUES LESS THAN (TO_DAYS('2026-08-01')), PARTITION p_future VALUES LESS THAN MAXVALUE ); -- Alte Daten löschen wird dann: ALTER TABLE Trades DROP PARTITION p_2026_06; ``` **C) (Optional, fortgeschritten) Hex-Spalten binär speichern:** `TransactionHash`/`ConditionId` als `BINARY(32)` statt `VARCHAR(66)`. Spart ~50 % auf diesen Spalten + Indizes, aber erfordert Datenmigration (Hex→Binär) und Code-Anpassung beim Lesen/Schreiben. **Erst angehen, wenn A+B nicht reichen.** --- ## Gesamt-Abnahme (alles zusammen) 1. `dotnet build` fehlerfrei. 2. `dotnet test src/Predictalytics.Application.Tests`: **alle vorher grünen Tests weiter grün (Baseline 39/1)** + alle neuen G-Tests grün, 1 übersprungen bleibt übersprungen. Keine Assertion eines Alt-Tests geändert. 3. Storage-Governor greift: bei simulierter DB-Größe nahe `MaxDatabaseSizeGb` wird das Fenster verkürzt (G1-Test beweist die Rechenlogik). 4. `Aggregated`-Trader-Trades werden von der Retention erfasst (G2-Test). 5. Trader-Detail zeigt je Kategorie einen `avgReturnPct` (G4). **Reihenfolge:** G1 → G2 → G4 → (G3 optional zuletzt). G3 nur machen, wenn der Massen-Insert-Test danach grün ist — sonst zurückrollen.