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

257 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 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.