# 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 <Name> -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;

/// <summary>
/// Pure helper: shrinks the retention window as the database approaches its size budget.
/// No DB access here — fully unit-testable.
/// </summary>
public static class StorageGovernor
{
    /// <summary>
    /// 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.
    /// </summary>
    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<double>(
          "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<double>` 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.
