8.8 KiB
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/CANCELan M09 geben. NIEOPEN/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, idempotentpublisher/publisher.py— publiziert Aktionen an M09 (trade.approved, je Aktion eine eindeutigeaction_id)consumer/consumer.py— konsumiert M09-Execution-Events (order.filledetc.) für Reconciliationpricing/client.py— liest Preise von M03, inkl. Stale-Guardproviders/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 Positionposition_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)
- action_id statt Journal-pd_id: M09 speichert die M18-
action_idalssource_portfolio_decision_id— Verifikation musswait_execution_for_action(action_id)nutzen, nicht Journal-pd_id. - Audit-Eventname: Consumer schreibt
ORDER_EXECUTED(bzw.ORDER_FILLED); Test akzeptiert beide. - Szenario 10 RA/RB: Verifikation über
action_id, nichtpd_id. - Stale-Szenario:
stale_price_max_age_seconds=3600; eine 5-min-Kerze ist NICHT stale. M03 akzeptiert bis 30 Tage alte Kerzen, persistiert injiziertestsaber 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_eventappend-only). - Port 55018 nur Docker-intern (
expose), kein öffentliches Mapping. Netztrading-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, alleFILLED,broker=paper, keine Doppel-Order je action_id. - Health/Security:
/health+/readiness200; 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-…intrade_journal,position_state,position_action,execution_orderund M03ohlcv(z. B.M18E2E2-SL,M18E2E2-STALE, …). - M03-Testrest-Symbole (
M18E2E2-*inohlcv) 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-HostModul-01-PostgreSQL(Modul-01), RabbitMQModul-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.