Add FIXPLAN Teil G (storage budget + per-category edge) for Gemini 3.5 Flash
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
04596f95a6
commit
c3e66d6cca
@@ -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 <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.
|
||||||
Reference in New Issue
Block a user