diff --git a/application/broker_reconciliation.py b/application/broker_reconciliation.py index e026827..9e3e45f 100644 --- a/application/broker_reconciliation.py +++ b/application/broker_reconciliation.py @@ -63,6 +63,53 @@ def _number(value: object, *, field_name: str) -> float: raise IBKRReconciliationReadError(f"IBKR reconciliation is missing {field_name}.") from exc +def _order_identity_integer(value: object, *, field_name: str) -> int: + if isinstance(value, bool): + raise IBKRReconciliationReadError( + f"IBKR reconciliation order identity has an invalid {field_name}." + ) + try: + return int(str(value).strip(), 10) + except (TypeError, ValueError) as exc: + raise IBKRReconciliationReadError( + f"IBKR reconciliation order identity has an invalid {field_name}." + ) from exc + + +def build_canonical_order_key( + *, + account_id: object, + client_id: object, + order_id: object, + perm_id: object, +) -> str: + """Build the account-scoped IBKR key used by submit and reconciliation paths.""" + + account = _text(account_id) + if not account or "|" in account or any(ord(character) < 32 for character in account): + raise IBKRReconciliationReadError( + "IBKR reconciliation order identity has an invalid account id." + ) + normalized_order_id = _order_identity_integer(order_id, field_name="order id") + if normalized_order_id <= 0: + normalized_perm_id = _order_identity_integer(perm_id, field_name="perm id") + if normalized_perm_id <= 0: + raise IBKRReconciliationReadError( + "IBKR reconciliation order identity requires a positive perm id for a manual order." + ) + return f"ibkr|account={account}|perm_id={normalized_perm_id}" + + normalized_client_id = _order_identity_integer(client_id, field_name="client id") + if normalized_client_id < 0: + raise IBKRReconciliationReadError( + "IBKR reconciliation order identity requires a non-negative client id." + ) + return ( + f"ibkr|account={account}|client_id={normalized_client_id}" + f"|order_id={normalized_order_id}" + ) + + def normalize_account_ids(account_ids: Iterable[str] | str | None) -> tuple[str, ...]: if account_ids is None: return () @@ -165,6 +212,12 @@ def _normalise_open_trade(trade: Any, *, selected_account_ids: tuple[str, ...]) raise IBKRReconciliationReadError("IBKR reconciliation received an open order without a contract.") return { "account": _text(account_id), + "order_key": build_canonical_order_key( + account_id=account_id, + client_id=getattr(order, "clientId", None), + order_id=getattr(order, "orderId", None), + perm_id=getattr(order, "permId", None), + ), "contract": _safe_contract_fields(contract), "perm_id": _text(getattr(order, "permId", "")), "action": _text(getattr(order, "action", "")).upper(), @@ -201,6 +254,12 @@ def _normalise_execution(fill: Any, *, selected_account_ids: tuple[str, ...]) -> raise IBKRReconciliationReadError("IBKR reconciliation received an incomplete execution record.") return { "account": _text(account_id), + "order_key": build_canonical_order_key( + account_id=account_id, + client_id=getattr(execution, "clientId", None), + order_id=getattr(execution, "orderId", None), + perm_id=getattr(execution, "permId", None), + ), "contract": _safe_contract_fields(contract), "execution_id": _text(getattr(execution, "execId", "")), "order_id": _text(getattr(execution, "orderId", "")), @@ -504,6 +563,7 @@ def matches(key: str, actual_digest: str) -> bool: "IBKRReconciliationCandidate", "IBKRReconciliationObservations", "IBKRReconciliationReadError", + "build_canonical_order_key", "build_reconciliation_candidate", "collect_read_only_reconciliation_observations", "normalize_account_ids", diff --git a/application/execution_service.py b/application/execution_service.py index b0c9846..f959b92 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -302,6 +302,22 @@ def _record_order_outcome( """ normalized_status = str(status or "").strip() prefix = "option_orders" if option_order else "orders" + identity_failure = str(order_payload.get("reconciliation_outcome") or "").strip() + order_key = str(order_payload.get("order_key") or "").strip() + identity_required_statuses = ( + _FILLED_ORDER_STATUSES + | _PARTIALLY_FILLED_ORDER_STATUSES + | _PENDING_ORDER_STATUSES + ) + if normalized_status == "ReconciliationRequired": + identity_failure = identity_failure or "order_identity_unavailable" + if identity_failure or (normalized_status in identity_required_statuses and not order_key): + reason = identity_failure or "order_identity_unavailable" + execution_summary[f"{prefix}_skipped"].append({**order_payload, "reason": reason}) + execution_summary["skipped_reasons"].append( + f"{reason}:{order_payload.get('symbol')}" + ) + return "failed" if normalized_status in _FILLED_ORDER_STATUSES: execution_summary[f"{prefix}_filled"].append(order_payload) return "filled" @@ -321,6 +337,17 @@ def _record_order_outcome( return "failed" +def _report_order_identity(report: object) -> dict[str, str]: + raw_payload = getattr(report, "raw_payload", None) + if not isinstance(raw_payload, Mapping): + return {} + return { + key: str(raw_payload[key]).strip() + for key in ("order_key", "reconciliation_outcome") + if str(raw_payload.get(key) or "").strip() + } + + def _normalize_account_ids(account_ids=None) -> tuple[str, ...]: if account_ids is None: return () @@ -842,6 +869,7 @@ def _execute_option_order_intents( **payload, "status": status, "broker_order_id": getattr(report, "broker_order_id", None), + **_report_order_identity(report), } outcome = _record_order_outcome( execution_summary, @@ -2116,6 +2144,7 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t "quantity": qty, "status": status, "broker_order_id": getattr(report, "broker_order_id", None), + **_report_order_identity(report), } outcome = _record_order_outcome(execution_summary, order_payload, status=status) trade_logs.append(translator("market_sell", symbol=symbol, qty=format_quantity(qty)) + f" {status_msg}") @@ -2301,6 +2330,7 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t "limit_price": limit_price, "status": status, "broker_order_id": getattr(report, "broker_order_id", None), + **_report_order_identity(report), } outcome = _record_order_outcome(execution_summary, order_payload, status=status) trade_logs.append( @@ -2333,6 +2363,14 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t has_pending_order = bool( execution_summary["orders_pending"] or execution_summary["option_orders_pending"] ) + identity_failure = next( + ( + reason + for reason in execution_summary["skipped_reasons"] + if reason.startswith("order_identity_unavailable:") + ), + None, + ) submission_failure = next( ( reason @@ -2341,7 +2379,11 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t ), None, ) - if has_pending_order: + if identity_failure: + execution_summary["execution_status"] = "blocked" + execution_summary["no_op_reason"] = identity_failure + trade_logs.append(translator("failed", reason=identity_failure)) + elif has_pending_order: execution_summary["execution_status"] = "pending_reconciliation" execution_summary["no_op_reason"] = "broker_order_pending_confirmation" elif has_terminal_order: diff --git a/application/ibkr_order_execution.py b/application/ibkr_order_execution.py index a4355c7..e6a0659 100644 --- a/application/ibkr_order_execution.py +++ b/application/ibkr_order_execution.py @@ -8,7 +8,24 @@ from quant_platform_kit.common.models import ExecutionReport, OrderIntent from quant_platform_kit.ibkr.execution import submit_order_intent as _submit_order_intent +from application.broker_reconciliation import ( + IBKRReconciliationReadError, + build_canonical_order_key, +) + DEFAULT_TIME_IN_FORCE = "DAY" +_ORDER_IDENTITY_REQUIRED_STATUSES = frozenset( + { + "ApiPending", + "ApiPendingSubmit", + "Filled", + "Partial", + "PartiallyFilled", + "PendingSubmit", + "PreSubmitted", + "Submitted", + } +) def _stock_factory_for_market( @@ -106,7 +123,7 @@ def submit_order_intent( """Submit an IBKR order with explicit TIF to avoid account-preset rejections.""" intent = _intent_with_default_time_in_force(order_intent) - return _submit_order_intent( + report = _submit_order_intent( ib, intent, account_id=account_id, @@ -125,3 +142,24 @@ def submit_order_intent( ), limit_order_factory=limit_order_factory, ) + raw_payload = dict(report.raw_payload or {}) + if report.broker_order_id is None and report.status not in _ORDER_IDENTITY_REQUIRED_STATUSES: + return report + try: + order_key = build_canonical_order_key( + account_id=raw_payload.get("account_id"), + client_id=getattr(getattr(ib, "client", None), "clientId", None), + order_id=report.broker_order_id, + perm_id=raw_payload.get("perm_id"), + ) + except IBKRReconciliationReadError: + return replace( + report, + status="ReconciliationRequired", + raw_payload={ + **raw_payload, + "broker_status": report.status, + "reconciliation_outcome": "order_identity_unavailable", + }, + ) + return replace(report, raw_payload={**raw_payload, "order_key": order_key}) diff --git a/docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md b/docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md index dc00360..0614e32 100644 --- a/docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md +++ b/docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md @@ -14,6 +14,30 @@ 让操作者人工确认“恢复原有实盘基线”。现有自动化权限策略将这类 broker/order execution 变更视为高风险,禁止自动恢复。 +## Canonical order-key 身份契约 + +固定依赖 `ib-insync==0.9.86` 的 `Wrapper.orderKey` 规则是:`orderId <= 0` 代表 +TWS manual order,使用 `permId`;正 `orderId` 使用 `(clientId, orderId)`。平台在此 +基础上加入账户隔离,形成以下唯一身份状态表: + +| 输入 | canonical key | 对账结果 | +|---|---|---| +| `orderId > 0`,且 account/client/order 完整 | `ibkr|account=...|client_id=...|order_id=...` | `IDENTIFIED` | +| `orderId <= 0`,且 account/正 `permId` 完整 | `ibkr|account=...|perm_id=...` | `IDENTIFIED` | +| account、orderId、所需 clientId/permId 缺失或不可归一 | 不生成 key | `IDENTITY_UNAVAILABLE`,失败关闭 | + +提交适配器只会把 `IDENTIFIED` 报告交给成功状态;身份失败固定返回 +`ReconciliationRequired` 和 `reconciliation_outcome=order_identity_unavailable`,上游执行摘要 +必须为 `blocked`,不得写成 `executed` 或把 key 置为 `None` 后继续。只读对账遇到同类失败时 +直接拒绝本次采集,`/reconcile` 返回失败且不生成候选。 + +`broker_order_id` 只是原始回执字段,不能替代 canonical key;即使同一批结果中已有完成订单, +任一 identity unavailable 或 `ReconciliationRequired` 仍优先使整体摘要失败关闭为 `blocked`。 + +canonical key 会进入私有 open-order/recent-execution 归一记录,因此新代码生成的对应摘要可能 +与旧 expected digest 不同;不匹配时继续保持 `RECONCILE_ONLY`。本契约不更新基线、不授权重录, +也不定义 partial、cancel 或 terminal outcome 语义。 + ## 发布给统一管理站点的最小来源快照 `scripts/publish_reconciliation_recovery_source.py` 只接受上一步的私有候选和 diff --git a/tests/test_broker_reconciliation.py b/tests/test_broker_reconciliation.py index ee9cba4..1971ead 100644 --- a/tests/test_broker_reconciliation.py +++ b/tests/test_broker_reconciliation.py @@ -4,6 +4,7 @@ import pytest +import application.broker_reconciliation as broker_reconciliation from application.broker_reconciliation import ( IBKRReconciliationObservations, IBKRReconciliationReadError, @@ -32,11 +33,13 @@ def _snapshot(*, account_id: str = "U123"): ) -def _trade(*, account_id: str = "U123"): +def _trade(*, account_id: str = "U123", order_id: int = 456, perm_id: int = 9001): return SimpleNamespace( order=SimpleNamespace( account=account_id, - permId=456, + clientId=7, + orderId=order_id, + permId=perm_id, action="BUY", orderType="LMT", totalQuantity=2, @@ -54,12 +57,14 @@ def _trade(*, account_id: str = "U123"): ) -def _fill(*, account_id: str = "U123"): +def _fill(*, account_id: str = "U123", order_id: int = 456, perm_id: int = 9001): return SimpleNamespace( execution=SimpleNamespace( acctNumber=account_id, + clientId=7, execId="exec-1", - orderId=456, + orderId=order_id, + permId=perm_id, time="20260830 13:30:00 UTC", side="BOT", shares=1, @@ -118,6 +123,44 @@ def fetch_portfolio_snapshot(_ib, **kwargs): assert len(observations.open_orders) == 1 assert len(observations.recent_executions) == 1 assert observations.open_orders[0]["account"] == "U123" + assert observations.open_orders[0]["order_key"] == observations.recent_executions[0]["order_key"] + + +@pytest.mark.parametrize("order_id", [-7, 0]) +def test_manual_order_key_uses_perm_id_for_non_positive_order_ids(order_id: int) -> None: + assert broker_reconciliation.build_canonical_order_key( + account_id="U123", + client_id=7, + order_id=order_id, + perm_id=9001, + ) == "ibkr|account=U123|perm_id=9001" + + +def test_api_order_key_uses_account_client_and_positive_order_id() -> None: + assert broker_reconciliation.build_canonical_order_key( + account_id="U123", + client_id=7, + order_id=456, + perm_id=9001, + ) == "ibkr|account=U123|client_id=7|order_id=456" + + +@pytest.mark.parametrize( + "identity", + [ + {"account_id": "U123", "client_id": 7, "order_id": 0, "perm_id": None}, + {"account_id": "U123", "client_id": 7, "order_id": -7, "perm_id": 0}, + {"account_id": "U123", "client_id": 7, "order_id": None, "perm_id": 9001}, + {"account_id": "U123", "client_id": 7, "order_id": "invalid", "perm_id": 9001}, + {"account_id": "U123", "client_id": None, "order_id": 456, "perm_id": 9001}, + {"account_id": "U123", "client_id": "invalid", "order_id": 456, "perm_id": 9001}, + {"account_id": "U123", "client_id": 7, "order_id": 0, "perm_id": "invalid"}, + {"account_id": "", "client_id": 7, "order_id": 456, "perm_id": 9001}, + ], +) +def test_order_key_rejects_missing_or_unusable_identity(identity: dict[str, object]) -> None: + with pytest.raises(IBKRReconciliationReadError, match="order identity"): + broker_reconciliation.build_canonical_order_key(**identity) def test_cash_reconciliation_ignores_dynamic_margin_and_valuation_tags() -> None: diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index ee5ca10..a37d34b 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -298,6 +298,123 @@ def accountValues(self): assert "failed submit_failed:VOO:Rejected" in trade_logs +def test_execute_rebalance_blocks_filled_order_with_only_broker_order_id(tmp_path): + class FakeIB: + def openTrades(self): + return [] + + def fills(self): + return [] + + def accountValues(self): + return [SimpleNamespace(tag="CashBalance", currency="USD", value="1000")] + + _trade_logs, summary = execute_rebalance( + FakeIB(), + {"VOO": 1.0}, + {}, + {"equity": 1000.0, "buying_power": 1000.0}, + fetch_quote_snapshots=lambda _ib, symbols: { + symbol: SimpleNamespace(last_price=100.0) for symbol in symbols + }, + submit_order_intent=lambda *_args, **_kwargs: SimpleNamespace( + broker_order_id="123", + status="Filled", + symbol="VOO", + side="buy", + quantity=9, + filled_quantity=9, + average_fill_price=100.0, + raw_payload={}, + ), + order_intent_cls=OrderIntent, + translator=build_translator("en"), + strategy_symbols=["VOO"], + strategy_profile="tech_communication_pullback_enhancement", + signal_metadata=_signal_metadata( + {"VOO": 1.0}, + risk_symbols=("VOO",), + trade_date="2026-04-01", + snapshot_as_of="2026-03-31", + ), + dry_run_only=False, + cash_reserve_ratio=0.0, + rebalance_threshold_ratio=0.02, + limit_buy_premium=1.005, + sell_settle_delay_sec=0, + execution_lock_dir=tmp_path, + return_summary=True, + ) + + assert summary["execution_status"] == "blocked" + assert summary["no_op_reason"] == "order_identity_unavailable:VOO" + assert summary["orders_filled"] == [] + + +def test_execute_rebalance_identity_failure_precedes_completed_order(tmp_path): + class FakeIB: + def openTrades(self): + return [] + + def fills(self): + return [] + + def accountValues(self): + return [SimpleNamespace(tag="CashBalance", currency="USD", value="2000")] + + def submit_order_intent(_ib, intent): + if intent.symbol == "VOO": + return SimpleNamespace( + broker_order_id="123", + status="Filled", + symbol="VOO", + side="buy", + quantity=7, + filled_quantity=7, + average_fill_price=100.0, + raw_payload={ + "order_key": "ibkr|account=U123|client_id=7|order_id=123" + }, + ) + return SimpleNamespace( + broker_order_id="124", + status="ReconciliationRequired", + raw_payload={}, + ) + + _trade_logs, summary = execute_rebalance( + FakeIB(), + {"VOO": 0.4, "QQQ": 0.4}, + {}, + {"equity": 2000.0, "buying_power": 2000.0}, + fetch_quote_snapshots=lambda _ib, symbols: { + symbol: SimpleNamespace(last_price=100.0) for symbol in symbols + }, + submit_order_intent=submit_order_intent, + order_intent_cls=OrderIntent, + translator=build_translator("en"), + strategy_symbols=["VOO", "QQQ"], + strategy_profile="tech_communication_pullback_enhancement", + signal_metadata=_signal_metadata( + {"VOO": 0.4, "QQQ": 0.4}, + risk_symbols=("VOO", "QQQ"), + trade_date="2026-04-01", + snapshot_as_of="2026-03-31", + ), + dry_run_only=False, + cash_reserve_ratio=0.0, + rebalance_threshold_ratio=0.02, + limit_buy_premium=1.005, + sell_settle_delay_sec=0, + execution_lock_dir=tmp_path, + return_summary=True, + ) + + assert summary["execution_status"] == "blocked" + assert summary["no_op_reason"] == "order_identity_unavailable:QQQ" + assert [order["symbol"] for order in summary["orders_filled"]] == ["VOO"] + + def test_execute_rebalance_uses_symbol_specific_limit_buy_premium(monkeypatch, tmp_path): class FakeIB: def openTrades(self): @@ -633,8 +750,17 @@ def fake_submit_order_intent(_ib, intent): quantity=intent.quantity, filled_quantity=intent.quantity, average_fill_price=prices[intent.symbol], + raw_payload={ + "order_key": f"ibkr|account=U123|client_id=7|order_id={len(submitted)}" + }, ) - return SimpleNamespace(broker_order_id=f"order-{len(submitted)}", status="Submitted") + return SimpleNamespace( + broker_order_id=f"order-{len(submitted)}", + status="Submitted", + raw_payload={ + "order_key": f"ibkr|account=U123|client_id=7|order_id={len(submitted)}" + }, + ) _trade_logs, summary = execute_rebalance( FakeIB(), @@ -673,6 +799,9 @@ def fake_submit_order_intent(_ib, intent): ] assert summary["orders_filled"][0]["symbol"] == "SOXL" assert summary["orders_pending"][0]["symbol"] == "SOXX" + assert summary["orders_pending"][0]["order_key"] == ( + "ibkr|account=U123|client_id=7|order_id=2" + ) assert summary["orders_submitted"] == [] assert summary["execution_status"] == "pending_reconciliation" assert summary["projected_sell_release_value"] == 577.5 @@ -696,7 +825,13 @@ def accountValues(self): def fake_submit_order_intent(_ib, intent): submitted.append(intent) - return SimpleNamespace(broker_order_id=f"order-{len(submitted)}", status="Submitted") + return SimpleNamespace( + broker_order_id=f"order-{len(submitted)}", + status="Submitted", + raw_payload={ + "order_key": f"ibkr|account=U123|client_id=7|order_id={len(submitted)}" + }, + ) _trade_logs, summary = execute_rebalance( FakeIB(), @@ -757,6 +892,7 @@ def fake_submit_order_intent(_ib, intent): quantity=intent.quantity, filled_quantity=1, average_fill_price=190.0, + raw_payload={"order_key": "ibkr|account=U123|client_id=7|order_id=1"}, ) return SimpleNamespace(broker_order_id=f"order-{len(submitted)}", status="Submitted") @@ -986,7 +1122,11 @@ def accountValues(self): def fake_submit_order_intent(_ib, intent): submitted.append(intent) - return SimpleNamespace(broker_order_id="1", status="Submitted") + return SimpleNamespace( + broker_order_id="1", + status="Submitted", + raw_payload={"order_key": "ibkr|account=U123|client_id=7|order_id=1"}, + ) monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) @@ -1066,7 +1206,11 @@ def accountValues(self): def fake_submit_order_intent(_ib, intent): submitted.append(intent) - return SimpleNamespace(broker_order_id="1", status="Submitted") + return SimpleNamespace( + broker_order_id="1", + status="Submitted", + raw_payload={"order_key": "ibkr|account=U123|client_id=7|order_id=1"}, + ) monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) @@ -1455,7 +1599,11 @@ def fake_fetch_quote_snapshots(_ib, symbols): kwargs = dict( fetch_quote_snapshots=fake_fetch_quote_snapshots, - submit_order_intent=lambda *_args, **_kwargs: SimpleNamespace(broker_order_id="1", status="Submitted"), + submit_order_intent=lambda *_args, **_kwargs: SimpleNamespace( + broker_order_id="1", + status="Submitted", + raw_payload={"order_key": "ibkr|account=DU123|client_id=7|order_id=1"}, + ), order_intent_cls=OrderIntent, translator=translate, strategy_symbols=["VOO", "BOXX"], @@ -1574,7 +1722,11 @@ def fake_fetch_quote_snapshots(_ib, symbols): {}, {"equity": 1000.0, "buying_power": 1000.0}, fetch_quote_snapshots=fake_fetch_quote_snapshots, - submit_order_intent=lambda *_args, **_kwargs: SimpleNamespace(broker_order_id="1", status="Submitted"), + submit_order_intent=lambda *_args, **_kwargs: SimpleNamespace( + broker_order_id="1", + status="Submitted", + raw_payload={"order_key": "ibkr|account=DU123|client_id=7|order_id=1"}, + ), order_intent_cls=OrderIntent, translator=translate, strategy_symbols=["VOO", "BOXX"], @@ -1690,7 +1842,13 @@ def accountValues(self): def fake_submit_order_intent(_ib, intent): submitted.append(intent) - return SimpleNamespace(broker_order_id=f"order-{len(submitted)}", status="Submitted") + return SimpleNamespace( + broker_order_id=f"order-{len(submitted)}", + status="Submitted", + raw_payload={ + "order_key": f"ibkr|account=DU123|client_id=7|order_id={len(submitted)}" + }, + ) monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) @@ -1889,7 +2047,11 @@ def accountValues(self): def fake_submit_order_intent(_ib, intent): submitted.append(intent) - return SimpleNamespace(broker_order_id="1", status="Submitted") + return SimpleNamespace( + broker_order_id="1", + status="Submitted", + raw_payload={"order_key": "ibkr|account=U123|client_id=7|order_id=1"}, + ) monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) diff --git a/tests/test_ibkr_order_execution.py b/tests/test_ibkr_order_execution.py index b84baf9..7c0fa78 100644 --- a/tests/test_ibkr_order_execution.py +++ b/tests/test_ibkr_order_execution.py @@ -22,6 +22,7 @@ def __init__(self, side, quantity, limit_price): class FakeIB: def __init__(self): + self.client = SimpleNamespace(clientId=7) self.placed_contract = None self.placed_order = None @@ -85,7 +86,7 @@ def test_submit_order_intent_sets_default_day_tif_on_market_orders(): report = submit_order_intent( ib, - OrderIntent(symbol="AAPL", side="sell", quantity=3), + OrderIntent(symbol="AAPL", side="sell", quantity=3, account_id="U1234567"), wait_seconds=0, stock_factory=fake_stock, market_order_factory=FakeMarketOrder, @@ -101,7 +102,13 @@ def test_submit_order_intent_preserves_explicit_tif_on_market_orders(): submit_order_intent( ib, - OrderIntent(symbol="AAPL", side="sell", quantity=3, time_in_force="GTC"), + OrderIntent( + symbol="AAPL", + side="sell", + quantity=3, + time_in_force="GTC", + account_id="U1234567", + ), wait_seconds=0, stock_factory=fake_stock, market_order_factory=FakeMarketOrder, @@ -123,6 +130,24 @@ def test_submit_order_intent_preserves_account_id(): assert ib.placed_order.account == "U1234567" assert report.raw_payload["account_id"] == "U1234567" + assert report.raw_payload["order_key"] == "ibkr|account=U1234567|client_id=7|order_id=42" + + +def test_submit_order_intent_returns_reconciliation_outcome_when_order_key_cannot_be_built(): + ib = FakeIB() + ib.client.clientId = None + + report = submit_order_intent( + ib, + OrderIntent(symbol="AAPL", side="buy", quantity=3, account_id="U1234567"), + wait_seconds=0, + stock_factory=fake_stock, + market_order_factory=FakeMarketOrder, + ) + + assert report.status == "ReconciliationRequired" + assert report.raw_payload["reconciliation_outcome"] == "order_identity_unavailable" + assert "order_key" not in report.raw_payload def test_submit_order_intent_can_target_hk_stock_exchange_and_currency(): @@ -130,7 +155,7 @@ def test_submit_order_intent_can_target_hk_stock_exchange_and_currency(): submit_order_intent( ib, - OrderIntent(symbol="00700", side="buy", quantity=100), + OrderIntent(symbol="00700", side="buy", quantity=100, account_id="U1234567"), wait_seconds=0, stock_factory=fake_stock, market_order_factory=FakeMarketOrder, @@ -154,6 +179,7 @@ def test_submit_order_intent_passes_option_factory_and_default_tif(): quantity=1, order_type="limit", limit_price=150.0, + account_id="U1234567", metadata={ "asset_class": "option", "intent_type": "single_leg_option",