trading-system-docs/modul-19-broker-reconciliation.md
Red Queen 35446c03df feat(tolaria): C3I final individual decisions — reconcile modul-12 + migrate modul-18/19 root-only to schema v1
C3I FINAL INDIVIDUAL DECISIONS:
- modul-12: CONTROLLED ROOT<->CANONICAL RECONCILIATION + PAIRED MIGRATION.
  Canonical auf aktuellen Root-Stand (inkl. Phase 10e Intrabar/Gap Execution)
  gebracht, Root unveraendert, atomare Unit, derived_from=Root-ID.
- modul-18/19: ROOT-ONLY MIGRATION (kein Canonical-Partner).
- vps.md: NICHT migriert/löschen/archivieren, DEFERRED_STUB_CLEANUP dokumentiert.
Body byte-genau. IDs stabil. Knowledge schema v1. (Red Queen)
2026-08-25 19:27:07 +00:00

10 KiB

knowledge_schema id type role representation state
1 object/3f834840-867d-9e9f-c7d2-8f1caf1550a4 arch module source current

Modul-19: Broker-Reconciliation

Status: FREIGEGEBEN (21.08.2026) · Container Modul-19-Broker-Reconciliation · Port 55019 (nur intern/expose)

Zweck

Modul-19 gleicht die interne Trading-Realität (M10 Trade-Journal, M18 Position-Manager) gegen die Broker-/Execution-Realität ab. Es erkennt Diskrepanzen (fehlende/überschüssige Positionen, Mengen-, Richtungs- oder Statusabweichungen) und erzeugt daraus Audit + Alarm, ohne selbst zu handeln.

Kritische Grenze: M19 ist ein reiner Reconciliation-/Monitoring-Dienst, KEIN Trade-Ausführer.

  • M19 erzeugt NIE OPEN / INCREASE (keine automatische Reparatur, kein Ordering).
  • M19 schreibt ausschließlich in eigene Audit-Tabellen (reconciliation_*), liest fremde Tabellen read-only.
  • Keine KI/ML. Keine direkte Broker-Anbindung in V1.
  • FAIL-CLOSED: unklare/unerreichbare Broker-Realität → STALE/UNKNOWN/Alarm + Audit, nie blind handeln.
  • Keine Zombie-Konsumenten; Restart/Reconnect erzeugt keine Doppel-Events (idempotent).

V1-Grenze: „Brokerrealität“ = rekonstruiert aus M09 execution_order

⚠️ WICHTIG (V1-Akzeptanz): In V1 gibt es keinen echten Broker-Adapter. Die „Brokerrealität" wird rekonstruiert aus der von M09 persistierten Tabelle execution_order (PaperBroker hält sein Orderbuch nur In-Memory; die einzig nachweisbare Quelle ist die DB-Persistenz).

Das ist für V1 akzeptabel, aber NICHT gleichwertig mit echter Broker-Reconciliation. Echte Broker-Reconciliation (Abruf von Live-Positionen/Fills beim Broker/MT5) folgt nach M19 über einen Demo-Broker-Adapter (Plugin am BrokerStateProvider-Interface).

Architektur / Datenfluss

M09 Execution (execution_order, status SUBMITTED/PARTIALLY_FILLED/FILLED) ──┐  Broker-Realität (V1)
M10 Trade-Journal (trade_journal, status=OPEN) ─────────────────────────────┤  interne Realität
M18 Position-Manager (position_state, position_key=trade_id) ───────────────┘
        │
        ▼
Modul-19-Broker-Reconciliation (Port 55019, NUR intern)
   ├── BrokerStateProvider (Interface, V1=paper)  → Broker-Positionen (netto je Symbol)
   ├── InternalStateReader  → interne offene Positionen (Journal + M18 state)
   ├── ReconciliationEngine → Klassifikation je Symbol/Position
   ├── Storage  → reconciliation_run / reconciliation_result / reconciliation_event (idempotent)
   └── Publisher → RabbitMQ exchange "reconciliation" routing "reconciliation.alert" (additiv, M15 kann später konsumieren)

App-Struktur:

  • broker/base.pyBrokerStateProvider-Interface (V1=paper, später live/demo-Adapter)
  • broker/paper.py — V1-Paper-Provider: rekonstruiert Broker-Positionen aus execution_order (read-only)
  • broker/registry.py — Provider-Registry
  • core/internal_state.py — liest interne offene Positionen (M10 + M18)
  • core/engine.py — Reconciliation-Klassifikation (deterministisch)
  • core/service.py — Loop-Orchestrator, publiziert kritische Alarme, Trade-Actions hart deaktiviert
  • storage/storage.py — Persistenz (eigene Tabellen), Thread-Local-DB (psycopg2 nicht thread-safe)
  • publisher/publisher.py — Alarm-Publish auf RMQ (additive Schnittstelle)
  • api/main.py — REST-API (/health, /health/ready, /reconcile, /reconcile/runs, /reconcile/results), Port 55019 intern

Eigene Tabellen (migrations/001_reconciliation.sql):

  • reconciliation_run — Lauf (run_id PK, status RUNNING/COMPLETED/FAILED, totals)
  • reconciliation_result — Ergebnis je Position/Symbol (idempotent: UNIQUE(run_id, position_key))
  • reconciliation_event — append-only Alarm-/Audit-Events

Klassifikationen (deterministisch, V1)

Klassifikation Bedingung Konsequenz
MATCH intern offen == Broker offen (Symbol, Richtung, Menge) kein Event
QUANTITY_MISMATCH Broker-Menge ≠ interne Menge CRITICAL-Event
DIRECTION_MISMATCH Broker-Richtung ≠ interne Richtung CRITICAL-Event
MISSING_BROKER intern offen, Broker hat Position CRITICAL-Event
MISSING_INTERNAL Broker offen, intern kein Datensatz CRITICAL-Event
STATUS_MISMATCH intern kennt Position, aber CLOSED/abweichend vs. Broker offen CRITICAL-Event
PARTIAL_FILL Broker PARTIALLY_FILLED → Menge berücksichtigt (Konsistenz)
STALE / UNKNOWN Broker-Realität unklar/unerreichbar FAIL-CLOSED, kein Trade, Alarm + Audit

Hinweis (Design): MISSING_INTERNAL = Broker offen, intern hat gar keinen Datensatz. STATUS_MISMATCH = intern kennt die Position, aber sie ist CLOSED/abweichend während der Broker offen ist (vorher fälschlich MISSING_INTERNAL). Beides sind kritische Abweichungen, die als reconciliation.alert-CRITICAL gemeldet werden.

Interne Positionswahrheit (Kernregel, 21.08.2026)

⚠️ Kernregel (User, 21.08.2026): Eine externe Broker-Position darf nur MATCH sein, wenn eine echte interne OPEN-Position existiert. Interne Positionswahrheit = position_state (M18). trade_journal/execution_order allein dürfen KEINE interne offene Position vortäuschen.

Vorher (Bug): get_internal_open() las trade_journal (M10) als Basis. Ein trade_journal-Eintrag mit status='OPEN' galt als intern offen, auch ohne position_state (M18). Folge: IG-Position existierte, intern nur execution_order/trade_journal → M19 klassifizierte fälschlich MATCH mit qty 0 (Fake-MATCH).

Nachher (Fix): get_internal_open() liest primär position_state (M18) als Treiber. Ein trade_journal-Eintrag ohne position_state zählt nicht als intern offen → Broker-Position wird MISSING_INTERNAL statt Fake-MATCH. CLOSED position_state-Einträge werden mitgeliefert, damit die Engine STATUS_MISMATCH erkennen kann.

Erwartete Klassifikation (Kernregel):

Situation Klassifikation
IG OPEN + kein position_state MISSING_INTERNAL
IG OPEN + position_state OPEN + gleiche qty/direction MATCH
IG OPEN + position_state CLOSED STATUS_MISMATCH
IG OPEN + andere qty QUANTITY_MISMATCH
IG OPEN + andere direction DIRECTION_MISMATCH
Intern OPEN + IG flat MISSING_BROKER

Wichtig: qty=0 darf niemals zu MATCH führen (Engine: internal_open erfordert qty>0).

Safety (hart)

  • allow_trade_actions ist hart False (Konfiguration + API erzwingen; /reconcile lehnt ab wenn True).
  • M19 erzeugt niemals OPEN/INCREASE, keine automatische Reparatur, überschreibt Brokerrealität nie.
  • Bei UNKNOWN/STALE/UNREACHABLE → FAIL-CLOSED + Audit + Alarm, nie blind senden.
  • M19 ist reiner Publisher auf RMQ (kein Consumer → keine Zombie-Konsumenten).

Deployment

  • Port 55019 nur intern (expose, keine Host-Port-Bindings — wie M18).
  • Netz trading-modules_trading-modules, restart: unless-stopped.
  • RMQ-Host Modul-02-RabbitMQ, vhost trading. PG-Host Modul-01-PostgreSQL.
  • Reconciliation-Loop loop_interval_seconds=60, loop_enabled=True.

Tests

  • Unit: tests/test_engine.py — 16/16 grün (Klassifikation, Provider, Safety test_never_allows_trade, Kernregel qty=0 nie MATCH, journal-ohne-position_state → MISSING_INTERNAL).
  • E2E: tests/e2e_m19_full.py — 14/14 grün (MATCH, QUANTITY, DIRECTION, MISSING_BROKER/INTERNAL, PARTIAL_FILL, STATUS_MISMATCH, Duplikat/Idempotenz, parallel/race-safe, Health/Readiness, Event-Dedup, Audit-Trail, S13 Fake-MATCH → MISSING_INTERNAL).
  • Infrastruktur (live, VPS): Broker-DB unreachable → FAIL-CLOSED (broker_reachable:false, trade_actions_allowed:false); Restart sauber; RMQ-Down → kein Crash (gehaltene pika-Warnungen); RMQ-Reconnect ohne Zombies; M09-Ausfall → M19 arbeitet weiter (Quelle ist PG execution_order); Health+Readiness 200; Port nur intern.

Regression M03→M09→M18

Alle Kettenglieder healthy nach M19-Deployment (M03/M05/M08/M09/M10/M15/M18 /health = ok). M19 schreibt ausschließlich in reconciliation_*; fremde Tabellen (execution_order, trade_journal, position_state) bleiben unberührt. Keine M19-Konsumenten an der Trading-Kette; M19-Exchange reconciliation ist additiv (M15 kann künftig konsumieren). Keine Produktivpositionen — alle Datensätze sind M18E2E2/M19E2E2-Testreste.

Bugs / Lessons Learned

  1. RMQ-vhost / vs trading: M18 nutzt vhost trading; M19 musste auf vhost trading korrigiert werden (anfänglich / → Fehler). Nach Fix sauber.
  2. Envs-Präfix M19_: pydantic-settings env_prefix="M19_" — Envs wie M19_PG_HOST, M19_RABBITMQ_HOST verwenden, NICHT bare PG_HOST (Falle beim Isolationstest). Produktiv-Container trägt noch bare PG_*/ RABBITMQ_*-Envs (M18-Muster) = tote Envs; M19 greift auf korrekte pydantic-Defaults (Modul-01-PostgreSQL, Modul-02-RabbitMQ, vhost trading) zurück. Für saubere Konsistenz künftig Envs auf M19_-Präfix umstellen.
  3. psycopg2 nicht thread-safe: Storage nutzt threading.local() pro Thread (Fix aus M18 übernommen).
  4. MISSING_INTERNAL vs STATUS_MISMATCH: sauber getrennt (s. o.), sonst fälschliche Klassifikation.
  5. Broker-Realität rekonstruiert: V1 akzeptiert execution_order als Broker-Realität; echter Adapter folgt nach M19 (Demo-Broker-Adapter), siehe oben.
  6. Keine Host-Port-Bindings (nur expose) — wie M18, keine öffentliche API.
  7. Fake-MATCH (Root Cause, 21.08.2026): get_internal_open() las trade_journal (M10) als Basis. Ein trade_journal-Eintrag mit status='OPEN' galt als intern offen, auch ohne position_state (M18). Folge: IG-Position existierte, intern nur execution_order/trade_journal → M19 klassifizierte fälschlich MATCH mit qty 0. Fix: Interne Positionswahrheit primär aus position_state (M18); trade_journal/ execution_order allein vortäuschen keine interne offene Position → MISSING_INTERNAL. qty=0 nie MATCH.