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>
This commit is contained in:
Richard
2026-08-23 18:46:00 +02:00
co-authored by Claude Opus 5
parent 1f230fe12c
commit 6975720dc6
14 changed files with 429 additions and 73 deletions
+335
View File
@@ -0,0 +1,335 @@
# FIXPLAN Teil G — Speicher-Budget & Kategorie-Edge (für Gemini 3.5 Flash)
> **✅ ABGESCHLOSSEN — G1 bis G4 sind umgesetzt.** Am 2026-08-23 gegen den Code geprüft:
> * **G1** Speicher-Governor — `Application/Services/StorageGovernor.cs`, angewendet in
> `TradeRetentionWorker.cs:89`; `MaxDatabaseSizeGb`/`MinRetentionDays` werden gelesen
> (`:74,75`). Tests in `StorageGovernorTests.cs` und `TradeRetentionWorkerTests.cs`.
> * **G2** Retention räumt auch `Aggregated`-Trader — `TradeRetentionWorker.cs`, Test vorhanden.
> * **G3** `UsdcSize` auf `Trade`, `OutcomeIndex` auf `MarketOutcome` — beide persistiert,
> Test in `TradeRepositoryTests.cs`.
> * **G4** Per-Kategorie-Edge — `AvgReturnPct` bis in die Trader-Detailansicht
> (`TraderEndpoints.cs:80`, `app.js:913`).
>
> Der **Anhang** („Einmalige DB-Optimierungen") bleibt offen: das sind SQL-Schritte, die
> laut Absprache der Nutzer selbst gegen die Datenbank fährt, kein Code.
>
> Die unten genannte Test-Baseline (39/1) ist überholt — Stand 2026-08-23 sind es
> **126 grün + 1 übersprungen**.
> 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`:
```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.Api/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, ~4060 % 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.