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