trading-system-docs/modul-19-broker-reconciliation.md
Rain Ocampo df3cd3fc57 M09/M19: IG-Demo-Adapter + CLOSE-Pfad-Fix + Fake-MATCH-Fix (21.08.2026)
M09 (modul-09-execution-service.md):
- IG-Demo-Adapter (IG_DEMO-Modus) dokumentiert
- Order-Aktions-Auswertung: OPEN/INCREASE->create_position, CLOSE/REDUCE->close_position
- CLOSE-Direction korrekt umkehren (_close_direction: LONG->SELL, SHORT->BUY)
- broker_order_id/dealId sauber durchreichen
- unbekannte/ungueltige Aktionen FAIL-CLOSED (IG_UNSUPPORTED_ACTION)
- 6 IG-Adapter-Tests dokumentiert
- erster erfolgreicher IG-DEMO OPEN->CLOSE-E2E (US500)

M19 (modul-19-broker-reconciliation.md):
- Fake-MATCH Root Cause dokumentiert
- korrigierte interne Positionswahrheit: position_state (M18) primaer
- trade_journal/execution_order allein vortaeuschen keine interne offene Position
- qty=0 nie MATCH
- Tests: Unit 16/16, E2E 14/14 (S13 Fake-MATCH->MISSING_INTERNAL)
2026-08-21 06:48:27 +00:00

144 lines
10 KiB
Markdown

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