Skip to content

Commit d07d54d

Browse files
Pigbibicodex
andcommitted
fix: fail closed IBKR terminal order outcomes
Co-Authored-By: Codex <noreply@openai.com>
1 parent 1ed5851 commit d07d54d

6 files changed

Lines changed: 336 additions & 39 deletions

File tree

application/broker_reconciliation.py

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import os
1818
from typing import Any
1919

20+
from ib_insync import OrderStatus
2021
from quant_platform_kit.common.broker_reconciliation import (
2122
BrokerReconciliationEvidence,
2223
BrokerReconciliationFinding,
@@ -51,6 +52,14 @@ class IBKRReconciliationReadError(RuntimeError):
5152
"TotalCashBalance",
5253
"SettledCash",
5354
)
55+
_ORDER_EVENT_DIGEST_FIELDS = frozenset(
56+
{
57+
"order_key",
58+
"order_identity",
59+
"cumulative_filled_quantity",
60+
"status_transitions",
61+
}
62+
)
5463

5564

5665
def _text(value: object) -> str:
@@ -120,21 +129,47 @@ def build_ibkr_order_key(
120129
return "ibkr-order-v1-" + hashlib.sha256(encoded).hexdigest()
121130

122131

123-
def normalize_ibkr_order_state(status: object) -> str:
132+
def normalize_ibkr_order_state(status: object, *, filled_quantity: object = 0.0) -> str:
133+
"""Map pinned ib_insync order states to local reconciliation states."""
134+
124135
normalized = _text(status)
125-
if normalized == "Filled":
136+
try:
137+
has_filled_quantity = float(filled_quantity or 0.0) > 0.0
138+
except (TypeError, ValueError):
139+
has_filled_quantity = False
140+
if normalized == OrderStatus.Filled:
126141
return "filled"
127-
if normalized in {"PartiallyFilled", "Partial"}:
128-
return "partially_filled"
129-
if normalized in {"PendingSubmit", "ApiPending", "ApiPendingSubmit", "Submitted", "PreSubmitted"}:
130-
return "submitted"
131-
if normalized in {"Cancelled", "ApiCancelled"}:
142+
if normalized in {OrderStatus.Cancelled, OrderStatus.ApiCancelled}:
132143
return "cancelled"
133-
if normalized in {"Inactive", "Rejected"}:
134-
return "rejected"
144+
if normalized in OrderStatus.ActiveStates or normalized == OrderStatus.PendingCancel:
145+
if has_filled_quantity:
146+
return "partially_filled"
147+
return "pending_cancel" if normalized == OrderStatus.PendingCancel else "submitted"
148+
if normalized == OrderStatus.Inactive:
149+
return "inactive"
135150
return "unknown"
136151

137152

153+
def _without_order_event_digest_fields(value: object) -> object:
154+
if isinstance(value, Mapping):
155+
return {
156+
key: _without_order_event_digest_fields(item)
157+
for key, item in value.items()
158+
if key not in _ORDER_EVENT_DIGEST_FIELDS
159+
}
160+
if isinstance(value, tuple):
161+
return tuple(_without_order_event_digest_fields(item) for item in value)
162+
if isinstance(value, list):
163+
return [_without_order_event_digest_fields(item) for item in value]
164+
return value
165+
166+
167+
def calculate_legacy_reconciliation_observation_sha256(value: object) -> str:
168+
"""Keep frozen reconciliation baselines independent of new order-event metadata."""
169+
170+
return calculate_broker_observation_sha256(_without_order_event_digest_fields(value))
171+
172+
138173
def _number(value: object, *, field_name: str) -> float:
139174
try:
140175
return float(value)
@@ -558,8 +593,8 @@ def build_reconciliation_candidate(
558593
account_scope_sha256 = calculate_broker_observation_sha256(observations.account_scope)
559594
positions_sha256 = calculate_broker_observation_sha256(observations.positions)
560595
cash_sha256 = calculate_broker_observation_sha256(observations.cash)
561-
open_orders_sha256 = calculate_broker_observation_sha256(observations.open_orders)
562-
recent_executions_sha256 = calculate_broker_observation_sha256(observations.recent_executions)
596+
open_orders_sha256 = calculate_legacy_reconciliation_observation_sha256(observations.open_orders)
597+
recent_executions_sha256 = calculate_legacy_reconciliation_observation_sha256(observations.recent_executions)
563598
execution_state_store = build_execution_marker_store_from_env(
564599
platform_env_prefix="IBKR",
565600
env_reader=env_reader,
@@ -633,6 +668,7 @@ def matches(key: str, actual_digest: str) -> bool:
633668
"build_ibkr_order_identity",
634669
"build_ibkr_order_key",
635670
"build_reconciliation_candidate",
671+
"calculate_legacy_reconciliation_observation_sha256",
636672
"collect_read_only_reconciliation_observations",
637673
"normalize_account_ids",
638674
"normalize_ibkr_order_state",

application/execution_service.py

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,12 @@ def check_order_submitted(report, *, translator):
254254
"""Check if order was accepted. DAY orders auto-expire at close if not filled."""
255255
order_id = report.broker_order_id
256256
status = report.status
257+
order_state = normalize_ibkr_order_state(
258+
status,
259+
filled_quantity=getattr(report, "filled_quantity", 0.0),
260+
)
257261

258-
if status == "Filled":
262+
if order_state == "filled":
259263
return (
260264
True,
261265
translator(
@@ -267,7 +271,7 @@ def check_order_submitted(report, *, translator):
267271
order_id=order_id,
268272
),
269273
)
270-
if status in {"PartiallyFilled", "Partial"}:
274+
if order_state == "partially_filled":
271275
return (
272276
True,
273277
translator(
@@ -280,18 +284,11 @@ def check_order_submitted(report, *, translator):
280284
order_id=order_id,
281285
),
282286
)
283-
if status in {"PendingSubmit", "ApiPending", "ApiPendingSubmit", "Submitted", "PreSubmitted"}:
287+
if order_state in {"submitted", "pending_cancel"}:
284288
return True, f"✅ {translator('submitted', order_id=order_id, status=status)}"
285289
return False, f"❌ {translator('failed', reason=status)}"
286290

287291

288-
_FILLED_ORDER_STATUSES = frozenset({"Filled"})
289-
_PARTIALLY_FILLED_ORDER_STATUSES = frozenset({"PartiallyFilled", "Partial"})
290-
_PENDING_ORDER_STATUSES = frozenset(
291-
{"PendingSubmit", "ApiPending", "ApiPendingSubmit", "Submitted", "PreSubmitted"}
292-
)
293-
294-
295292
def _ibkr_client_id(ib: Any) -> object:
296293
wrapper = getattr(ib, "wrapper", None)
297294
if wrapper is not None and getattr(wrapper, "clientId", None) is not None:
@@ -318,7 +315,13 @@ def _build_order_event_payload(
318315
"broker_order_id": order_id,
319316
"cumulative_filled_quantity": float(getattr(report, "filled_quantity", 0.0) or 0.0),
320317
"status_transitions": [
321-
{"from": "created", "to": normalize_ibkr_order_state(status)},
318+
{
319+
"from": "created",
320+
"to": normalize_ibkr_order_state(
321+
status,
322+
filled_quantity=getattr(report, "filled_quantity", 0.0),
323+
),
324+
},
322325
],
323326
}
324327
try:
@@ -353,14 +356,18 @@ def _record_order_outcome(
353356
make the rebalance appear complete.
354357
"""
355358
normalized_status = str(status or "").strip()
359+
order_state = normalize_ibkr_order_state(
360+
normalized_status,
361+
filled_quantity=order_payload.get("cumulative_filled_quantity", 0.0),
362+
)
356363
prefix = "option_orders" if option_order else "orders"
357-
if normalized_status in _FILLED_ORDER_STATUSES:
364+
if order_state == "filled":
358365
execution_summary[f"{prefix}_filled"].append(order_payload)
359366
return "filled"
360-
if normalized_status in _PARTIALLY_FILLED_ORDER_STATUSES:
367+
if order_state == "partially_filled":
361368
execution_summary[f"{prefix}_partially_filled"].append(order_payload)
362369
return "partially_filled"
363-
if normalized_status in _PENDING_ORDER_STATUSES:
370+
if order_state in {"submitted", "pending_cancel"}:
364371
execution_summary[f"{prefix}_pending"].append(order_payload)
365372
return "pending"
366373
execution_summary[f"{prefix}_skipped"].append(
@@ -1378,10 +1385,11 @@ def _planned_buy_order_quantity(
13781385

13791386
def _projected_sell_release_value_for_report(report, *, fallback_price=0.0, fallback_quantity=0.0) -> float:
13801387
status = str(getattr(report, "status", "") or "")
1381-
if status not in {"Filled", "PartiallyFilled", "Partial"}:
1382-
return 0.0
13831388
filled_quantity = float(getattr(report, "filled_quantity", 0.0) or 0.0)
1384-
if status == "Filled" and filled_quantity <= 0.0:
1389+
order_state = normalize_ibkr_order_state(status, filled_quantity=filled_quantity)
1390+
if order_state not in {"filled", "partially_filled"}:
1391+
return 0.0
1392+
if order_state == "filled" and filled_quantity <= 0.0:
13851393
filled_quantity = float(getattr(report, "quantity", 0.0) or fallback_quantity or 0.0)
13861394
if filled_quantity <= 0.0:
13871395
return 0.0

application/rebalance_service.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import re
99

1010
from application.cycle_result import StrategyCycleResult
11+
from application.broker_reconciliation import normalize_ibkr_order_state
1112
from application.runtime_dependencies import IBKRRebalanceConfig, IBKRRebalanceRuntime
1213
from application.reconciliation_service import (
1314
build_reconciliation_record,
@@ -778,10 +779,10 @@ def _record_execution_outcome(
778779
marker_key: str,
779780
signal_metadata: dict,
780781
execution_summary,
781-
) -> None:
782+
) -> bool:
782783
store = getattr(config, "execution_state_store", None)
783784
if not store or not marker_key:
784-
return
785+
return False
785786
summary = dict(execution_summary or {})
786787
orders_by_key = {}
787788
for collection in (
@@ -799,8 +800,19 @@ def _record_execution_outcome(
799800
for order in summary.get(collection) or ():
800801
if isinstance(order, Mapping) and str(order.get("order_key") or "").strip():
801802
orders_by_key[str(order["order_key"])] = dict(order)
803+
if not orders_by_key:
804+
return False
805+
if any(
806+
normalize_ibkr_order_state(
807+
order.get("status"),
808+
filled_quantity=order.get("cumulative_filled_quantity", 0.0),
809+
)
810+
not in {"filled", "cancelled"}
811+
for order in orders_by_key.values()
812+
):
813+
return False
802814
try:
803-
store.record_outcome(
815+
recorded = store.record_outcome(
804816
marker_key,
805817
metadata={
806818
"schema_version": "ibkr_order_consumer_outcome.v1",
@@ -816,10 +828,10 @@ def _record_execution_outcome(
816828
},
817829
)
818830
except Exception as exc:
819-
print(
820-
f"Execution outcome write failed\nMarker: {marker_key}\n{type(exc).__name__}: {exc}",
821-
flush=True,
822-
)
831+
raise RuntimeError("IBKR terminal execution outcome unavailable; refusing success") from exc
832+
if recorded is not True:
833+
raise RuntimeError("IBKR terminal execution outcome unavailable; refusing success")
834+
return True
823835

824836

825837
def run_strategy_core(

tests/test_broker_reconciliation.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
IBKRReconciliationReadError,
1010
build_ibkr_order_key,
1111
build_reconciliation_candidate,
12+
calculate_legacy_reconciliation_observation_sha256,
1213
collect_read_only_reconciliation_observations,
1314
)
15+
from quant_platform_kit.common.broker_reconciliation import calculate_broker_observation_sha256
1416
from quant_platform_kit.common.live_continuity import runtime_target_fingerprint
1517
from quant_platform_kit.common.runtime_target import build_runtime_target
1618

@@ -157,6 +159,33 @@ def test_manual_order_key_requires_perm_id() -> None:
157159
build_ibkr_order_key(account_id="U123", order_id=0)
158160

159161

162+
def test_order_event_metadata_does_not_change_legacy_reconciliation_digest() -> None:
163+
legacy_open_order = {
164+
"account": "U123",
165+
"contract": {"symbol": "SOXL"},
166+
"perm_id": "9001",
167+
"action": "BUY",
168+
"order_type": "LMT",
169+
"total_quantity": 2.0,
170+
"limit_price": 22.0,
171+
"aux_price": 0.0,
172+
"status": "Submitted",
173+
"filled": 0.0,
174+
"remaining": 2.0,
175+
}
176+
enriched_open_order = {
177+
**legacy_open_order,
178+
"order_key": "ibkr-order-v1-example",
179+
"order_identity": {"account_scope_sha256": "digest", "client_id": "7", "order_id": "456"},
180+
"cumulative_filled_quantity": 0.0,
181+
"status_transitions": [{"from": "created", "to": "submitted"}],
182+
}
183+
184+
assert calculate_legacy_reconciliation_observation_sha256((enriched_open_order,)) == (
185+
calculate_broker_observation_sha256((legacy_open_order,))
186+
)
187+
188+
160189
def test_cash_reconciliation_ignores_dynamic_margin_and_valuation_tags() -> None:
161190
def fetch_snapshot(_ib, *, dynamic_net_liquidation: float, dynamic_available_funds: float, **_kwargs):
162191
return SimpleNamespace(
@@ -411,3 +440,99 @@ def configured_env(name, default=None):
411440

412441
assert candidate.permits_active_lkg is True
413442
assert candidate.recovery_blockers == ()
443+
444+
445+
def test_candidate_keeps_legacy_order_digests_after_order_event_wiring(tmp_path) -> None:
446+
target = _frozen_runtime_target()
447+
448+
def empty_env(name, default=None):
449+
return str(tmp_path) if name == "IBKR_EXECUTION_STATE_DIR" else default
450+
451+
legacy_observations = IBKRReconciliationObservations(
452+
account_scope={"account_ids": ["U123"]},
453+
account_identity_match=True,
454+
positions=(),
455+
cash=(),
456+
open_orders=(
457+
{
458+
"account": "U123",
459+
"contract": {"symbol": "SOXL"},
460+
"perm_id": "9001",
461+
"status": "Submitted",
462+
"filled": 0.0,
463+
"remaining": 2.0,
464+
},
465+
),
466+
recent_executions=(
467+
{
468+
"account": "U123",
469+
"contract": {"symbol": "SOXL"},
470+
"order_id": "456",
471+
"execution_id": "exec-1",
472+
"shares": 1.0,
473+
"price": 21.5,
474+
},
475+
),
476+
)
477+
seed = build_reconciliation_candidate(
478+
observations=legacy_observations,
479+
runtime_target=target,
480+
platform_id="ibkr",
481+
strategy_profile="soxl_soxx_trend_income",
482+
account_group="LIVE",
483+
project_id=None,
484+
env_reader=empty_env,
485+
)
486+
expected = {
487+
key: seed.evidence.to_dict()[key]
488+
for key in (
489+
"positions_sha256",
490+
"cash_sha256",
491+
"open_orders_sha256",
492+
"recent_executions_sha256",
493+
"local_execution_ledger_sha256",
494+
)
495+
}
496+
enriched_observations = IBKRReconciliationObservations(
497+
account_scope=legacy_observations.account_scope,
498+
account_identity_match=legacy_observations.account_identity_match,
499+
positions=legacy_observations.positions,
500+
cash=legacy_observations.cash,
501+
open_orders=(
502+
{
503+
**legacy_observations.open_orders[0],
504+
"order_key": "ibkr-order-v1-example",
505+
"order_identity": {"account_scope_sha256": "digest", "client_id": "7", "order_id": "456"},
506+
"cumulative_filled_quantity": 0.0,
507+
},
508+
),
509+
recent_executions=(
510+
{
511+
**legacy_observations.recent_executions[0],
512+
"order_key": "ibkr-order-v1-example",
513+
"order_identity": {"account_scope_sha256": "digest", "client_id": "7", "order_id": "456"},
514+
"cumulative_filled_quantity": 1.0,
515+
},
516+
),
517+
)
518+
519+
def configured_env(name, default=None):
520+
if name == "IBKR_EXECUTION_STATE_DIR":
521+
return str(tmp_path)
522+
if name == "IBKR_RECONCILIATION_EXPECTED_DIGESTS_JSON":
523+
import json
524+
525+
return json.dumps(expected)
526+
return default
527+
528+
candidate = build_reconciliation_candidate(
529+
observations=enriched_observations,
530+
runtime_target=target,
531+
platform_id="ibkr",
532+
strategy_profile="soxl_soxx_trend_income",
533+
account_group="LIVE",
534+
project_id=None,
535+
env_reader=configured_env,
536+
)
537+
538+
assert candidate.permits_active_lkg is True

0 commit comments

Comments
 (0)