From 1219ec6f3a54b4ee40bfccaab67d180e05b43514 Mon Sep 17 00:00:00 2001 From: Rain Ocampo Date: Thu, 20 Aug 2026 08:02:14 +0000 Subject: [PATCH] Modul-08: Portfolio-Manager Doku (portfolio_v1, FAIL-CLOSED, atomare Race-Loesung, E2E final gruen) --- modul-08-portfolio-manager.md | 252 ++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 modul-08-portfolio-manager.md diff --git a/modul-08-portfolio-manager.md b/modul-08-portfolio-manager.md new file mode 100644 index 0000000..5f520b5 --- /dev/null +++ b/modul-08-portfolio-manager.md @@ -0,0 +1,252 @@ +# Modul-08: Portfolio-Manager + +**Status: FREIGEGEBEN** (20.08.2026) + +Deterministischer Portfolio-Manager. Konsumiert `RISK_APPROVED` aus Modul-07, +prüft das Gesamtportfolio gegen zentrale Limits und publiziert +`TRADE_APPROVED` oder `PORTFOLIO_REJECTED`. + +**Keine KI/ML. Keine Broker-Order. FAIL-CLOSED.** + +--- + +## 1. Rolle in der Kette + +``` +Modul-03 Ingest → 04 Regime → 05 Signal → 06 Ranking + → SIGNAL_RANKED (market.rankings/signal.ranked) + → Modul-07 Risk-Check → RISK_APPROVED (market.risk/risk.approved) + → Modul-08 Portfolio-Check → portfolio_decision-Tabelle + → TRADE_APPROVED / PORTFOLIO_REJECTED (market.portfolio) +``` + +Modul-08 prüft **NICHT erneut** die Positionsgrößenlogik aus Modul-07, +sondern ausschließlich **Portfolio-Risiken**. + +--- + +## 2. Events + +| Richtung | Exchange | Routing-Key | Event | +|----------|----------|-------------|-------| +| Input (Consumer) | `market.risk` | `risk.approved` | `RISK_APPROVED` | +| Output (Publisher) | `market.portfolio` | `trade.approved` | `TRADE_APPROVED` | +| Output (Publisher) | `market.portfolio` | `portfolio.rejected` | `PORTFOLIO_REJECTED` | + +Consumer-Queue: `portfolio.input` (durable, manuelles Ack, prefetch=1). + +--- + +## 3. V1 Portfolio-Regeln (zentral konfigurierbar, env-overridable) + +| Regel | Config-Feld | Default | +|-------|-------------|---------| +| max. Anzahl offener Positionen | `max_positions` | 5 | +| max. gesamtes Open Risk % | `max_open_risk_percent` | 0.05 (5%) | +| max. Exposure pro Position % | `max_exposure_per_position_percent` | 0.20 | +| max. Exposure pro Symbol % | `max_exposure_per_symbol_percent` | 0.30 | +| max. Exposure pro Assetklasse % | `max_exposure_per_asset_class_percent` | 0.50 | +| max. Long-Exposure % | `max_long_exposure_percent` | 0.60 | +| max. Short-Exposure % | `max_short_exposure_percent` | 0.60 | +| Duplikat Symbol/Strategie erlauben | `allow_duplicate_symbol_strategy` | false | +| Korrelation anwenden | `apply_correlation` | false (vorbereitet) | +| Account Equity | `account_equity` | 10000.0 | + +Alle Limits sind Prozentwerte des `account_equity`. Bei `account_equity <= 0` +→ FAIL-CLOSED `PORTFOLIO_REJECTED`. + +**Später geplant** (V2): Sector/Country/Currency-Exposure, Korrelation +(erst wenn verlässliche Daten vorhanden). + +--- + +## 4. FAIL-CLOSED + +Jede Exception, fehlende oder ungültige kritische Eingabe führt zu +`PORTFOLIO_REJECTED`, **niemals** zu `TRADE_APPROVED`. Fehlende kritische +Felder (quantity, risk, entry) → `PORTFOLIO_REJECTED` mit Reason-Codes. + +--- + +## 5. Portfoliozustand (autoritative Quelle) + +Der Portfoliozustand wird **NICHT nur aus Events angenommen**. Die +Architektur trennt die Quelle des Zustands vom Entscheidungs-Code: + +- `app/portfolio/state.py` definiert `PortfolioStateProvider` (Interface) + und `PortfolioState` / `OpenPosition`. +- V1-Provider liest offene Positionen aus der eigenen `portfolio_decision`- + Tabelle (alle `TRADE_APPROVED`-Entscheidungen). +- **Später** kann ein Broker-/Execution-Provider als autoritative Quelle + ergänzt werden, ohne den Entscheidungs-Code zu ändern. + +--- + +## 6. Race-Condition-Schutz (atomar) + +Zwei nahezu gleichzeitige `RISK_APPROVED` dürfen **nicht** beide auf demselben +alten Portfoliozustand genehmigt werden und dadurch Limits überschreiten. + +Lösung (`app/storage/storage.py` → `evaluate_and_save_atomic`): +1. **Advisory Lock** (`pg_advisory_xact_lock`) serialisiert parallele + Portfolio-Entscheidungen. +2. Innerhalb **einer Transaktion**: Zustand lesen → Engine bewerten → + speichern → Commit. +3. Der zweite Prozess wartet auf den Lock und bewertet auf dem **aktualisierten** + Zustand (inkl. der ersten Entscheidung). + +--- + +## 7. Idempotenz + +- Unique-Index `uq_portfolio_decision_src` auf `source_risk_decision_id` + (partial, nur wo nicht NULL). +- Gleiches `source_risk_decision_id` → keine Doppelentscheidung/Event. +- Zusätzlich prüft der Service vor der Verarbeitung, ob die Quelle bereits + entschieden wurde (Skip + Ack). + +--- + +## 8. DB-Tabelle `portfolio_decision` + +Jede Entscheidung wird gespeichert — **auch `PORTFOLIO_REJECTED`**. + +| Spalte | Typ | Beschreibung | +|--------|-----|--------------| +| `id` | BIGSERIAL PK | | +| `portfolio_decision_id` | TEXT | eigene Entscheidungs-ID | +| `source_risk_decision_id` | TEXT | id des RISK_APPROVED (Modul-07) | +| `source_ranking_id` | TEXT | durchgereichtes Ranking (Modul-06) | +| `source_signal_id` | TEXT | durchgereichtes Signal (Modul-05) | +| `source_event_id` | TEXT | | +| `correlation_id` | TEXT | | +| `timestamp` | TIMESTAMPTZ | Entscheidungszeitpunkt (UTC) | +| `symbol` | TEXT | | +| `asset_class` | TEXT | | +| `strategy` | TEXT | | +| `direction` | TEXT | LONG / SHORT | +| `timeframe` | TEXT | | +| `proposed_quantity` | DOUBLE | aus RISK_APPROVED | +| `proposed_risk` | DOUBLE | actual_risk_amount | +| `proposed_exposure` | DOUBLE | qty × entry | +| `current_portfolio_risk` | DOUBLE | offenes Risiko VOR | +| `new_portfolio_risk` | DOUBLE | offenes Risiko NACH | +| `exposure_before` | DOUBLE | Gesamt-Exposure VOR | +| `exposure_after` | DOUBLE | Gesamt-Exposure NACH | +| `decision` | TEXT | TRADE_APPROVED / PORTFOLIO_REJECTED | +| `reason_codes` | TEXT[] | z. B. `{OK}` oder `{EXPOSURE_PER_POSITION_EXCEEDED,...}` | +| `rule_version` | TEXT | `portfolio_v1@1.0.0` | +| `details` | JSONB | Begründung + Zwischenwerte | +| `portfolio_snapshot` | JSONB | Portfoliozustand zum Zeitpunkt | +| `created_at` | TIMESTAMPTZ | | + +--- + +## 9. Reason-Codes + +| Code | Bedeutung | +|------|-----------| +| `OK` | alle Limits eingehalten → TRADE_APPROVED | +| `MAX_POSITIONS_EXCEEDED` | max. offene Positionen erreicht | +| `OPEN_RISK_EXCEEDED` | gesamtes Open Risk überschritten | +| `EXPOSURE_PER_POSITION_EXCEEDED` | Exposure pro Position überschritten | +| `EXPOSURE_PER_SYMBOL_EXCEEDED` | Exposure pro Symbol überschritten | +| `EXPOSURE_PER_ASSET_CLASS_EXCEEDED` | Exposure pro Assetklasse überschritten | +| `LONG_EXPOSURE_EXCEEDED` | Long-Exposure überschritten | +| `SHORT_EXPOSURE_EXCEEDED` | Short-Exposure überschritten | +| `DUPLICATE_SYMBOL_STRATEGY` | doppelte Position Symbol/Strategie | +| `QUANTITY_MISSING` / `RISK_AMOUNT_MISSING` / `ENTRY_MISSING` | fehlende kritische Daten (FAIL-CLOSED) | +| `EQUITY_MISSING` | account_equity fehlt/ungültig (FAIL-CLOSED) | + +--- + +## 10. Architektur + +``` +app/ + config.py # zentrale Konfiguration (env-overridable) + core/ + models.py # PortfolioDecision + Event-Modelle + service.py # Orchestrator (Consumer→Engine→Storage→Publisher) + portfolio/ + engine.py # Registry der Portfolio-Verfahren + portfolio_v1.py # deterministische V1-Prüfung (FAIL-CLOSED) + state.py # PortfolioState-Provider (autoritative Quelle) + storage/ + storage.py # idempotent + atomare Race-Condition-Lösung + publisher/ + publisher.py # frische Verbindung je Publish + close + consumer/ + consumer.py # manuelles Ack, Reconnect-Backoff, Zombie-Schutz + api/ + main.py # FastAPI (Health/Readiness/Decisions/State) +migrations/ + 001_portfolio_decision.sql +Dockerfile +requirements.txt +test_portfolio.py # 11 Unit-Tests (Engine-Logik) +``` + +--- + +## 11. Tests + +### Unit-Tests (`test_portfolio.py`, 11/11 grün) +1. erstes gültiges Portfolio-Trade → TRADE_APPROVED +2. max Positions überschritten → PORTFOLIO_REJECTED +3. max Open Risk überschritten → PORTFOLIO_REJECTED +4. Symbol-Exposure überschritten → PORTFOLIO_REJECTED +5. Long/Short-Exposure-Limit → PORTFOLIO_REJECTED +6. Duplicate Symbol/Strategie → PORTFOLIO_REJECTED +7. fehlender Portfoliozustand → FAIL-CLOSED +8. Idempotenz (deterministisch) +9. Assetklassen-Exposure → PORTFOLIO_REJECTED +10. Exposure pro Position → PORTFOLIO_REJECTED +11. Short-Exposure → PORTFOLIO_REJECTED + +### E2E (Kette 03→04→05→06→07→08, alle Checks grün) +- **A**: Kette 03→08, Portfolio-Decision in DB (LONG & SHORT) +- **B**: exakt 1 Portfolio-Event je Symbol (TRADE_APPROVED/PORTFOLIO_REJECTED) +- **C**: Pflichtfelder in Portfolio-Decision vorhanden +- **D**: Idempotenz (identisches RISK_APPROVED → kein Doppel-Decision) +- **E**: parallele RISK_APPROVED → beide verarbeitet, keine Race-Verlust +- **F**: erstes gültiges Portfolio-Trade → TRADE_APPROVED + +### Reconnect-Test +RabbitMQ gestoppt → Consumer erkennt Ausfall, Reconnect-Backoff (1s→2s→4s→8s). +RabbitMQ gestartet → Consumer verbindet sich neu, alle 5 Queues haben exakt +1 Consumer, Health/Readiness 200. + +--- + +## 12. Health / Readiness + +- `GET /health` → Liveness (immer 200, Komponentenstatus im Body) +- `GET /health/ready` → Readiness (200 nur wenn PG + RabbitMQ erreichbar) +- `GET /decisions/{symbol}` → gespeicherte Entscheidungen (inkl. REJECTED) +- `GET /portfolio-rules` → verfügbare Portfolio-Regeln +- `GET /portfolio/state` → aktueller Portfoliozustand + +Port: **55008** (nur Docker-intern, kein öffentlicher Host-Port). + +--- + +## 13. Deploy + +- Compose-Service: `modul-08-portfolio-manager` +- Image: `portfolio-manager:0.1.0` +- Container: `Modul-08-Portfolio-Manager` +- Netz: `trading-modules` +- `restart: unless-stopped` +- Env: PG + RabbitMQ-Zugangsdaten (aus Compose) + +--- + +## 14. Wichtige Fixes (20.08.2026) + +**Consumer-Bug (alle Module 04–08):** `process_data_events(time_limit=None)` +verarbeitete nur EIN Event und baute danach die Verbindung neu auf. Der +erneute `_connect()` führte `queue_delete` aus und löschte wartende Events +(z. B. ein zweites, nahezu gleichzeitiges RISK_APPROVED) unwiederbringlich. +Fix: innere Schleife `while not stop: process_data_events(time_limit=1.0)` +hält die Verbindung am Leben. Betroffen und gefixt: Modul-04, 05, 06, 07, 08.