Files
Predictalytics/FIXPLAN-G-Speicher.md
T

15 KiB
Raw Permalink Blame History

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

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:

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

&& t.Trader.IngestMode == IngestMode.Full

ändern zu

&& 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.

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

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

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

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

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 == 0AvgReturnPct == 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, ~4060 % kleiner):

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:

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