Skip to content

Commit dc577a0

Browse files
Pigbibicodex
andcommitted
feat: enforce canonical IBKR order identity
Co-Authored-By: Codex <noreply@openai.com>
1 parent ccc219b commit dc577a0

7 files changed

Lines changed: 298 additions & 10 deletions

application/broker_reconciliation.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,53 @@ def _number(value: object, *, field_name: str) -> float:
6363
raise IBKRReconciliationReadError(f"IBKR reconciliation is missing {field_name}.") from exc
6464

6565

66+
def _order_identity_integer(value: object, *, field_name: str) -> int:
67+
if isinstance(value, bool):
68+
raise IBKRReconciliationReadError(
69+
f"IBKR reconciliation order identity has an invalid {field_name}."
70+
)
71+
try:
72+
return int(str(value).strip(), 10)
73+
except (TypeError, ValueError) as exc:
74+
raise IBKRReconciliationReadError(
75+
f"IBKR reconciliation order identity has an invalid {field_name}."
76+
) from exc
77+
78+
79+
def build_canonical_order_key(
80+
*,
81+
account_id: object,
82+
client_id: object,
83+
order_id: object,
84+
perm_id: object,
85+
) -> str:
86+
"""Build the account-scoped IBKR key used by submit and reconciliation paths."""
87+
88+
account = _text(account_id)
89+
if not account or "|" in account or any(ord(character) < 32 for character in account):
90+
raise IBKRReconciliationReadError(
91+
"IBKR reconciliation order identity has an invalid account id."
92+
)
93+
normalized_order_id = _order_identity_integer(order_id, field_name="order id")
94+
if normalized_order_id <= 0:
95+
normalized_perm_id = _order_identity_integer(perm_id, field_name="perm id")
96+
if normalized_perm_id <= 0:
97+
raise IBKRReconciliationReadError(
98+
"IBKR reconciliation order identity requires a positive perm id for a manual order."
99+
)
100+
return f"ibkr|account={account}|perm_id={normalized_perm_id}"
101+
102+
normalized_client_id = _order_identity_integer(client_id, field_name="client id")
103+
if normalized_client_id < 0:
104+
raise IBKRReconciliationReadError(
105+
"IBKR reconciliation order identity requires a non-negative client id."
106+
)
107+
return (
108+
f"ibkr|account={account}|client_id={normalized_client_id}"
109+
f"|order_id={normalized_order_id}"
110+
)
111+
112+
66113
def normalize_account_ids(account_ids: Iterable[str] | str | None) -> tuple[str, ...]:
67114
if account_ids is None:
68115
return ()
@@ -165,6 +212,12 @@ def _normalise_open_trade(trade: Any, *, selected_account_ids: tuple[str, ...])
165212
raise IBKRReconciliationReadError("IBKR reconciliation received an open order without a contract.")
166213
return {
167214
"account": _text(account_id),
215+
"order_key": build_canonical_order_key(
216+
account_id=account_id,
217+
client_id=getattr(order, "clientId", None),
218+
order_id=getattr(order, "orderId", None),
219+
perm_id=getattr(order, "permId", None),
220+
),
168221
"contract": _safe_contract_fields(contract),
169222
"perm_id": _text(getattr(order, "permId", "")),
170223
"action": _text(getattr(order, "action", "")).upper(),
@@ -201,6 +254,12 @@ def _normalise_execution(fill: Any, *, selected_account_ids: tuple[str, ...]) ->
201254
raise IBKRReconciliationReadError("IBKR reconciliation received an incomplete execution record.")
202255
return {
203256
"account": _text(account_id),
257+
"order_key": build_canonical_order_key(
258+
account_id=account_id,
259+
client_id=getattr(execution, "clientId", None),
260+
order_id=getattr(execution, "orderId", None),
261+
perm_id=getattr(execution, "permId", None),
262+
),
204263
"contract": _safe_contract_fields(contract),
205264
"execution_id": _text(getattr(execution, "execId", "")),
206265
"order_id": _text(getattr(execution, "orderId", "")),
@@ -504,6 +563,7 @@ def matches(key: str, actual_digest: str) -> bool:
504563
"IBKRReconciliationCandidate",
505564
"IBKRReconciliationObservations",
506565
"IBKRReconciliationReadError",
566+
"build_canonical_order_key",
507567
"build_reconciliation_candidate",
508568
"collect_read_only_reconciliation_observations",
509569
"normalize_account_ids",

application/execution_service.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,25 @@ def _record_order_outcome(
302302
"""
303303
normalized_status = str(status or "").strip()
304304
prefix = "option_orders" if option_order else "orders"
305+
identity_failure = str(order_payload.get("reconciliation_outcome") or "").strip()
306+
broker_order_id = str(order_payload.get("broker_order_id") or "").strip()
307+
order_key = str(order_payload.get("order_key") or "").strip()
308+
identity_required_statuses = (
309+
_FILLED_ORDER_STATUSES
310+
| _PARTIALLY_FILLED_ORDER_STATUSES
311+
| _PENDING_ORDER_STATUSES
312+
)
313+
if identity_failure or (
314+
normalized_status in identity_required_statuses
315+
and not order_key
316+
and not broker_order_id
317+
):
318+
reason = identity_failure or "order_identity_unavailable"
319+
execution_summary[f"{prefix}_skipped"].append({**order_payload, "reason": reason})
320+
execution_summary["skipped_reasons"].append(
321+
f"{reason}:{order_payload.get('symbol')}"
322+
)
323+
return "failed"
305324
if normalized_status in _FILLED_ORDER_STATUSES:
306325
execution_summary[f"{prefix}_filled"].append(order_payload)
307326
return "filled"
@@ -321,6 +340,17 @@ def _record_order_outcome(
321340
return "failed"
322341

323342

343+
def _report_order_identity(report: object) -> dict[str, str]:
344+
raw_payload = getattr(report, "raw_payload", None)
345+
if not isinstance(raw_payload, Mapping):
346+
return {}
347+
return {
348+
key: str(raw_payload[key]).strip()
349+
for key in ("order_key", "reconciliation_outcome")
350+
if str(raw_payload.get(key) or "").strip()
351+
}
352+
353+
324354
def _normalize_account_ids(account_ids=None) -> tuple[str, ...]:
325355
if account_ids is None:
326356
return ()
@@ -842,6 +872,7 @@ def _execute_option_order_intents(
842872
**payload,
843873
"status": status,
844874
"broker_order_id": getattr(report, "broker_order_id", None),
875+
**_report_order_identity(report),
845876
}
846877
outcome = _record_order_outcome(
847878
execution_summary,
@@ -2116,6 +2147,7 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
21162147
"quantity": qty,
21172148
"status": status,
21182149
"broker_order_id": getattr(report, "broker_order_id", None),
2150+
**_report_order_identity(report),
21192151
}
21202152
outcome = _record_order_outcome(execution_summary, order_payload, status=status)
21212153
trade_logs.append(translator("market_sell", symbol=symbol, qty=format_quantity(qty)) + f" {status_msg}")
@@ -2301,6 +2333,7 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
23012333
"limit_price": limit_price,
23022334
"status": status,
23032335
"broker_order_id": getattr(report, "broker_order_id", None),
2336+
**_report_order_identity(report),
23042337
}
23052338
outcome = _record_order_outcome(execution_summary, order_payload, status=status)
23062339
trade_logs.append(
@@ -2337,7 +2370,9 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
23372370
(
23382371
reason
23392372
for reason in execution_summary["skipped_reasons"]
2340-
if reason.startswith(("submit_failed:", "option_submit_failed:"))
2373+
if reason.startswith(
2374+
("submit_failed:", "option_submit_failed:", "order_identity_unavailable:")
2375+
)
23412376
),
23422377
None,
23432378
)

application/ibkr_order_execution.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,24 @@
88
from quant_platform_kit.common.models import ExecutionReport, OrderIntent
99
from quant_platform_kit.ibkr.execution import submit_order_intent as _submit_order_intent
1010

11+
from application.broker_reconciliation import (
12+
IBKRReconciliationReadError,
13+
build_canonical_order_key,
14+
)
15+
1116
DEFAULT_TIME_IN_FORCE = "DAY"
17+
_ORDER_IDENTITY_REQUIRED_STATUSES = frozenset(
18+
{
19+
"ApiPending",
20+
"ApiPendingSubmit",
21+
"Filled",
22+
"Partial",
23+
"PartiallyFilled",
24+
"PendingSubmit",
25+
"PreSubmitted",
26+
"Submitted",
27+
}
28+
)
1229

1330

1431
def _stock_factory_for_market(
@@ -106,7 +123,7 @@ def submit_order_intent(
106123
"""Submit an IBKR order with explicit TIF to avoid account-preset rejections."""
107124

108125
intent = _intent_with_default_time_in_force(order_intent)
109-
return _submit_order_intent(
126+
report = _submit_order_intent(
110127
ib,
111128
intent,
112129
account_id=account_id,
@@ -125,3 +142,24 @@ def submit_order_intent(
125142
),
126143
limit_order_factory=limit_order_factory,
127144
)
145+
raw_payload = dict(report.raw_payload or {})
146+
if report.broker_order_id is None and report.status not in _ORDER_IDENTITY_REQUIRED_STATUSES:
147+
return report
148+
try:
149+
order_key = build_canonical_order_key(
150+
account_id=raw_payload.get("account_id"),
151+
client_id=getattr(getattr(ib, "client", None), "clientId", None),
152+
order_id=report.broker_order_id,
153+
perm_id=raw_payload.get("perm_id"),
154+
)
155+
except IBKRReconciliationReadError:
156+
return replace(
157+
report,
158+
status="ReconciliationRequired",
159+
raw_payload={
160+
**raw_payload,
161+
"broker_status": report.status,
162+
"reconciliation_outcome": "order_identity_unavailable",
163+
},
164+
)
165+
return replace(report, raw_payload={**raw_payload, "order_key": order_key})

docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,27 @@
1414
让操作者人工确认“恢复原有实盘基线”。现有自动化权限策略将这类 broker/order
1515
execution 变更视为高风险,禁止自动恢复。
1616

17+
## Canonical order-key 身份契约
18+
19+
固定依赖 `ib-insync==0.9.86``Wrapper.orderKey` 规则是:`orderId <= 0` 代表
20+
TWS manual order,使用 `permId`;正 `orderId` 使用 `(clientId, orderId)`。平台在此
21+
基础上加入账户隔离,形成以下唯一身份状态表:
22+
23+
| 输入 | canonical key | 对账结果 |
24+
|---|---|---|
25+
| `orderId > 0`,且 account/client/order 完整 | `ibkr|account=...|client_id=...|order_id=...` | `IDENTIFIED` |
26+
| `orderId <= 0`,且 account/正 `permId` 完整 | `ibkr|account=...|perm_id=...` | `IDENTIFIED` |
27+
| account、orderId、所需 clientId/permId 缺失或不可归一 | 不生成 key | `IDENTITY_UNAVAILABLE`,失败关闭 |
28+
29+
提交适配器只会把 `IDENTIFIED` 报告交给成功状态;身份失败固定返回
30+
`ReconciliationRequired``reconciliation_outcome=order_identity_unavailable`,上游执行摘要
31+
必须为 `blocked`,不得写成 `executed` 或把 key 置为 `None` 后继续。只读对账遇到同类失败时
32+
直接拒绝本次采集,`/reconcile` 返回失败且不生成候选。
33+
34+
canonical key 会进入私有 open-order/recent-execution 归一记录,因此新代码生成的对应摘要可能
35+
与旧 expected digest 不同;不匹配时继续保持 `RECONCILE_ONLY`。本契约不更新基线、不授权重录,
36+
也不定义 partial、cancel 或 terminal outcome 语义。
37+
1738
## 发布给统一管理站点的最小来源快照
1839

1940
`scripts/publish_reconciliation_recovery_source.py` 只接受上一步的私有候选和

tests/test_broker_reconciliation.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pytest
66

7+
import application.broker_reconciliation as broker_reconciliation
78
from application.broker_reconciliation import (
89
IBKRReconciliationObservations,
910
IBKRReconciliationReadError,
@@ -32,11 +33,13 @@ def _snapshot(*, account_id: str = "U123"):
3233
)
3334

3435

35-
def _trade(*, account_id: str = "U123"):
36+
def _trade(*, account_id: str = "U123", order_id: int = 456, perm_id: int = 9001):
3637
return SimpleNamespace(
3738
order=SimpleNamespace(
3839
account=account_id,
39-
permId=456,
40+
clientId=7,
41+
orderId=order_id,
42+
permId=perm_id,
4043
action="BUY",
4144
orderType="LMT",
4245
totalQuantity=2,
@@ -54,12 +57,14 @@ def _trade(*, account_id: str = "U123"):
5457
)
5558

5659

57-
def _fill(*, account_id: str = "U123"):
60+
def _fill(*, account_id: str = "U123", order_id: int = 456, perm_id: int = 9001):
5861
return SimpleNamespace(
5962
execution=SimpleNamespace(
6063
acctNumber=account_id,
64+
clientId=7,
6165
execId="exec-1",
62-
orderId=456,
66+
orderId=order_id,
67+
permId=perm_id,
6368
time="20260830 13:30:00 UTC",
6469
side="BOT",
6570
shares=1,
@@ -118,6 +123,44 @@ def fetch_portfolio_snapshot(_ib, **kwargs):
118123
assert len(observations.open_orders) == 1
119124
assert len(observations.recent_executions) == 1
120125
assert observations.open_orders[0]["account"] == "U123"
126+
assert observations.open_orders[0]["order_key"] == observations.recent_executions[0]["order_key"]
127+
128+
129+
@pytest.mark.parametrize("order_id", [-7, 0])
130+
def test_manual_order_key_uses_perm_id_for_non_positive_order_ids(order_id: int) -> None:
131+
assert broker_reconciliation.build_canonical_order_key(
132+
account_id="U123",
133+
client_id=7,
134+
order_id=order_id,
135+
perm_id=9001,
136+
) == "ibkr|account=U123|perm_id=9001"
137+
138+
139+
def test_api_order_key_uses_account_client_and_positive_order_id() -> None:
140+
assert broker_reconciliation.build_canonical_order_key(
141+
account_id="U123",
142+
client_id=7,
143+
order_id=456,
144+
perm_id=9001,
145+
) == "ibkr|account=U123|client_id=7|order_id=456"
146+
147+
148+
@pytest.mark.parametrize(
149+
"identity",
150+
[
151+
{"account_id": "U123", "client_id": 7, "order_id": 0, "perm_id": None},
152+
{"account_id": "U123", "client_id": 7, "order_id": -7, "perm_id": 0},
153+
{"account_id": "U123", "client_id": 7, "order_id": None, "perm_id": 9001},
154+
{"account_id": "U123", "client_id": 7, "order_id": "invalid", "perm_id": 9001},
155+
{"account_id": "U123", "client_id": None, "order_id": 456, "perm_id": 9001},
156+
{"account_id": "U123", "client_id": "invalid", "order_id": 456, "perm_id": 9001},
157+
{"account_id": "U123", "client_id": 7, "order_id": 0, "perm_id": "invalid"},
158+
{"account_id": "", "client_id": 7, "order_id": 456, "perm_id": 9001},
159+
],
160+
)
161+
def test_order_key_rejects_missing_or_unusable_identity(identity: dict[str, object]) -> None:
162+
with pytest.raises(IBKRReconciliationReadError, match="order identity"):
163+
broker_reconciliation.build_canonical_order_key(**identity)
121164

122165

123166
def test_cash_reconciliation_ignores_dynamic_margin_and_valuation_tags() -> None:

0 commit comments

Comments
 (0)