--- type: Trading-Modul-System _organized: true --- # 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.