trading-system-docs/notes/trading/system-docs/modul-08-portfolio-manager.md

9.5 KiB
Raw Blame History

type _organized
Trading-Modul-System 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.pyevaluate_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 0408): 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.