1212from collections .abc import Callable , Iterable , Mapping
1313from dataclasses import dataclass
1414from datetime import datetime , timezone
15+ import hashlib
1516import json
1617import os
1718from 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+
59138def _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]
0 commit comments