From 05843d50ac568e253d176b592ddd537e555e029e Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 3 Nov 2026 05:33:40 +0800 Subject: [PATCH 1/6] feat: Add read-only IBKR order outcome observations --- docs/README.md | 2 + docs/ibkr-order-outcomes.md | 38 +++ src/quant_platform_kit/ibkr/__init__.py | 2 + src/quant_platform_kit/ibkr/order_outcome.py | 231 ++++++++++++++++++ .../notifications/_redaction.py | 5 + tests/test_ibkr_order_outcome.py | 161 ++++++++++++ 6 files changed, 439 insertions(+) create mode 100644 docs/ibkr-order-outcomes.md create mode 100644 src/quant_platform_kit/ibkr/order_outcome.py create mode 100644 tests/test_ibkr_order_outcome.py diff --git a/docs/README.md b/docs/README.md index ef5f68ee..8ce72efc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ Read documents at the library revision used by your consumer. These are source-l ## Runtime contracts +- [IBKR read-only order outcomes](ibkr-order-outcomes.md): identity-bound terminal observations without execution authority. + - [Platform notification outcomes](platform-notification-outcomes.md): credential-free intents, receipts and log redaction. - [Notification routing policy](notification-routing-policy.md): durable daily facts and urgent incidents. diff --git a/docs/ibkr-order-outcomes.md b/docs/ibkr-order-outcomes.md new file mode 100644 index 00000000..eb4ae0cf --- /dev/null +++ b/docs/ibkr-order-outcomes.md @@ -0,0 +1,38 @@ +# IBKR read-only order outcomes + +`quant_platform_kit.ibkr.order_outcome.resolve_order_outcome` observes one +submitted order using `reqAllOpenOrders`, `reqCompletedOrders(apiOnly=False)` +and `reqExecutions`. It does not submit, modify, cancel or retry an order. The +existing `submit_order_intent` wait and return contract are unchanged. + +The caller supplies the original broker order ID, account, client ID, symbol, +side, requested quantity and aware submission timestamp. An available permanent +ID adds an identity check. Order IDs alone are not globally unique. Same-number +orders on other clients or accounts cannot contribute evidence. + +`OrderOutcome` is frozen and contains `order_id`, `status`, `filled_qty`, +`remaining`, `avg_price`, `commission`, `last_update`, `terminal` and immutable +execution fills. Its status is one of `filled`, `partially_filled`, `cancelled`, +`rejected`, `pending` or `unknown`. A nonterminal partial fill retains its open +obligation. A cancellation can be terminal with a positive filled quantity. + +Missing quantities/prices stay `None`; no requested quantity is promoted to a +fill. Missing commission reports stay unknown. `last_update` is a broker +observation timestamp, never the local query time. Empty sources, mismatched or +inconsistent facts, naive timestamps and ambiguous `Inactive` statuses remain +unknown. Transport failures raise `OrderOutcomeReadError` with a fixed diagnostic; +callers must not treat a partial source snapshot as success. + +The `ib_insync` completed-order callback supplies an empty `OrderStatus`. Its +zero defaults are not fill facts. The resolver requires explicit +`order.filledQuantity` and/or matching executions. Without a broker timestamp, +a stable permanent ID is required for a zero-fill terminal observation. +Execution IDs are deduplicated; conflicting duplicates remain unknown. + +Consumers own bounded request timeouts on their private connection and durable +observation storage. They must keep financial records in protected storage and +mask identifiers in notices. Observations do not clear UNKNOWN execution claims, +release cash or risk reservations, or authorize another order. Adoption requires +a separately reviewed consumer revision; library tests do not prove deployment +or real broker behavior. Commission evidence that arrives after the consumer +freezes a terminal observation can remain unknown in that observation. diff --git a/src/quant_platform_kit/ibkr/__init__.py b/src/quant_platform_kit/ibkr/__init__.py index b4aa77a9..7c283797 100644 --- a/src/quant_platform_kit/ibkr/__init__.py +++ b/src/quant_platform_kit/ibkr/__init__.py @@ -1,5 +1,6 @@ from .connection import connect_ib, ensure_event_loop from .execution import submit_order_intent +from .order_outcome import OrderOutcome, OutcomeFill, OrderOutcomeReadError, resolve_order_outcome from .market_data import ( AdjustedHistoricalCandle, StrictAdjustedHistoryDiagnostic, @@ -23,6 +24,7 @@ ) __all__ = [ + "OrderOutcome", "OutcomeFill", "OrderOutcomeReadError", "resolve_order_outcome", "AdjustedHistoricalCandle", "StrictAdjustedHistoryDiagnostic", "StrictAdjustedHistoryError", diff --git a/src/quant_platform_kit/ibkr/order_outcome.py b/src/quant_platform_kit/ibkr/order_outcome.py new file mode 100644 index 00000000..e13ee90c --- /dev/null +++ b/src/quant_platform_kit/ibkr/order_outcome.py @@ -0,0 +1,231 @@ +"""Read-only, identity-bound IBKR outcome observations; no execution authority.""" +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass +import logging +from datetime import datetime, timezone +import math +from typing import Any, Callable, Literal + + +class OrderOutcomeReadError(RuntimeError): + """A broker read failed; no partial snapshot may be treated as complete.""" + + +@dataclass(frozen=True) +class OutcomeFill: + execution_id: str + quantity: float + price: float + + +@dataclass(frozen=True) +class OrderOutcome: + order_id: str + status: Literal['filled', 'partially_filled', 'cancelled', 'rejected', 'pending', 'unknown'] + filled_qty: float | None = None + remaining: float | None = None + avg_price: float | None = None + commission: float | None = None + last_update: str | None = None + terminal: bool = False + fills: tuple[OutcomeFill, ...] = () + + +def _number(value: Any) -> float: + if isinstance(value, bool): + raise ValueError('invalid_outcome_number') + number = float(value) + if not math.isfinite(number) or abs(number) >= 1e100: + raise ValueError('invalid_outcome_number') + return number + + +def _time(value: Any) -> datetime: + instant = value if isinstance(value, datetime) else datetime.fromisoformat(str(value).replace('Z', '+00:00')) + if instant.tzinfo is None or instant.utcoffset() is None: + raise ValueError('aware_outcome_time_required') + return instant.astimezone(timezone.utc) + + +class _QuietOutcomeLogger(logging.LoggerAdapter): + def log(self, level, msg, *args, **kwargs): + # SDK callback messages can embed complete account-bound executions. + # The caller receives a fixed read failure instead of raw diagnostics. + return + + +@contextmanager +def _quiet_reads(ib): + originals = [] + try: + for owner in (ib, getattr(ib, 'wrapper', None), getattr(ib, 'client', None)): + logger = getattr(owner, '_logger', None) + if logger is not None: + originals.append((owner, logger)) + owner._logger = _QuietOutcomeLogger(logger, {}) + yield + finally: + for owner, logger in reversed(originals): + owner._logger = logger + + +def resolve_order_outcome( + ib: Any, broker_order_id: str, *, account_id: str, client_id: int, + symbol: str, side: str, quantity: float, submitted_at: datetime | str, + execution_filter_factory: Callable[..., Any] | None = None, + permanent_id: int | None = None, +) -> OrderOutcome: + """Read all three broker sources and require the original order identity. + + API order IDs are client-scoped. Neither an empty open-order list nor an + Inactive status proves cancellation/rejection. Transport errors raise a + fixed error; missing, inconsistent or insufficient facts return unknown. + The caller owns request deadlines on this connection. No submit/cancel API + is called and no execution journal or risk reservation is changed. + """ + order_id = str(broker_order_id) + unknown = OrderOutcome(order_id, 'unknown') + try: + qty = _number(quantity) + since = _time(submitted_at) + if (not order_id.isdecimal() or int(order_id) <= 0 or not account_id + or type(client_id) is not int or client_id < 0 or qty <= 0 + or side.lower() not in {'buy', 'sell'} or not symbol): + return unknown + except (ValueError, TypeError, OverflowError): + return unknown + if execution_filter_factory is None: + from ib_insync import ExecutionFilter + execution_filter_factory = ExecutionFilter + try: + with _quiet_reads(ib): + opened = list(ib.reqAllOpenOrders()) + completed = list(ib.reqCompletedOrders(apiOnly=False)) + executions = list(ib.reqExecutions(execution_filter_factory(acctCode=account_id))) + except Exception: + raise OrderOutcomeReadError('broker_outcome_read_failed') from None + try: + states = [] + times = [] + for trade in [*opened, *completed]: + order = trade.order + if str(order.orderId) != order_id: + continue + if order.account != account_id or order.clientId != client_id: + continue + if (trade.contract.symbol != symbol or order.action.upper() != side.upper() + or _number(order.totalQuantity) != qty): + return unknown + if permanent_id is not None and getattr(order, 'permId', None) != permanent_id: + return unknown + state = trade.orderStatus + filled, remaining = _number(state.filled), _number(state.remaining) + price = _number(state.avgFillPrice) if filled else None + if filled == remaining == 0: + # ib_insync order callbacks can construct an empty OrderStatus. + # Its zero defaults are not broker fill/remaining observations. + # Resolve these fields below from explicit filledQuantity and + # executions, rather than accepting or rejecting those defaults. + try: + explicit = _number(order.filledQuantity) + if not 0 <= explicit <= qty: + return unknown + except (AttributeError, ValueError, TypeError): + explicit = None + states.append((str(state.status), explicit, None, None)) + else: + if (filled < 0 or remaining < 0 or not math.isclose(filled + remaining, qty) + or (filled > 0 and (price is None or price <= 0))): + return unknown + states.append((str(state.status), filled, remaining, price)) + times.extend(_time(entry.time) for entry in getattr(trade, 'log', ()) if getattr(entry, 'time', None)) + fills: dict[str, tuple[OutcomeFill, float | None, datetime]] = {} + for item in executions: + execution = item.execution + if str(execution.orderId) != order_id: + continue + # Other client/account records with the same integer order ID are + # not ours. A matching trade is also required to agree above. + if execution.acctNumber != account_id or execution.clientId != client_id: + continue + if item.contract.symbol != symbol or execution.side.upper() != {'buy': 'BOT', 'sell': 'SLD'}[side.lower()]: + return unknown + if permanent_id is not None and getattr(execution, 'permId', None) != permanent_id: + return unknown + at = _time(execution.time) + if at < since: + return unknown + fill = OutcomeFill(str(execution.execId), _number(execution.shares), _number(execution.price)) + if not fill.execution_id or fill.quantity <= 0 or fill.price <= 0: + return unknown + commission = None + try: + commission = _number(item.commissionReport.commission) + except (AttributeError, ValueError, TypeError): + pass # The broker can deliver commission later; never assume zero. + record = (fill, commission, at) + if fill.execution_id in fills and fills[fill.execution_id] != record: + return unknown + fills[fill.execution_id] = record + observed = sum(row[0].quantity for row in fills.values()) + if observed > qty: + return unknown + for index, state in enumerate(states): + raw, filled, remaining, price = state + if remaining is not None: + continue + if filled is None: + if not fills: + return unknown + filled = observed + if filled and not fills: + return unknown # No broker price is available on this source. + price = (sum(row[0].quantity * row[0].price for row in fills.values()) / observed + if observed else None) + states[index] = (raw, filled, qty - filled, price) + terminal_states = [row for row in states if row[0] in {'Filled', 'Cancelled', 'ApiCancelled', 'Rejected'}] + if terminal_states and len(set(terminal_states)) != 1: + return unknown + selected = terminal_states[0] if terminal_states else max(states, key=lambda row: row[1], default=None) + if selected is None and not fills: + return unknown + filled = selected[1] if selected else observed + remaining = selected[2] if selected else qty - observed + price = selected[3] if selected else sum(row[0].quantity * row[0].price for row in fills.values()) / observed + if fills and (not math.isclose(observed, filled) or not math.isclose( + sum(row[0].quantity * row[0].price for row in fills.values()) / observed, price or 0, rel_tol=1e-6)): + return unknown + if selected and any(row[1] > filled for row in states): + return unknown + raw_status = selected[0] if selected else '' + terminal = bool(terminal_states) + if raw_status == 'Filled' and (filled != qty or remaining != 0): + return unknown + if raw_status == 'Rejected' and filled != 0: + return unknown + if raw_status in {'Cancelled', 'ApiCancelled'}: + status = 'cancelled' + elif raw_status == 'Rejected': + status = 'rejected' + elif filled == qty and (raw_status == 'Filled' or observed == qty): + status, terminal = 'filled', True + elif filled > 0 and (not selected or raw_status in {'Submitted', 'PreSubmitted', 'PartiallyFilled', 'Partial'}): + status = 'partially_filled' + elif selected and raw_status in {'Submitted', 'PreSubmitted', 'PendingSubmit', 'ApiPending', 'PendingCancel'}: + status = 'pending' + else: + return unknown + times.extend(row[2] for row in fills.values()) + if times and max(times) < since: + return unknown + if terminal and not times and permanent_id is None: + return unknown # An old reused integer ID cannot prove this order. + commission = (sum(row[1] for row in fills.values()) + if fills and all(row[1] is not None for row in fills.values()) else None) + return OrderOutcome(order_id, status, filled, remaining, price, commission, + max(times).isoformat() if times else None, terminal, + tuple(row[0] for _, row in sorted(fills.items()))) + except (AttributeError, ValueError, TypeError, OverflowError, ZeroDivisionError): + return unknown diff --git a/src/quant_platform_kit/notifications/_redaction.py b/src/quant_platform_kit/notifications/_redaction.py index 4cf92fcc..d04067c3 100644 --- a/src/quant_platform_kit/notifications/_redaction.py +++ b/src/quant_platform_kit/notifications/_redaction.py @@ -76,3 +76,8 @@ def redact_sensitive_data(obj: object) -> object: def redact_exception(exc: BaseException) -> str: """Render a traceback and its chained causes without credential-shaped text.""" return redact_sensitive_text("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) + + +def mask_identifier(value: object) -> str: + """Fully mask a known identifier, including aliases and short numeric IDs.""" + return _REDACTED diff --git a/tests/test_ibkr_order_outcome.py b/tests/test_ibkr_order_outcome.py new file mode 100644 index 00000000..75c76edb --- /dev/null +++ b/tests/test_ibkr_order_outcome.py @@ -0,0 +1,161 @@ +from datetime import datetime, timezone +from types import SimpleNamespace as NS + +import pytest + +from quant_platform_kit.ibkr.order_outcome import resolve_order_outcome, OrderOutcomeReadError + +NOW = datetime(2026, 11, 3, 14, 31, tzinfo=timezone.utc) + + +def trade(status='Submitted', filled=0, remaining=3, price=0, **overrides): + order = dict(orderId=73, account='U00000003', clientId=7, action='SELL', totalQuantity=3) + order.update(overrides) + return NS(order=NS(**order), contract=NS(symbol='TQQQ'), + orderStatus=NS(status=status, filled=filled, remaining=remaining, avgFillPrice=price), + log=[NS(time=NOW)]) + + +def fill(qty=3, price=52, exec_id='synthetic-exec-1', **overrides): + values = dict(orderId=73, acctNumber='U00000003', clientId=7, side='SLD', shares=qty, + price=price, execId=exec_id, time=NOW) + values.update(overrides) + return NS(execution=NS(**values), contract=NS(symbol='TQQQ'), + commissionReport=NS(commission=0.35)) + + +class Broker: + def __init__(self, opened=(), completed=(), executions=()): + self.opened, self.completed, self.executions = opened, completed, executions + self.reads = [] + + def reqAllOpenOrders(self): + self.reads.append('open') + return self.opened + + def reqCompletedOrders(self, apiOnly): + self.reads.append('completed') + return self.completed + + def reqExecutions(self, query): + self.reads.append('executions') + return self.executions + + +def resolve(broker, **kwargs): + return resolve_order_outcome(broker, '73', account_id='U00000003', client_id=7, + symbol='TQQQ', side='sell', quantity=3, + submitted_at=NOW, execution_filter_factory=NS, **kwargs) + + +def test_pending_then_filled_tqqq_sell_three(): + b = Broker(opened=[trade()]) + assert resolve(b).status == 'pending' + b.opened = [] + b.completed = [trade('Filled', 3, 0, 52)] + b.executions = [fill()] + result = resolve(b) + assert (result.status, result.filled_qty, result.remaining, result.avg_price) == ('filled', 3, 0, 52) + assert result.terminal and result.commission == 0.35 + assert result.last_update == NOW.isoformat() + assert b.reads == ['open', 'completed', 'executions'] * 2 + + +def test_partial_then_cancelled_preserves_fill(): + b = Broker(opened=[trade('Submitted', 1, 2, 51)], executions=[fill(1, 51)]) + assert resolve(b).status == 'partially_filled' + assert not resolve(b).terminal + b.opened = [] + b.completed = [trade('Cancelled', 1, 2, 51)] + result = resolve(b) + assert result.terminal and result.status == 'cancelled' + assert result.filled_qty == 1 and result.remaining == 2 + + +@pytest.mark.parametrize('status', ['Rejected', 'Cancelled', 'ApiCancelled']) +def test_zero_fill_terminal(status): + r = resolve(Broker(completed=[trade(status)])) + assert r.terminal and r.filled_qty == 0 and r.avg_price is None + + +@pytest.mark.parametrize('overrides', [dict(orderId=74), dict(account='U00000004'), dict(clientId=8), + dict(action='BUY'), dict(totalQuantity=4)]) +def test_identity_mismatch_never_fills(overrides): + r = resolve(Broker(completed=[trade('Filled', 3, 0, 52, **overrides)])) + assert r.status == 'unknown' and not r.terminal and r.filled_qty is None + + +@pytest.mark.parametrize('filled,remaining,price', [(3, 2, 52), (3, 0, 0), (float('nan'), 0, 52), (4, 0, 52)]) +def test_invalid_facts_remain_unknown(filled, remaining, price): + r = resolve(Broker(completed=[trade('Filled', filled, remaining, price)])) + assert r.status == 'unknown' + + +def test_executions_can_confirm_full_fill_without_receipt(): + r = resolve(Broker(executions=[fill()])) + assert r.status == 'filled' and r.filled_qty == 3 + + +def test_conflicting_execution_duplicate_is_unknown(): + assert resolve(Broker(executions=[fill(), fill(price=53)])).status == 'unknown' + + +def test_missing_evidence_never_assumes_zero_fill(): + r = resolve(Broker()) + assert r.status == 'unknown' and r.filled_qty is None and r.remaining is None + + +def test_transport_error_is_failure_without_private_diagnostics(): + b = Broker() + def fail(): + raise TimeoutError('synthetic-private-diagnostic') + b.reqAllOpenOrders = fail + with pytest.raises(OrderOutcomeReadError, match='broker_outcome_read_failed') as error: + resolve(b) + assert 'synthetic-private-diagnostic' not in str(error.value) + + +def test_invalid_timestamp_and_old_execution_are_unknown(): + assert resolve(Broker(executions=[fill(time=datetime(2020, 1, 1, tzinfo=timezone.utc))])).status == 'unknown' + assert resolve(Broker(executions=[fill(time=NOW.replace(tzinfo=None))])).status == 'unknown' + + +def test_real_completed_order_shape_uses_executions_not_default_zeroes(): + completed = trade('Filled', 0, 0, 0, filledQuantity=3) + completed.log = [] + result = resolve(Broker(completed=[completed], executions=[fill()])) + assert result.status == 'filled' and result.filled_qty == 3 + + +def test_completed_cancel_without_executions_requires_stable_permanent_id(): + completed = trade('Cancelled', 0, 0, 0, filledQuantity=0, permId=9901) + completed.log = [] + b = Broker(completed=[completed]) + assert resolve(b).status == 'unknown' + assert resolve(b, permanent_id=9901).status == 'cancelled' + assert resolve(b, permanent_id=9902).status == 'unknown' + + +def test_same_integer_order_id_on_different_client_is_not_our_order(): + result = resolve(Broker(completed=[trade('Filled', 3, 0, 52, clientId=8), trade('Filled', 3, 0, 52)])) + assert result.status == 'filled' + + +def test_broker_callback_diagnostics_are_suppressed_and_logger_restored(caplog): + import logging + b = Broker() + logger = logging.getLogger('synthetic-outcome-broker') + b.wrapper = NS(_logger=logger) + def opened(): + b.wrapper._logger.error('synthetic-private-account-diagnostic') + return [] + b.reqAllOpenOrders = opened + resolve(b) + assert 'synthetic-private-account-diagnostic' not in caplog.text + assert b.wrapper._logger is logger + + +def test_shared_identifier_mask_never_exposes_short_ids_or_aliases(): + from quant_platform_kit.notifications._redaction import mask_identifier + for value in ('73', 'synthetic-account-alias', 'U00000003', None): + assert mask_identifier(value) == '' From 8af503569d30304d23c17a6eff31c549a10b4d66 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 3 Nov 2026 05:36:34 +0800 Subject: [PATCH 2/6] test: Replay a synthetic TQQQ sell settlement --- tests/fixtures/ibkr-tqqq-sell-three.json | 7 +++++++ tests/test_ibkr_order_outcome.py | 10 +++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/ibkr-tqqq-sell-three.json diff --git a/tests/fixtures/ibkr-tqqq-sell-three.json b/tests/fixtures/ibkr-tqqq-sell-three.json new file mode 100644 index 00000000..66df9b78 --- /dev/null +++ b/tests/fixtures/ibkr-tqqq-sell-three.json @@ -0,0 +1,7 @@ +{ + "synthetic": true, + "scenario": "TQQQ SELL 3: acknowledged, then broker-confirmed fill", + "pending": {"status": "Submitted", "filled": 0, "remaining": 3, "price": 0}, + "terminal": {"status": "Filled", "filled": 3, "remaining": 0, "price": 52}, + "execution": {"qty": 3, "price": 52, "exec_id": "synthetic-exec-1"} +} diff --git a/tests/test_ibkr_order_outcome.py b/tests/test_ibkr_order_outcome.py index 75c76edb..0c92755d 100644 --- a/tests/test_ibkr_order_outcome.py +++ b/tests/test_ibkr_order_outcome.py @@ -49,11 +49,15 @@ def resolve(broker, **kwargs): def test_pending_then_filled_tqqq_sell_three(): - b = Broker(opened=[trade()]) + import json + from pathlib import Path + replay = json.loads((Path(__file__).parent / 'fixtures/ibkr-tqqq-sell-three.json').read_text()) + assert replay['synthetic'] is True + b = Broker(opened=[trade(**replay['pending'])]) assert resolve(b).status == 'pending' b.opened = [] - b.completed = [trade('Filled', 3, 0, 52)] - b.executions = [fill()] + b.completed = [trade(**replay['terminal'])] + b.executions = [fill(**replay['execution'])] result = resolve(b) assert (result.status, result.filled_qty, result.remaining, result.avg_price) == ('filled', 3, 0, 52) assert result.terminal and result.commission == 0.35 From dcbd9c601fc8f1ddb9be9679a38e9f748dd03da6 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 3 Nov 2026 05:53:26 +0800 Subject: [PATCH 3/6] docs: Avoid an overlapping runtime index insertion --- docs/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 7e8a6298..0d7cadd2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,6 @@ Read documents at the library revision used by your consumer. These are source-l ## Runtime contracts - - [Platform notification outcomes](platform-notification-outcomes.md): credential-free intents, receipts and log redaction. - [Notification routing policy](notification-routing-policy.md): durable daily facts and urgent incidents. From a17d588308fa0472867065f01afd3acb5546c1d7 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 3 Nov 2026 07:58:26 +0800 Subject: [PATCH 4/6] fix: Validate IBKR settlement evidence and request failures --- docs/ibkr-order-outcomes.md | 20 +++-- src/quant_platform_kit/ibkr/order_outcome.py | 49 ++++++++++-- tests/test_ibkr_order_outcome.py | 82 +++++++++++++++++++- 3 files changed, 139 insertions(+), 12 deletions(-) diff --git a/docs/ibkr-order-outcomes.md b/docs/ibkr-order-outcomes.md index eb4ae0cf..944572ed 100644 --- a/docs/ibkr-order-outcomes.md +++ b/docs/ibkr-order-outcomes.md @@ -17,20 +17,30 @@ execution fills. Its status is one of `filled`, `partially_filled`, `cancelled`, obligation. A cancellation can be terminal with a positive filled quantity. Missing quantities/prices stay `None`; no requested quantity is promoted to a -fill. Missing commission reports stay unknown. `last_update` is a broker -observation timestamp, never the local query time. Empty sources, mismatched or +fill. Missing commission reports stay unknown. A known fee requires a matching +execution ID, a populated report currency and a finite commission; a matching +zero is valid. `last_update` is a broker observation timestamp, never the local +query time. Empty sources, mismatched or inconsistent facts, naive timestamps and ambiguous `Inactive` statuses remain unknown. Transport failures raise `OrderOutcomeReadError` with a fixed diagnostic; -callers must not treat a partial source snapshot as success. +callers must not treat a partial source snapshot as success. SDK error events +observed during the three reads also fail the observation, including when the +SDK returns an empty list instead of raising. The temporary listener is removed +on success or failure; the caller's `RaiseRequestErrors` setting is unchanged. The `ib_insync` completed-order callback supplies an empty `OrderStatus`. Its zero defaults are not fill facts. The resolver requires explicit `order.filledQuantity` and/or matching executions. Without a broker timestamp, a stable permanent ID is required for a zero-fill terminal observation. +Each terminal observation must supply its own freshness evidence; an open order's +log cannot date a timeless terminal. Conflicting observed permanent IDs remain +unknown even when the caller has no permanent ID. Execution freshness compares +UTC timestamps at broker second resolution, admitting same-second fills while +rejecting executions from an earlier second. Original timestamp values are kept. Execution IDs are deduplicated; conflicting duplicates remain unknown. -Consumers own bounded request timeouts on their private connection and durable -observation storage. They must keep financial records in protected storage and +Consumers own bounded request timeouts and exclusive reads on their private +connection, and durable observation storage. They must keep financial records in protected storage and mask identifiers in notices. Observations do not clear UNKNOWN execution claims, release cash or risk reservations, or authorize another order. Adoption requires a separately reviewed consumer revision; library tests do not prove deployment diff --git a/src/quant_platform_kit/ibkr/order_outcome.py b/src/quant_platform_kit/ibkr/order_outcome.py index e13ee90c..b661497b 100644 --- a/src/quant_platform_kit/ibkr/order_outcome.py +++ b/src/quant_platform_kit/ibkr/order_outcome.py @@ -59,14 +59,29 @@ def log(self, level, msg, *args, **kwargs): @contextmanager def _quiet_reads(ib): originals = [] + failed = False + def on_error(*args): + nonlocal failed + failed = True + # The SDK can end a failed request with [] when RaiseRequestErrors=False. + # Observe errors without changing caller configuration or retaining payloads. + errors = getattr(ib, 'errorEvent', None) + subscribed = False try: + if errors is not None: + errors += on_error + subscribed = True for owner in (ib, getattr(ib, 'wrapper', None), getattr(ib, 'client', None)): logger = getattr(owner, '_logger', None) if logger is not None: originals.append((owner, logger)) owner._logger = _QuietOutcomeLogger(logger, {}) yield + if failed: + raise OrderOutcomeReadError('broker_outcome_read_failed') finally: + if subscribed: + errors -= on_error for owner, logger in reversed(originals): owner._logger = logger @@ -108,7 +123,8 @@ def resolve_order_outcome( raise OrderOutcomeReadError('broker_outcome_read_failed') from None try: states = [] - times = [] + state_times = [] + identities = set() for trade in [*opened, *completed]: order = trade.order if str(order.orderId) != order_id: @@ -120,6 +136,9 @@ def resolve_order_outcome( return unknown if permanent_id is not None and getattr(order, 'permId', None) != permanent_id: return unknown + observed_id = getattr(order, 'permId', None) + if observed_id: + identities.add(observed_id) state = trade.orderStatus filled, remaining = _number(state.filled), _number(state.remaining) price = _number(state.avgFillPrice) if filled else None @@ -140,7 +159,8 @@ def resolve_order_outcome( or (filled > 0 and (price is None or price <= 0))): return unknown states.append((str(state.status), filled, remaining, price)) - times.extend(_time(entry.time) for entry in getattr(trade, 'log', ()) if getattr(entry, 'time', None)) + state_times.append([_time(entry.time) for entry in getattr(trade, 'log', ()) + if getattr(entry, 'time', None)]) fills: dict[str, tuple[OutcomeFill, float | None, datetime]] = {} for item in executions: execution = item.execution @@ -154,21 +174,28 @@ def resolve_order_outcome( return unknown if permanent_id is not None and getattr(execution, 'permId', None) != permanent_id: return unknown + observed_id = getattr(execution, 'permId', None) + if observed_id: + identities.add(observed_id) at = _time(execution.time) - if at < since: + if at.replace(microsecond=0) < since.replace(microsecond=0): return unknown fill = OutcomeFill(str(execution.execId), _number(execution.shares), _number(execution.price)) if not fill.execution_id or fill.quantity <= 0 or fill.price <= 0: return unknown commission = None try: - commission = _number(item.commissionReport.commission) + report = item.commissionReport + if report.execId == fill.execution_id and report.currency: + commission = _number(report.commission) except (AttributeError, ValueError, TypeError): pass # The broker can deliver commission later; never assume zero. record = (fill, commission, at) if fill.execution_id in fills and fills[fill.execution_id] != record: return unknown fills[fill.execution_id] = record + if len(identities) > 1: + return unknown observed = sum(row[0].quantity for row in fills.values()) if observed > qty: return unknown @@ -185,7 +212,16 @@ def resolve_order_outcome( price = (sum(row[0].quantity * row[0].price for row in fills.values()) / observed if observed else None) states[index] = (raw, filled, qty - filled, price) - terminal_states = [row for row in states if row[0] in {'Filled', 'Cancelled', 'ApiCancelled', 'Rejected'}] + terminal_indices = [index for index, row in enumerate(states) + if row[0] in {'Filled', 'Cancelled', 'ApiCancelled', 'Rejected'}] + terminal_states = [states[index] for index in terminal_indices] + for index in terminal_indices: + # A different observation cannot supply freshness for this terminal. + own_times = state_times[index] + if own_times and max(own_times) < since: + return unknown + if not own_times and permanent_id is None and not fills: + return unknown if terminal_states and len(set(terminal_states)) != 1: return unknown selected = terminal_states[0] if terminal_states else max(states, key=lambda row: row[1], default=None) @@ -217,8 +253,9 @@ def resolve_order_outcome( status = 'pending' else: return unknown + times = list(state_times[states.index(selected)]) if selected else [] times.extend(row[2] for row in fills.values()) - if times and max(times) < since: + if times and max(times).replace(microsecond=0) < since.replace(microsecond=0): return unknown if terminal and not times and permanent_id is None: return unknown # An old reused integer ID cannot prove this order. diff --git a/tests/test_ibkr_order_outcome.py b/tests/test_ibkr_order_outcome.py index 0c92755d..dd9ea3db 100644 --- a/tests/test_ibkr_order_outcome.py +++ b/tests/test_ibkr_order_outcome.py @@ -21,7 +21,7 @@ def fill(qty=3, price=52, exec_id='synthetic-exec-1', **overrides): price=price, execId=exec_id, time=NOW) values.update(overrides) return NS(execution=NS(**values), contract=NS(symbol='TQQQ'), - commissionReport=NS(commission=0.35)) + commissionReport=NS(execId=exec_id, currency='USD', commission=0.35)) class Broker: @@ -163,3 +163,83 @@ def test_shared_identifier_mask_never_exposes_short_ids_or_aliases(): from quant_platform_kit.notifications._redaction import mask_identifier for value in ('73', 'synthetic-account-alias', 'U00000003', None): assert mask_identifier(value) == '' + + +class ErrorEvent: + def __init__(self): + self.handlers = [] + + def __iadd__(self, handler): + self.handlers.append(handler) + return self + + def __isub__(self, handler): + self.handlers.remove(handler) + return self + + def emit(self): + for handler in self.handlers: + handler(901, 321, 'synthetic-private-diagnostic', None) + + +@pytest.mark.parametrize('source', ['reqAllOpenOrders', 'reqCompletedOrders', 'reqExecutions']) +def test_unraised_request_error_fails_closed_and_listener_is_restored(source): + broker = Broker(completed=[trade('Filled', 3, 0, 52)]) + broker.errorEvent = ErrorEvent() + broker.RaiseRequestErrors = False + original = getattr(broker, source) + def failed(*args, **kwargs): + broker.errorEvent.emit() + return original(*args, **kwargs) + setattr(broker, source, failed) + with pytest.raises(OrderOutcomeReadError, match='^broker_outcome_read_failed$'): + resolve(broker) + assert broker.errorEvent.handlers == [] + assert broker.RaiseRequestErrors is False + + +@pytest.mark.parametrize('report', [NS(commission=0.0, execId='', currency=''), + NS(commission=1.25, execId='synthetic-other', currency='USD'), + NS(commission=0.0, execId='synthetic-exec-1', currency='')]) +def test_absent_or_unbound_commission_stays_unknown(report): + execution = fill() + execution.commissionReport = report + assert resolve(Broker(executions=[execution])).commission is None + + +def test_matching_zero_commission_is_known(): + execution = fill() + execution.commissionReport.commission = 0.0 + assert resolve(Broker(executions=[execution])).commission == 0.0 + + +@pytest.mark.parametrize('offset,expected', [(0, 'filled'), (-1, 'unknown')]) +def test_execution_time_uses_broker_second_precision(offset, expected): + from datetime import timedelta + result = resolve_order_outcome(Broker(executions=[fill(time=NOW + timedelta(seconds=offset))]), + '73', account_id='U00000003', client_id=7, symbol='TQQQ', side='sell', quantity=3, + submitted_at=NOW.replace(microsecond=123456), execution_filter_factory=NS) + assert result.status == expected + + +@pytest.mark.parametrize('permanent', [None, 9900, 9901]) +def test_timeless_terminal_cannot_borrow_open_order_timestamp(permanent): + old = trade('Cancelled', 0, 0, filledQuantity=0) + old.log = [] + if permanent is not None: + old.order.permId = permanent + current = trade(permId=9901) + assert resolve(Broker(opened=[current], completed=[old])).status == 'unknown' + + +def test_old_terminal_timestamp_cannot_borrow_newer_open_order_timestamp(): + from datetime import timedelta + old = trade('Cancelled') + old.log = [NS(time=NOW - timedelta(seconds=1))] + assert resolve(Broker(opened=[trade()], completed=[old])).status == 'unknown' + + +def test_conflicting_execution_permanent_identity_is_unknown(): + result = resolve(Broker(completed=[trade('Filled', 3, 0, 52, permId=9900)], + executions=[fill(permId=9901)])) + assert result.status == 'unknown' From 81e6da3140c69b46a3736e852fe8a887537ca423 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 3 Nov 2026 08:32:40 +0800 Subject: [PATCH 5/6] fix: Bind timeless outcomes and retain commission currency --- docs/ibkr-order-outcomes.md | 11 ++-- src/quant_platform_kit/ibkr/order_outcome.py | 36 ++++++++++--- tests/test_ibkr_order_outcome.py | 53 +++++++++++++++++++- 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/docs/ibkr-order-outcomes.md b/docs/ibkr-order-outcomes.md index 944572ed..6d22fde8 100644 --- a/docs/ibkr-order-outcomes.md +++ b/docs/ibkr-order-outcomes.md @@ -19,8 +19,10 @@ obligation. A cancellation can be terminal with a positive filled quantity. Missing quantities/prices stay `None`; no requested quantity is promoted to a fill. Missing commission reports stay unknown. A known fee requires a matching execution ID, a populated report currency and a finite commission; a matching -zero is valid. `last_update` is a broker observation timestamp, never the local -query time. Empty sources, mismatched or +zero is valid. Currency participates in duplicate comparison; conflicting reports +remain unknown. Fees are summed only when every report uses the same currency; +mixed currencies leave commission unknown without conversion. `last_update` is a +broker observation timestamp, never the local query time. Empty sources, mismatched or inconsistent facts, naive timestamps and ambiguous `Inactive` statuses remain unknown. Transport failures raise `OrderOutcomeReadError` with a fixed diagnostic; callers must not treat a partial source snapshot as success. SDK error events @@ -31,7 +33,10 @@ on success or failure; the caller's `RaiseRequestErrors` setting is unchanged. The `ib_insync` completed-order callback supplies an empty `OrderStatus`. Its zero defaults are not fill facts. The resolver requires explicit `order.filledQuantity` and/or matching executions. Without a broker timestamp, -a stable permanent ID is required for a zero-fill terminal observation. +a positive, non-Boolean integer permanent ID must bind that terminal to the +caller's supplied permanent ID or to every matching execution. An unbound +terminal cannot borrow execution freshness, including for partial cancellations. +Invalid supplied permanent IDs leave the outcome unknown. Each terminal observation must supply its own freshness evidence; an open order's log cannot date a timeless terminal. Conflicting observed permanent IDs remain unknown even when the caller has no permanent ID. Execution freshness compares diff --git a/src/quant_platform_kit/ibkr/order_outcome.py b/src/quant_platform_kit/ibkr/order_outcome.py index b661497b..f086e24f 100644 --- a/src/quant_platform_kit/ibkr/order_outcome.py +++ b/src/quant_platform_kit/ibkr/order_outcome.py @@ -107,7 +107,9 @@ def resolve_order_outcome( since = _time(submitted_at) if (not order_id.isdecimal() or int(order_id) <= 0 or not account_id or type(client_id) is not int or client_id < 0 or qty <= 0 - or side.lower() not in {'buy', 'sell'} or not symbol): + or side.lower() not in {'buy', 'sell'} or not symbol + or (permanent_id is not None + and (type(permanent_id) is not int or permanent_id <= 0))): return unknown except (ValueError, TypeError, OverflowError): return unknown @@ -124,6 +126,8 @@ def resolve_order_outcome( try: states = [] state_times = [] + state_ids = [] + execution_ids = [] identities = set() for trade in [*opened, *completed]: order = trade.order @@ -138,7 +142,10 @@ def resolve_order_outcome( return unknown observed_id = getattr(order, 'permId', None) if observed_id: + if type(observed_id) is not int or observed_id <= 0: + return unknown identities.add(observed_id) + state_ids.append(observed_id) state = trade.orderStatus filled, remaining = _number(state.filled), _number(state.remaining) price = _number(state.avgFillPrice) if filled else None @@ -161,7 +168,7 @@ def resolve_order_outcome( states.append((str(state.status), filled, remaining, price)) state_times.append([_time(entry.time) for entry in getattr(trade, 'log', ()) if getattr(entry, 'time', None)]) - fills: dict[str, tuple[OutcomeFill, float | None, datetime]] = {} + fills: dict[str, tuple[OutcomeFill, tuple[float, str] | None, datetime]] = {} for item in executions: execution = item.execution if str(execution.orderId) != order_id: @@ -176,7 +183,10 @@ def resolve_order_outcome( return unknown observed_id = getattr(execution, 'permId', None) if observed_id: + if type(observed_id) is not int or observed_id <= 0: + return unknown identities.add(observed_id) + execution_ids.append(observed_id) at = _time(execution.time) if at.replace(microsecond=0) < since.replace(microsecond=0): return unknown @@ -186,8 +196,9 @@ def resolve_order_outcome( commission = None try: report = item.commissionReport - if report.execId == fill.execution_id and report.currency: - commission = _number(report.commission) + if (report.execId == fill.execution_id and isinstance(report.currency, str) + and report.currency.strip()): + commission = (_number(report.commission), report.currency) except (AttributeError, ValueError, TypeError): pass # The broker can deliver commission later; never assume zero. record = (fill, commission, at) @@ -220,8 +231,15 @@ def resolve_order_outcome( own_times = state_times[index] if own_times and max(own_times) < since: return unknown - if not own_times and permanent_id is None and not fills: - return unknown + if not own_times: + # A timeless terminal must bind to the caller's stable identity + # or to every execution supplying its current fill evidence. + state_id = state_ids[index] + if (type(state_id) is not int or state_id <= 0 + or not (state_id == permanent_id or + (execution_ids and all(type(value) is int and value == state_id + for value in execution_ids)))): + return unknown if terminal_states and len(set(terminal_states)) != 1: return unknown selected = terminal_states[0] if terminal_states else max(states, key=lambda row: row[1], default=None) @@ -259,8 +277,10 @@ def resolve_order_outcome( return unknown if terminal and not times and permanent_id is None: return unknown # An old reused integer ID cannot prove this order. - commission = (sum(row[1] for row in fills.values()) - if fills and all(row[1] is not None for row in fills.values()) else None) + fees = [row[1] for row in fills.values()] + commission = (sum(fee[0] for fee in fees) + if fees and all(fee is not None for fee in fees) + and len({fee[1] for fee in fees}) == 1 else None) return OrderOutcome(order_id, status, filled, remaining, price, commission, max(times).isoformat() if times else None, terminal, tuple(row[0] for _, row in sorted(fills.items()))) diff --git a/tests/test_ibkr_order_outcome.py b/tests/test_ibkr_order_outcome.py index dd9ea3db..911d35eb 100644 --- a/tests/test_ibkr_order_outcome.py +++ b/tests/test_ibkr_order_outcome.py @@ -125,9 +125,9 @@ def test_invalid_timestamp_and_old_execution_are_unknown(): def test_real_completed_order_shape_uses_executions_not_default_zeroes(): - completed = trade('Filled', 0, 0, 0, filledQuantity=3) + completed = trade('Filled', 0, 0, 0, filledQuantity=3, permId=9901) completed.log = [] - result = resolve(Broker(completed=[completed], executions=[fill()])) + result = resolve(Broker(completed=[completed], executions=[fill(permId=9901)])) assert result.status == 'filled' and result.filled_qty == 3 @@ -243,3 +243,52 @@ def test_conflicting_execution_permanent_identity_is_unknown(): result = resolve(Broker(completed=[trade('Filled', 3, 0, 52, permId=9900)], executions=[fill(permId=9901)])) assert result.status == 'unknown' + + +@pytest.mark.parametrize('permanent', [None, 0, -1, True, 1.0, '9901']) +def test_invalid_permanent_id_cannot_date_timeless_terminal(permanent): + old = trade('Cancelled', 0, 0, filledQuantity=0, permId=permanent) + old.log = [] + result = resolve(Broker(completed=[old]), permanent_id=permanent) + assert result.status == 'unknown' and not result.terminal + + +@pytest.mark.parametrize('trade_id', [None, 0, -1, True, 9902]) +def test_timeless_cancel_cannot_borrow_unbound_execution(trade_id): + old = trade('Cancelled', 0, 0, filledQuantity=1, permId=trade_id) + old.log = [] + result = resolve(Broker(completed=[old], executions=[fill(1, permId=9901)])) + assert result.status == 'unknown' and not result.terminal + + +@pytest.mark.parametrize('permanent', [None, 9901]) +def test_timeless_partial_cancel_positive_execution_binding(permanent): + current = trade('Cancelled', 0, 0, filledQuantity=1, permId=9901) + current.log = [] + result = resolve(Broker(completed=[current], executions=[fill(1, permId=9901)]), + permanent_id=permanent) + assert result.status == 'cancelled' and result.terminal and result.filled_qty == 1 + + +def test_duplicate_commission_currency_conflict_is_unknown(): + first, second = fill(), fill() + second.commissionReport.currency = 'EUR' + result = resolve(Broker(executions=[first, second])) + assert result.status == 'unknown' and result.commission is None + + +def test_mixed_commission_currencies_are_not_summed(): + first, second = fill(1), fill(2, exec_id='synthetic-exec-2') + second.commissionReport.currency = 'EUR' + result = resolve(Broker(executions=[first, second])) + assert result.status == 'filled' and result.commission is None + + +def test_same_currency_duplicate_is_idempotent(): + first = fill() + assert resolve(Broker(executions=[first, fill()])) == resolve(Broker(executions=[first])) + + +def test_same_currency_distinct_executions_sum_fees(): + result = resolve(Broker(executions=[fill(1), fill(2, exec_id='synthetic-exec-2')])) + assert result.status == 'filled' and result.commission == 0.7 From 66e793e31583db78aa8788e1e4c893543f51fa89 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 3 Nov 2026 08:47:05 +0800 Subject: [PATCH 6/6] fix(ibkr): reject snapshots with SDK decoder failures --- docs/ibkr-order-outcomes.md | 5 +- src/quant_platform_kit/ibkr/order_outcome.py | 25 +++-- tests/test_ibkr_order_outcome.py | 28 +++++ tests/test_ibkr_order_outcome_decoder.py | 111 +++++++++++++++++++ 4 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 tests/test_ibkr_order_outcome_decoder.py diff --git a/docs/ibkr-order-outcomes.md b/docs/ibkr-order-outcomes.md index 6d22fde8..5bc26cf3 100644 --- a/docs/ibkr-order-outcomes.md +++ b/docs/ibkr-order-outcomes.md @@ -27,7 +27,10 @@ inconsistent facts, naive timestamps and ambiguous `Inactive` statuses remain unknown. Transport failures raise `OrderOutcomeReadError` with a fixed diagnostic; callers must not treat a partial source snapshot as success. SDK error events observed during the three reads also fail the observation, including when the -SDK returns an empty list instead of raising. The temporary listener is removed +SDK returns an empty list instead of raising. Decoder error diagnostics also fail +the read, including SDK exceptions swallowed before emitting an error event. Raw +SDK diagnostics are suppressed during the reads; decoder and other SDK loggers +are restored afterward, on both success and failure. The temporary listener is removed on success or failure; the caller's `RaiseRequestErrors` setting is unchanged. The `ib_insync` completed-order callback supplies an empty `OrderStatus`. Its diff --git a/src/quant_platform_kit/ibkr/order_outcome.py b/src/quant_platform_kit/ibkr/order_outcome.py index f086e24f..10cdc050 100644 --- a/src/quant_platform_kit/ibkr/order_outcome.py +++ b/src/quant_platform_kit/ibkr/order_outcome.py @@ -50,10 +50,15 @@ def _time(value: Any) -> datetime: class _QuietOutcomeLogger(logging.LoggerAdapter): + def __init__(self, logger, on_failure=None): + super().__init__(logger, {}) + self.on_failure = on_failure + def log(self, level, msg, *args, **kwargs): # SDK callback messages can embed complete account-bound executions. # The caller receives a fixed read failure instead of raw diagnostics. - return + if self.on_failure is not None and level >= logging.ERROR: + self.on_failure() @contextmanager @@ -71,19 +76,25 @@ def on_error(*args): if errors is not None: errors += on_error subscribed = True - for owner in (ib, getattr(ib, 'wrapper', None), getattr(ib, 'client', None)): - logger = getattr(owner, '_logger', None) + client = getattr(ib, 'client', None) + sinks = [(ib, '_logger'), (getattr(ib, 'wrapper', None), '_logger'), + (client, '_logger'), (getattr(client, 'decoder', None), 'logger')] + for owner, attribute in sinks: + logger = getattr(owner, attribute, None) if logger is not None: - originals.append((owner, logger)) - owner._logger = _QuietOutcomeLogger(logger, {}) + originals.append((owner, attribute, logger)) + # Decoder catches failures without raising or emitting errorEvent. + # Observe its error calls even when normal logging is disabled. + failure = on_error if attribute == 'logger' else None + setattr(owner, attribute, _QuietOutcomeLogger(logger, failure)) yield if failed: raise OrderOutcomeReadError('broker_outcome_read_failed') finally: if subscribed: errors -= on_error - for owner, logger in reversed(originals): - owner._logger = logger + for owner, attribute, logger in reversed(originals): + setattr(owner, attribute, logger) def resolve_order_outcome( diff --git a/tests/test_ibkr_order_outcome.py b/tests/test_ibkr_order_outcome.py index 911d35eb..3b9bd691 100644 --- a/tests/test_ibkr_order_outcome.py +++ b/tests/test_ibkr_order_outcome.py @@ -292,3 +292,31 @@ def test_same_currency_duplicate_is_idempotent(): def test_same_currency_distinct_executions_sum_fees(): result = resolve(Broker(executions=[fill(1), fill(2, exec_id='synthetic-exec-2')])) assert result.status == 'filled' and result.commission == 0.7 + + +@pytest.mark.parametrize('source', ['reqAllOpenOrders', 'reqCompletedOrders', 'reqExecutions']) +@pytest.mark.parametrize('disabled', [False, True]) +def test_decoder_diagnostic_sink_fails_closed_and_restores(source, disabled, caplog): + import logging + broker = Broker(completed=[trade('Filled', 3, 0, 52)]) + logger = logging.getLogger('synthetic-outcome-decoder') + decoder = NS(logger=logger) + broker.client = NS(decoder=decoder) + original_read = getattr(broker, source) + def failed(*args, **kwargs): + decoder.logger.exception('synthetic-private-payload U00000003') + return original_read(*args, **kwargs) + setattr(broker, source, failed) + previous_disabled = logger.disabled + logger.disabled = disabled + try: + with pytest.raises(OrderOutcomeReadError, match='^broker_outcome_read_failed$'): + resolve(broker) + assert decoder.logger is logger + assert 'synthetic-private-payload' not in caplog.text + assert 'U00000003' not in caplog.text + setattr(broker, source, original_read) + assert resolve(broker).terminal + assert decoder.logger is logger + finally: + logger.disabled = previous_disabled diff --git a/tests/test_ibkr_order_outcome_decoder.py b/tests/test_ibkr_order_outcome_decoder.py new file mode 100644 index 00000000..1926c6aa --- /dev/null +++ b/tests/test_ibkr_order_outcome_decoder.py @@ -0,0 +1,111 @@ +"""Synthetic SDK decoder integrity and diagnostic sink regressions.""" +from datetime import datetime, timezone +import logging + +import pytest +pytest.importorskip('ib_insync') # Optional SDK; core sink coverage is unconditional. +from ib_insync import IB, Stock, Order, OrderStatus, Trade, TradeLogEntry + +from quant_platform_kit.ibkr.order_outcome import resolve_order_outcome, OrderOutcomeReadError + +NOW = datetime(2026, 11, 3, 14, 31, tzinfo=timezone.utc) +ACCOUNT = 'U00000003' # Synthetic account only. +MARKER = 'synthetic-private-order-reference' + + +def decoder_broker(*, malformed): + ib = IB() + completed = Trade(Stock('TQQQ', 'SMART', 'USD'), + Order(orderId=73, account=ACCOUNT, clientId=7, action='SELL', + totalQuantity=3, permId=9901), + OrderStatus(orderId=73, status='Filled', filled=3, remaining=0, avgFillPrice=52), + [], [TradeLogEntry(NOW, 'Filled', '')]) + ib.reqAllOpenOrders = lambda: [] + ib.reqCompletedOrders = lambda apiOnly: [completed] + ib.client.getReqId = lambda: 901 + fields = ['11', '901', '73', '12345', 'TQQQ', 'STK', '', '0', '', '', + 'SMART', 'USD', 'TQQQ', 'NMS', 'synthetic-exec-1', + '20261103 14:31:00 UTC', ACCOUNT, 'SMART', 'SLD', + 'invalid-shares' if malformed else '3', '52', '9901', '7', '0', + '3', '52', MARKER, '', '0', '', '1'] + def deliver(req_id, query): + ib.client.decoder.interpret(fields) + ib.wrapper.execDetailsEnd(req_id) + ib.client.reqExecutions = deliver + return ib + + +def resolve(ib): + return resolve_order_outcome(ib, '73', account_id=ACCOUNT, client_id=7, + symbol='TQQQ', side='sell', quantity=3, submitted_at=NOW, permanent_id=9901) + + +def test_decoder_failure_does_not_accept_partial_snapshot(): + with pytest.raises(OrderOutcomeReadError, match='^broker_outcome_read_failed$'): + resolve(decoder_broker(malformed=True)) + + +def test_decoder_failure_does_not_log_private_payload(caplog): + with caplog.at_level(logging.DEBUG): + try: + resolve(decoder_broker(malformed=True)) + except OrderOutcomeReadError: + pass + assert MARKER not in caplog.text + assert ACCOUNT not in caplog.text + assert not any('Error handling fields' in r.getMessage() for r in caplog.records) + + +@pytest.mark.parametrize('malformed', [False, True]) +def test_decoder_logger_and_listener_restored(malformed): + ib = decoder_broker(malformed=malformed) + original = ib.client.decoder.logger + listeners = len(ib.errorEvent) + try: + resolve(ib) + except OrderOutcomeReadError: + assert malformed + assert ib.client.decoder.logger is original + assert len(ib.errorEvent) == listeners + + +def test_valid_decoder_control(): + outcome = resolve(decoder_broker(malformed=False)) + assert outcome.status == 'filled' and outcome.terminal and outcome.filled_qty == 3 + assert len(outcome.fills) == 1 and outcome.commission is None + + +@pytest.mark.parametrize('source', ['reqAllOpenOrders', 'reqCompletedOrders', 'reqExecutions']) +@pytest.mark.parametrize('disabled', [False, True]) +def test_decoder_error_in_any_read_fails_even_with_logging_disabled(source, disabled, caplog): + ib = decoder_broker(malformed=False) + decoder = ib.client.decoder + original_logger = decoder.logger + original_read = getattr(ib, source) + def failed_read(*args, **kwargs): + # SDK's generated callback handlers also catch and log exceptions. + decoder.interpret(['not-a-message-id', ACCOUNT, MARKER]) + return original_read(*args, **kwargs) + setattr(ib, source, failed_read) + old_disabled = original_logger.disabled + original_logger.disabled = disabled + try: + with pytest.raises(OrderOutcomeReadError, match='^broker_outcome_read_failed$'): + resolve(ib) + assert decoder.logger is original_logger + assert ACCOUNT not in caplog.text and MARKER not in caplog.text + setattr(ib, source, original_read) + assert resolve(ib).terminal # Failure state does not survive the read. + finally: + original_logger.disabled = old_disabled + + +def test_decoder_logger_restored_when_read_raises(): + ib = decoder_broker(malformed=False) + original = ib.client.decoder.logger + def failed(*args, **kwargs): + raise RuntimeError(MARKER) + ib.reqExecutions = failed + with pytest.raises(OrderOutcomeReadError, match='^broker_outcome_read_failed$'): + resolve(ib) + assert ib.client.decoder.logger is original