diff --git a/docs/README.md b/docs/README.md index fccf7d05..40e42904 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,8 @@ Read documents at the library revision used by your consumer. These are source-l - [Pending order evidence v1](pending-order-evidence-v1.md): optional journal evidence without budget release. - [Paper execution consumer](paper-execution-command-consumer.md), [portfolio action matrix](strategy-portfolio-action-matrix.md) and [evidence package](evidence-package-template.md). +- [IBKR read-only order outcomes](ibkr-order-outcomes.md): identity-bound terminal observations without execution authority. + ## Historical compatibility - [Legacy plugin runtime contract](strategy-plugin-runtime-contract.md) ([中文](strategy-plugin-runtime-contract.zh-CN.md)): **HISTORICAL / no authority effect**. Retained for replay and migration troubleshooting; not the current platform-mount specification. diff --git a/docs/ibkr-order-outcomes.md b/docs/ibkr-order-outcomes.md new file mode 100644 index 00000000..5bc26cf3 --- /dev/null +++ b/docs/ibkr-order-outcomes.md @@ -0,0 +1,56 @@ +# 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. A known fee requires a matching +execution ID, a populated report currency and a finite commission; a matching +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 +observed during the three reads also fail the observation, including when the +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 +zero defaults are not fill facts. The resolver requires explicit +`order.filledQuantity` and/or matching executions. Without a broker timestamp, +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 +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 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 +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..10cdc050 --- /dev/null +++ b/src/quant_platform_kit/ibkr/order_outcome.py @@ -0,0 +1,299 @@ +"""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 __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. + if self.on_failure is not None and level >= logging.ERROR: + self.on_failure() + + +@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 + 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, 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, attribute, logger in reversed(originals): + setattr(owner, attribute, 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 + 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 + 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 = [] + state_times = [] + state_ids = [] + execution_ids = [] + identities = set() + 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 + 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 + 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)) + state_times.append([_time(entry.time) for entry in getattr(trade, 'log', ()) + if getattr(entry, 'time', None)]) + fills: dict[str, tuple[OutcomeFill, tuple[float, str] | 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 + 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 + 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: + report = item.commissionReport + 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) + 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 + 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_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: + # 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) + 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 = list(state_times[states.index(selected)]) if selected else [] + times.extend(row[2] for row in fills.values()) + 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. + 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()))) + 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/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 new file mode 100644 index 00000000..3b9bd691 --- /dev/null +++ b/tests/test_ibkr_order_outcome.py @@ -0,0 +1,322 @@ +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(execId=exec_id, currency='USD', 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(): + 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(**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 + 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, permId=9901) + completed.log = [] + result = resolve(Broker(completed=[completed], executions=[fill(permId=9901)])) + 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) == '' + + +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' + + +@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 + + +@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