Skip to content

Commit eb84dc4

Browse files
Pigbibicodex
andcommitted
feat: bind IBKR order outcomes to reconciliation keys
Co-Authored-By: Codex <noreply@openai.com>
1 parent 572e170 commit eb84dc4

6 files changed

Lines changed: 419 additions & 32 deletions

File tree

application/broker_reconciliation.py

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from collections.abc import Callable, Iterable, Mapping
1313
from dataclasses import dataclass
1414
from datetime import datetime, timezone
15+
import hashlib
1516
import json
1617
import os
1718
from typing import Any
@@ -56,6 +57,84 @@ def _text(value: object) -> str:
5657
return str(value or "").strip()
5758

5859

60+
def _identity_text(value: object) -> str:
61+
return "" if value is None else str(value).strip()
62+
63+
64+
def build_ibkr_order_identity(
65+
*,
66+
account_id: object,
67+
client_id: object = None,
68+
order_id: object = None,
69+
perm_id: object = None,
70+
) -> dict[str, str]:
71+
"""Build the privacy-safe IBKR identity used by execution and reconciliation."""
72+
73+
account = _text(account_id)
74+
client = _identity_text(client_id)
75+
order = _identity_text(order_id)
76+
permanent = _identity_text(perm_id)
77+
if not account:
78+
raise ValueError("IBKR order identity requires an account scope")
79+
if not ((client and order and order != "0") or (permanent and permanent != "0")):
80+
raise ValueError("IBKR order identity requires client_id/order_id or perm_id")
81+
identity = {
82+
"account_scope_sha256": hashlib.sha256(account.encode("utf-8")).hexdigest(),
83+
}
84+
if client:
85+
identity["client_id"] = client
86+
if order:
87+
identity["order_id"] = order
88+
if permanent and permanent != "0":
89+
identity["perm_id"] = permanent
90+
return identity
91+
92+
93+
def build_ibkr_order_key(
94+
*,
95+
account_id: object,
96+
client_id: object = None,
97+
order_id: object = None,
98+
perm_id: object = None,
99+
) -> str:
100+
"""Match ib_insync's API-order key, with permId only for manual orders."""
101+
102+
identity = build_ibkr_order_identity(
103+
account_id=account_id,
104+
client_id=client_id,
105+
order_id=order_id,
106+
perm_id=perm_id,
107+
)
108+
if identity.get("client_id") and identity.get("order_id") not in {None, "0"}:
109+
correlation = {
110+
"account_scope_sha256": identity["account_scope_sha256"],
111+
"client_id": identity["client_id"],
112+
"order_id": identity["order_id"],
113+
}
114+
else:
115+
correlation = {
116+
"account_scope_sha256": identity["account_scope_sha256"],
117+
"perm_id": identity["perm_id"],
118+
}
119+
encoded = json.dumps(correlation, separators=(",", ":"), sort_keys=True).encode("utf-8")
120+
return "ibkr-order-v1-" + hashlib.sha256(encoded).hexdigest()
121+
122+
123+
def normalize_ibkr_order_state(status: object) -> str:
124+
normalized = _text(status)
125+
if normalized == "Filled":
126+
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"}:
132+
return "cancelled"
133+
if normalized in {"Inactive", "Rejected"}:
134+
return "rejected"
135+
return "unknown"
136+
137+
59138
def _number(value: object, *, field_name: str) -> float:
60139
try:
61140
return float(value)
@@ -163,10 +242,33 @@ def _normalise_open_trade(trade: Any, *, selected_account_ids: tuple[str, ...])
163242
contract = getattr(trade, "contract", None)
164243
if contract is None:
165244
raise IBKRReconciliationReadError("IBKR reconciliation received an open order without a contract.")
245+
client_id = getattr(order, "clientId", None)
246+
order_id = getattr(order, "orderId", None)
247+
perm_id = getattr(order, "permId", None)
248+
try:
249+
order_identity = build_ibkr_order_identity(
250+
account_id=account_id,
251+
client_id=client_id,
252+
order_id=order_id,
253+
perm_id=perm_id,
254+
)
255+
order_key = build_ibkr_order_key(
256+
account_id=account_id,
257+
client_id=client_id,
258+
order_id=order_id,
259+
perm_id=perm_id,
260+
)
261+
except ValueError as exc:
262+
raise IBKRReconciliationReadError(str(exc)) from exc
263+
cumulative_filled_quantity = _number(
264+
getattr(status, "filled", 0.0), field_name="open order filled quantity"
265+
)
166266
return {
167267
"account": _text(account_id),
168268
"contract": _safe_contract_fields(contract),
169-
"perm_id": _text(getattr(order, "permId", "")),
269+
"order_key": order_key,
270+
"order_identity": order_identity,
271+
"perm_id": _text(perm_id),
170272
"action": _text(getattr(order, "action", "")).upper(),
171273
"order_type": _text(getattr(order, "orderType", "")).upper(),
172274
"total_quantity": _number(
@@ -175,7 +277,8 @@ def _normalise_open_trade(trade: Any, *, selected_account_ids: tuple[str, ...])
175277
"limit_price": _number(getattr(order, "lmtPrice", 0.0), field_name="open order limit price"),
176278
"aux_price": _number(getattr(order, "auxPrice", 0.0), field_name="open order aux price"),
177279
"status": _text(getattr(status, "status", "")),
178-
"filled": _number(getattr(status, "filled", 0.0), field_name="open order filled quantity"),
280+
"filled": cumulative_filled_quantity,
281+
"cumulative_filled_quantity": cumulative_filled_quantity,
179282
"remaining": _number(
180283
getattr(status, "remaining", 0.0), field_name="open order remaining quantity"
181284
),
@@ -199,11 +302,34 @@ def _normalise_execution(fill: Any, *, selected_account_ids: tuple[str, ...]) ->
199302
contract = getattr(fill, "contract", None)
200303
if execution is None or contract is None:
201304
raise IBKRReconciliationReadError("IBKR reconciliation received an incomplete execution record.")
305+
client_id = getattr(execution, "clientId", None)
306+
order_id = getattr(execution, "orderId", None)
307+
perm_id = getattr(execution, "permId", None)
308+
try:
309+
order_identity = build_ibkr_order_identity(
310+
account_id=account_id,
311+
client_id=client_id,
312+
order_id=order_id,
313+
perm_id=perm_id,
314+
)
315+
order_key = build_ibkr_order_key(
316+
account_id=account_id,
317+
client_id=client_id,
318+
order_id=order_id,
319+
perm_id=perm_id,
320+
)
321+
except ValueError as exc:
322+
raise IBKRReconciliationReadError(str(exc)) from exc
202323
return {
203324
"account": _text(account_id),
204325
"contract": _safe_contract_fields(contract),
326+
"order_key": order_key,
327+
"order_identity": order_identity,
205328
"execution_id": _text(getattr(execution, "execId", "")),
206-
"order_id": _text(getattr(execution, "orderId", "")),
329+
"order_id": _text(order_id),
330+
"cumulative_filled_quantity": _number(
331+
getattr(execution, "cumQty", None), field_name="execution cumulative filled quantity"
332+
),
207333
"time": _text(getattr(execution, "time", "")),
208334
"side": _text(getattr(execution, "side", "")).upper(),
209335
"shares": _number(getattr(execution, "shares", None), field_name="execution shares"),
@@ -504,7 +630,10 @@ def matches(key: str, actual_digest: str) -> bool:
504630
"IBKRReconciliationCandidate",
505631
"IBKRReconciliationObservations",
506632
"IBKRReconciliationReadError",
633+
"build_ibkr_order_identity",
634+
"build_ibkr_order_key",
507635
"build_reconciliation_candidate",
508636
"collect_read_only_reconciliation_observations",
509637
"normalize_account_ids",
638+
"normalize_ibkr_order_state",
510639
]

application/execution_service.py

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
from typing import Any
1414

1515
import pandas as pd
16+
from application.broker_reconciliation import (
17+
build_ibkr_order_identity,
18+
build_ibkr_order_key,
19+
normalize_ibkr_order_state,
20+
)
1621
from application.paper_execution_admission import evaluate_ibkr_paper_execution_admission
1722
try:
1823
from quant_platform_kit.common.cash_sweep import should_sell_cash_sweep_to_fund_whole_share_buy
@@ -287,6 +292,53 @@ def check_order_submitted(report, *, translator):
287292
)
288293

289294

295+
def _ibkr_client_id(ib: Any) -> object:
296+
wrapper = getattr(ib, "wrapper", None)
297+
if wrapper is not None and getattr(wrapper, "clientId", None) is not None:
298+
return wrapper.clientId
299+
return getattr(getattr(ib, "client", None), "clientId", None)
300+
301+
302+
def _build_order_event_payload(
303+
ib: Any,
304+
report: Any,
305+
*,
306+
account_id: object,
307+
payload: Mapping[str, Any],
308+
) -> dict[str, Any]:
309+
status = str(getattr(report, "status", "") or "").strip()
310+
order_id = getattr(report, "broker_order_id", None)
311+
raw_payload = getattr(report, "raw_payload", None)
312+
report_account_id = raw_payload.get("account_id") if isinstance(raw_payload, Mapping) else None
313+
resolved_account_id = account_id or report_account_id
314+
client_id = _ibkr_client_id(ib)
315+
result = {
316+
**dict(payload),
317+
"status": status,
318+
"broker_order_id": order_id,
319+
"cumulative_filled_quantity": float(getattr(report, "filled_quantity", 0.0) or 0.0),
320+
"status_transitions": [
321+
{"from": "created", "to": normalize_ibkr_order_state(status)},
322+
],
323+
}
324+
try:
325+
result["order_identity"] = build_ibkr_order_identity(
326+
account_id=resolved_account_id,
327+
client_id=client_id,
328+
order_id=order_id,
329+
)
330+
result["order_key"] = build_ibkr_order_key(
331+
account_id=resolved_account_id,
332+
client_id=client_id,
333+
order_id=order_id,
334+
)
335+
except ValueError:
336+
# The broker effect may already exist. Keep the observed report without
337+
# inventing a correlation key; the durable run claim still blocks retry.
338+
result["order_key"] = None
339+
return result
340+
341+
290342
def _record_order_outcome(
291343
execution_summary: dict,
292344
order_payload: dict,
@@ -838,11 +890,12 @@ def _execute_option_order_intents(
838890
report = submit_order_intent(ib, order_intent)
839891
_, status_msg = check_order_submitted(report, translator=translator)
840892
status = str(getattr(report, "status", "") or "")
841-
order_payload = {
842-
**payload,
843-
"status": status,
844-
"broker_order_id": getattr(report, "broker_order_id", None),
845-
}
893+
order_payload = _build_order_event_payload(
894+
ib,
895+
report,
896+
account_id=order_account_id,
897+
payload=payload,
898+
)
846899
outcome = _record_order_outcome(
847900
execution_summary,
848901
order_payload,
@@ -2110,13 +2163,12 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
21102163
)
21112164
_, status_msg = check_order_submitted(report, translator=translator)
21122165
status = str(getattr(report, "status", "") or "")
2113-
order_payload = {
2114-
"symbol": symbol,
2115-
"side": "sell",
2116-
"quantity": qty,
2117-
"status": status,
2118-
"broker_order_id": getattr(report, "broker_order_id", None),
2119-
}
2166+
order_payload = _build_order_event_payload(
2167+
ib,
2168+
report,
2169+
account_id=order_account_id,
2170+
payload={"symbol": symbol, "side": "sell", "quantity": qty},
2171+
)
21202172
outcome = _record_order_outcome(execution_summary, order_payload, status=status)
21212173
trade_logs.append(translator("market_sell", symbol=symbol, qty=format_quantity(qty)) + f" {status_msg}")
21222174
if outcome != "failed":
@@ -2294,14 +2346,17 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
22942346
)
22952347
_, status_msg = check_order_submitted(report, translator=translator)
22962348
status = str(getattr(report, "status", "") or "")
2297-
order_payload = {
2298-
"symbol": symbol,
2299-
"side": "buy",
2300-
"quantity": qty,
2301-
"limit_price": limit_price,
2302-
"status": status,
2303-
"broker_order_id": getattr(report, "broker_order_id", None),
2304-
}
2349+
order_payload = _build_order_event_payload(
2350+
ib,
2351+
report,
2352+
account_id=order_account_id,
2353+
payload={
2354+
"symbol": symbol,
2355+
"side": "buy",
2356+
"quantity": qty,
2357+
"limit_price": limit_price,
2358+
},
2359+
)
23052360
outcome = _record_order_outcome(execution_summary, order_payload, status=status)
23062361
trade_logs.append(
23072362
translator("limit_buy", symbol=symbol, qty=format_quantity(qty), price=f"{limit_price:.2f}") + f" {status_msg}"

0 commit comments

Comments
 (0)