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)
This commit is contained in:
parent
b0de2d2d1e
commit
df3cd3fc57
2 changed files with 95 additions and 7 deletions
|
|
@ -45,10 +45,65 @@ RabbitMQPublisher (market.execution)
|
||||||
`submit_order()`, `get_order_status()`
|
`submit_order()`, `get_order_status()`
|
||||||
- `PaperBrokerAdapter` (app/broker/paper.py): V1, simuliert realistischen
|
- `PaperBrokerAdapter` (app/broker/paper.py): V1, simuliert realistischen
|
||||||
Lifecycle (SUBMITTED → FILLED, Partial Fill, Reject, Timeout) für Tests
|
Lifecycle (SUBMITTED → FILLED, Partial Fill, Reject, Timeout) für Tests
|
||||||
|
- `IgDemoAdapter` (app/broker/ig_demo.py): IG-Markets-Demo-Broker (IG_DEMO-Modus),
|
||||||
|
echter externer Broker-Adapter. Liest/schreibt über IG REST API (demo-api.ig.com).
|
||||||
- `BrokerRegistry` (app/broker/registry.py): wählt Adapter anhand
|
- `BrokerRegistry` (app/broker/registry.py): wählt Adapter anhand
|
||||||
`DEFAULT_BROKER` (V1: paper). Echte Broker-Adapter später austauschbar,
|
`DEFAULT_BROKER` (V1: paper). Echte Broker-Adapter später austauschbar,
|
||||||
keine Brokerlogik im Core-Code.
|
keine Brokerlogik im Core-Code.
|
||||||
|
|
||||||
|
## IG-Demo-Adapter (IG_DEMO-Modus, 21.08.2026)
|
||||||
|
|
||||||
|
**Status: FREIGEGEBEN / PRODUKTIV VERIFIZIERT** — erster echter externer Broker-Adapter.
|
||||||
|
|
||||||
|
- **Eigener Modus `IG_DEMO`** (nicht LIVE+Gate); PAPER/IG_DEMO/LIVE strikt getrennt.
|
||||||
|
- **Demo-Basis-URL erzwungen** (`demo-api.ig.com`), LIVE-Endpunkt (`api.ig.com`)
|
||||||
|
technisch blockiert, kein IG_DEMO→LIVE-Autowechsel.
|
||||||
|
- **Credentials ausschließlich VPS-Secret/ENV** (`.env.ig`, chmod 600/root-only);
|
||||||
|
nie in Code/Forgejo/Tolaria/DB/Logs.
|
||||||
|
- **Mengen instrumentabhängig** über `broker_mapping` (PostgreSQL); kein globales 1:1.
|
||||||
|
- **Epic-Mapping produktiv in PostgreSQL `broker_mapping`**; Forgejo nur Schema/Doku/Beispiele.
|
||||||
|
|
||||||
|
### Order-Aktions-Auswertung (CLOSE-Pfad-Fix, 21.08.2026)
|
||||||
|
`BrokerOrderRequest` trägt `action_type` (Default `OPEN`) + `broker_order_id`.
|
||||||
|
Der Service reicht beides durch; der Adapter wertet `action_type` aus:
|
||||||
|
|
||||||
|
| action_type | IG-Pfad |
|
||||||
|
|-------------|---------|
|
||||||
|
| `OPEN` / `INCREASE` | `POST /positions/otc` (create_position) |
|
||||||
|
| `CLOSE` / `REDUCE` | `POST /positions/otc` mit `_method:DELETE` (close_position) |
|
||||||
|
| unbekannt/ungültig | **FAIL-CLOSED** (`IG_UNSUPPORTED_ACTION`) |
|
||||||
|
|
||||||
|
**CLOSE-Direction korrekt umkehren:** CLOSE einer LONG/BUY-Position muss mit
|
||||||
|
**SELL** erfolgen (Gegenseite), nicht mit der Positionsrichtung. `_close_direction()`:
|
||||||
|
LONG→SELL, SHORT→BUY.
|
||||||
|
|
||||||
|
**broker_order_id/dealId sauber durchreichen:** `broker_order_id` wird vom Service
|
||||||
|
in die Order übernommen und an den Adapter durchgereicht; IG `dealId` wird als
|
||||||
|
`broker_order_id` persistiert.
|
||||||
|
|
||||||
|
**Unbekannte/ungültige Aktionen FAIL-CLOSED:** `_derive_action_type()` setzt
|
||||||
|
unbekannte Aktionen NICHT mehr auf OPEN zurück, sondern reicht sie durch →
|
||||||
|
Control blockt mit `UNKNOWN_ACTION_TYPE`, Adapter mit `IG_UNSUPPORTED_ACTION`.
|
||||||
|
|
||||||
|
### IG-Adapter-Tests (6/6 grün)
|
||||||
|
1. `test_unsupported_action_fail_closed` — unbekannte Aktion → FAIL-CLOSED
|
||||||
|
2. `test_close_direction_inverted` — CLOSE einer BUY-Position → SELL
|
||||||
|
3. `test_open_uses_create_position` — OPEN → create_position
|
||||||
|
4. `test_close_uses_close_position` — CLOSE → close_position mit `_method:DELETE`
|
||||||
|
5. `test_broker_order_id_passthrough` — broker_order_id/dealId durchgereicht
|
||||||
|
6. `test_fail_closed_without_credentials` — ohne Credentials → nicht konfiguriert
|
||||||
|
|
||||||
|
### Erster erfolgreicher IG-DEMO OPEN→CLOSE-E2E (21.08.2026)
|
||||||
|
- **OPEN** `IGE2E-US500-OPEN-1787292734` → dealId `DIAAAAYB46KMNAE`, size 1.0, BUY,
|
||||||
|
openLevel 7652.58 → **FILLED**
|
||||||
|
- **CLOSE-Bug gefunden + gefixt:** erster CLOSE sendete fälschlich als zweite OPEN
|
||||||
|
(action_type ignoriert) + falsche direction (BUY statt SELL)
|
||||||
|
- **CLOSE2** `IGE2E-US500-CLOSE2-1787293768` für `DIAAAAYB463S4AB` → **FILLED**
|
||||||
|
(filled_quantity 1, avg_fill_price 7652.19)
|
||||||
|
- **IG GET /positions: Anzahl 0** — alle Positionen geschlossen
|
||||||
|
- **Safety-Reset:** M09 zurück auf PAPER (`EXECUTION_MODE=PAPER`,
|
||||||
|
`TRADING_ENABLED=false`, `DEFAULT_BROKER=paper`)
|
||||||
|
|
||||||
## Idempotenz
|
## Idempotenz
|
||||||
|
|
||||||
- Unique-Index `uq_execution_order_src` auf `source_portfolio_decision_id`
|
- Unique-Index `uq_execution_order_src` auf `source_portfolio_decision_id`
|
||||||
|
|
@ -84,10 +139,11 @@ sowie `REJECTED`, `CANCELLED`, `FAILED`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- **21/21 Unit-Tests** (test_execution.py): gültige Order → PAPER FILLED,
|
- **31/31 Unit-Tests** (test_execution.py + test_ig_demo.py + test_control.py):
|
||||||
Idempotenz, ungültige Quantity → REJECTED, Kill-Switch, fehlende
|
gültige Order → PAPER FILLED, Idempotenz, ungültige Quantity → REJECTED,
|
||||||
Broker-Credentials → FAIL-CLOSED, Broker-Reject, Timeout → keine blinde
|
Kill-Switch, fehlende Broker-Credentials → FAIL-CLOSED, Broker-Reject,
|
||||||
Doppelorder, Partial Fill, Reconnect-Backoff, parallele identische Events
|
Timeout → keine blinde Doppelorder, Partial Fill, Reconnect-Backoff,
|
||||||
|
parallele identische Events, Control-Gate (M15), IG-Adapter (6/6)
|
||||||
- **E2E 6/6 Checks** (Kette 03→04→05→06→07→08→09): Execution-Order in DB,
|
- **E2E 6/6 Checks** (Kette 03→04→05→06→07→08→09): Execution-Order in DB,
|
||||||
exakt 1 Paper-Order, Pflichtfelder, Idempotenz, ORDER_SUBMITTED+ORDER_FILLED,
|
exakt 1 Paper-Order, Pflichtfelder, Idempotenz, ORDER_SUBMITTED+ORDER_FILLED,
|
||||||
gültiger Trade → PAPER FILLED
|
gültiger Trade → PAPER FILLED
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,32 @@ Modul-19-Broker-Reconciliation (Port 55019, NUR intern)
|
||||||
intern kennt die Position, aber sie ist CLOSED/abweichend während der Broker offen ist (vorher fälschlich
|
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.
|
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)
|
## Safety (hart)
|
||||||
- `allow_trade_actions` ist **hart `False`** (Konfiguration + API erzwingen; `/reconcile` lehnt ab wenn True).
|
- `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.
|
- M19 erzeugt **niemals** `OPEN`/`INCREASE`, keine automatische Reparatur, überschreibt Brokerrealität nie.
|
||||||
|
|
@ -83,10 +109,11 @@ MISSING_INTERNAL). Beides sind kritische Abweichungen, die als `reconciliation.a
|
||||||
- Reconciliation-Loop `loop_interval_seconds=60`, `loop_enabled=True`.
|
- Reconciliation-Loop `loop_interval_seconds=60`, `loop_enabled=True`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
- **Unit:** `tests/test_engine.py` — 11/11 grün (Klassifikation, Provider, Safety `test_never_allows_trade`).
|
- **Unit:** `tests/test_engine.py` — 16/16 grün (Klassifikation, Provider, Safety `test_never_allows_trade`,
|
||||||
- **E2E:** `tests/e2e_m19_full.py` — 13/13 grün (MATCH, QUANTITY, DIRECTION, MISSING_BROKER/INTERNAL,
|
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,
|
PARTIAL_FILL, STATUS_MISMATCH, Duplikat/Idempotenz, parallel/race-safe, Health/Readiness, Event-Dedup,
|
||||||
Audit-Trail).
|
Audit-Trail, **S13 Fake-MATCH → MISSING_INTERNAL**).
|
||||||
- **Infrastruktur (live, VPS):** Broker-DB unreachable → FAIL-CLOSED (broker_reachable:false,
|
- **Infrastruktur (live, VPS):** Broker-DB unreachable → FAIL-CLOSED (broker_reachable:false,
|
||||||
trade_actions_allowed:false); Restart sauber; RMQ-Down → kein Crash (gehaltene pika-Warnungen);
|
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.
|
RMQ-Reconnect ohne Zombies; M09-Ausfall → M19 arbeitet weiter (Quelle ist PG `execution_order`); Health+Readiness 200; Port nur intern.
|
||||||
|
|
@ -110,3 +137,8 @@ ist additiv (M15 kann künftig konsumieren). Keine Produktivpositionen — alle
|
||||||
5. **Broker-Realität rekonstruiert:** V1 akzeptiert `execution_order` als Broker-Realität; echter Adapter folgt
|
5. **Broker-Realität rekonstruiert:** V1 akzeptiert `execution_order` als Broker-Realität; echter Adapter folgt
|
||||||
nach M19 (Demo-Broker-Adapter), siehe oben.
|
nach M19 (Demo-Broker-Adapter), siehe oben.
|
||||||
6. **Keine Host-Port-Bindings** (nur `expose`) — wie M18, keine öffentliche API.
|
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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue