Skip to content

Commit 8972cc2

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

9 files changed

Lines changed: 347 additions & 40 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,4 @@ jobs:
134134
- name: Run unit tests
135135
run: |
136136
set -euo pipefail
137-
PYTHONPATH=. PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 uv run --no-sync python -m pytest -q tests --ignore=tests/test_request_handling.py --ignore=tests/test_event_loop.py --ignore=tests/test_monitor_dispatcher.py --ignore=tests/test_notifications.py --ignore=tests/test_connect_timeout_alert.py || true
137+
PYTHONPATH=. PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 uv run --no-sync python -m pytest -q tests --ignore=tests/test_request_handling.py --ignore=tests/test_event_loop.py --ignore=tests/test_monitor_dispatcher.py --ignore=tests/test_notifications.py --ignore=tests/test_connect_timeout_alert.py

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/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Local test package to prevent third-party ``tests`` namespace shadowing."""

0 commit comments

Comments
 (0)