diff --git a/FIXPLAN-G-Speicher.md b/FIXPLAN-G-Speicher.md new file mode 100644 index 0000000..06f34e7 --- /dev/null +++ b/FIXPLAN-G-Speicher.md @@ -0,0 +1,319 @@ +# 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.