Modul-08: Portfolio-Manager Doku (portfolio_v1, FAIL-CLOSED, atomare Race-Loesung, E2E final gruen)
This commit is contained in:
parent
b6afee27a2
commit
1219ec6f3a
1 changed files with 252 additions and 0 deletions
252
modul-08-portfolio-manager.md
Normal file
252
modul-08-portfolio-manager.md
Normal file
|
|
@ -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.
|
||||
Loading…
Reference in a new issue