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

8.8 KiB
Raw Blame History

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-Verbindungself._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 · 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.