trading-system-docs/modul-18-position-manager.md

128 lines
8 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.

# Modul-18: Position-Manager
**Status: FREIGEGEBEN (21.08.2026)** · Container `Modul-18-Position-Manager` · Port `55018` (nur intern/expose)
## Zweck
Modul-18 verwaltet **bereits offene Positionen** deterministisch: Es beobachtet offene Trades und gibt auf Basis fester
Exit-Regeln (Stop-Loss, Take-Profit, Break-even, Trailing, Time-Exit) gezielte **Exit-Aktionen** an Modul-09.
**Kritische Grenze:** M18 ist ein **reiner Exit-/Positions-Manager**, KEIN Signal-/Einstiegsmodul.
- ✅ M18 darf NUR `REDUCE` / `CLOSE` / `CANCEL` an M09 geben. **NIE `OPEN` / `INCREASE`.**
- ❌ Kein eigenmächtiges Nachkaufen/Pyramiding. Keine KI/ML. Keine direkte Broker-Anbindung.
- Jede Order-Aktion läuft ausschließlich über **M09 Execution** via RabbitMQ `trade.approved`.
- **FAIL-CLOSED:** stale/unbekannter Zustand → nie blind senden; Restart/Reconnect erzeugt keine Doppelaktion; keine Zombies.
## Architektur / Datenfluss
```
Modul-03-Market-Data (Preise) ──┐
Modul-10-Trade-Journal (OPEN) ──► Modul-18-Position-Manager (Port 55018, NUR intern)
Modul-15 (Trading-State, readonly) │
├── Engine-Loop (deterministische Exit-Regeln)
├── Publisher → RabbitMQ market.portfolio / trade.approved → M09
└── Consumer ← RabbitMQ market.execution / order.filled (Reconciliation)
Modul-09-Execution-Service (PaperBroker: broker=paper) → order.filled
```
**App-Struktur (`app/`):**
- `core/engine.py` — Exit-Regel-Engine (Regel-Priorität deterministisch)
- `core/orchestrator.py` — Hydrate-State, wendet Regeln an, idempotent
- `publisher/publisher.py` — publiziert Aktionen an M09 (`trade.approved`, je Aktion eine eindeutige `action_id`)
- `consumer/consumer.py` — konsumiert M09-Execution-Events (`order.filled` etc.) für Reconciliation
- `pricing/client.py` — liest Preise von M03, inkl. **Stale-Guard**
- `providers/journal.py` — Quelle offener Positionen (Trade-Journal, `status=OPEN`)
- `storage/storage.py` — Persistenz (`position_state`/`position_action`/`position_event`)
- `api/main.py` — REST-API (`/health`, `/readiness`), Port 55018 nur intern
**Eigene Tabellen (`migrations/001_position.sql`):**
- `position_state` — aktueller verwalteter Zustand je Position
- `position_action` — jede an M09 gegebene Order-Aktion (REDUCE/CLOSE/CANCEL)
- `position_event` — append-only Audit-Event-Historie
## Exit-Regeln (deterministisch)
| Regel | Trigger | Aktion |
|-------|---------|--------|
| Stop-Loss | Preis ≤ stop_loss | CLOSE |
| Take-Profit | Preis ≥ target | CLOSE |
| Break-even | Preis günstig, SL auf Entry gesetzt | CLOSE bei BE-SL |
| Trailing | Preis weiter günstig (best_price_seen) | CLOSE bei Trailing-SL |
| Time-Exit | max. Haltedauer erreicht | CLOSE |
| Partial-Fill / Duplikat | idempotente Aktion | exakt 1 Order, keine Doppelaktion |
| HALTED (M15) | Trading-State HALTED | CLOSE weiterhin erlaubt (Exit) |
| stale Preis | Kerze älter als `stale_price_max_age_seconds` (3600s) | **FAIL-CLOSED: keine Aktion** |
Regeln laufen mit fester Priorität; jede Exit-Aktion wird als eindeutige `position_action` (action_id) persistiert.
## M09-CLOSE-Pfad (Reduktion → Execution)
```
M18 position_action (action_id, CLOSE)
→ Publisher → RabbitMQ exchange "market.portfolio" routing "trade.approved"
→ M09 Execution (control/client bypass für REDUCE/CLOSE/CANCEL) → PaperBroker (broker=paper)
→ execution_order (source_portfolio_decision_id = M18 action_id, status FILLED)
→ RabbitMQ "market.execution" routing "order.filled"
→ M18-Consumer → Reconciliation (position_state → CLOSED, qty=0, Audit-Event)
```
**Verifikation:** Für jede Exit-Aktion existiert **exakt 1** execution_order mit `broker=paper`, `status=FILLED`,
`source_portfolio_decision_id` = M18 `action_id`. M09 speichert die M18-`action_id` als
`source_portfolio_decision_id` (top-level + Payload). Keine Doppelaktionen je action_id (HAVING count>1 = 0).
## Reconciliation über order.filled
M18 konsumiert M09-Execution-Events (`order.filled`, `order.partially_filled`, `order.rejected`, `order.failed`)
und gleicht den eigenen `position_state` ab: FILLED → Position als `CLOSED` markieren, `quantity=0`, Audit-Event
`ORDER_EXECUTED`/`ORDER_FILLED`. Damit sind M10 (Journal) und M18 (Manager) konsistent, auch wenn M10 CLOSE
nicht automatisch verknüpft.
## Thread-Local-DB-Fix (Root Cause „connection pointer is NULL")
**Symptom:** Reconciliation scheiterte sporadisch mit `connection pointer is NULL`.
**Root Cause:** `PositionStorage._conn` war eine **geteilte Singleton-Verbindung** zwischen Engine-Loop-Thread und
ExecutionConsumer-Thread. psycopg2 ist **nicht thread-safe**: ein Thread schloss die Verbindung
(`resolve_position_key`/`has_pending_exit`), der andere Thread lief auf totem Cursor.
**Fix:** **Thread-Local-Verbindung**`self._tl = threading.local()`; jeder Thread erhält eine eigene
psycopg2-Verbindung; kein Cross-Thread-`.close()`. `close()`/`rollback()` (ensure_schema) auf lokale Verbindung umgestellt.
**Verifikation:** Unit-Tests **13/13 grün**, E2E **47/47 PASS**.
## Testdesign-Lessons (E2E)
1. **action_id statt Journal-pd_id:** M09 speichert die M18-`action_id` als `source_portfolio_decision_id`
Verifikation muss `wait_execution_for_action(action_id)` nutzen, nicht Journal-`pd_id`.
2. **Audit-Eventname:** Consumer schreibt `ORDER_EXECUTED` (bzw. `ORDER_FILLED`); Test akzeptiert beide.
3. **Szenario 10 RA/RB:** Verifikation über `action_id`, nicht `pd_id`.
4. **Stale-Szenario:** `stale_price_max_age_seconds=3600`; eine 5-min-Kerze ist NICHT stale. M03 akzeptiert
bis 30 Tage alte Kerzen, persistiert injiziertes `ts` aber nicht (vergibt frisches ts). Fix: 2h-alte Kerze
(>1h < 30Tage) injizieren + **pro-Lauf eindeutiges Symbol** (M03-Speicher hat keinen Delete-Endpoint).
## Safety (User-Vorgabe, kritisch)
- **NUR REDUCE/CLOSE/CANCEL** niemals OPEN/INCREASE. Kein Nachkaufen/Pyramiding.
- Idempotent + race-safe + **FAIL-CLOSED**. Restart/Reconnect keine Doppelaktion. Keine Zombies.
- Broker/Execution unklar nie blind erneut senden.
- Vollständiger Audit-Trail (`position_event` append-only).
- Port 55018 nur Docker-intern (`expose`), kein öffentliches Mapping. Netz `trading-modules`.
- Keine Secrets in Logs/API/DB.
## Tests & E2E-Verifikation (VPS, 21.08.2026)
- **Unit-Tests** `tests/test_engine.py`: **13/13 grün** (Thread-Local-Fix).
- **E2E** `tests/e2e_m18_full2.py`: **47/47 PASS** (EXIT=0), deterministischer Lauf 015:
HOLD · SLCLOSE · TPCLOSE · Break-even · Trailing · Time-Exit · Partial-Fill · bereits CLOSED · Duplikat ·
parallel/race-safe (RA/RB) · M09 down · M15 HALTED · **stale Preis (FAIL-CLOSED)** · Restart · Reconciliation order.filled.
- Für jede Exit-Aktion: exakt 1 Execution je action_id, `broker=paper`, `status=FILLED`, keine Doppelaktionen,
keine offenen Zombie-Testpositionen.
- **Regression M03M09:** M18 erzeugt **NIE OPEN/INCREASE** (nur CLOSE). M09 execution_order:
nur `CLOSE`, alle `FILLED`, `broker=paper`, keine Doppel-Order je action_id.
- **Health/Security:** `/health` + `/readiness` 200; Port 55018 nur intern; keine Secrets in
Logs/API/DB; keine Tracebacks seit finalem Lauf; Consumer/RMQ-Reconnect sauber.
## Compose / Betrieb
- Netzwerk `trading-modules`, DB-Host `Modul-01-PostgreSQL` (Modul-01), RabbitMQ `Modul-02-RabbitMQ`.
- Port 55018 nur `expose` (Docker-intern), kein Host-Mapping. `restart: unless-stopped`.
- Keine Secrets im Compose-Env.
---
## Geändert
```
Geändert von: Rain Ocampo
Datum: 21.08.2026
Grund: Modul-18-Position-Manager formal abgeschlossen + FREIGEGEBEN. E2E 47/47 PASS, Regression M03→M09 gruen
(nur CLOSE, nie OPEN/INCREASE), Health/Security gruen (Port 55018 intern, keine Secrets/Tracebacks).
Thread-Local-DB-Fix (psycopg2 "connection pointer is NULL" geteiltes Singleton → Thread-Local). Doku angelegt.
```