138 lines
8.8 KiB
Markdown
138 lines
8.8 KiB
Markdown
# 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 0–15:
|
||
HOLD · SL→CLOSE · TP→CLOSE · 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 M03→M09:** 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.
|
||
|
||
## Testdaten / E2E-Fixtures
|
||
- Alle M18-E2E-Fixtures tragen ausschließlich das Präfix **`M18E2E2-…`** in `trade_journal`, `position_state`,
|
||
`position_action`, `execution_order` und M03 `ohlcv` (z. B. `M18E2E2-SL`, `M18E2E2-STALE`, …).
|
||
- M03-Testrest-Symbole (`M18E2E2-*` in `ohlcv`) werden **NICHT destruktiv gelöscht** (M03-Speicher hat keinen
|
||
sauberen Delete-Endpoint), sondern sind als **Testdaten** dokumentiert und klar vom Produktiv-Universe getrennt.
|
||
- **Kein Produktiv-Service/Scanner/Universe referenziert `M18E2E2`** (verifiziert: kein VPS-Config/Code-Code greift
|
||
es auf). M18 sendet nur CLOSE/REDUCE/CANCEL an bereits offene Positionen und erzeugt **kein OPEN** → Test-Symbole
|
||
können nie in ein produktives Signal-/Scanner-Universum gelangen.
|
||
- Produktionsdaten (nicht `M18E2E2-*`-Symbole) bleiben unberührt.
|
||
|
||
## 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.
|
||
```
|