Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 56 additions & 0 deletions docs/ibkr-order-outcomes.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/quant_platform_kit/ibkr/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,6 +24,7 @@
)

__all__ = [
"OrderOutcome", "OutcomeFill", "OrderOutcomeReadError", "resolve_order_outcome",
"AdjustedHistoricalCandle",
"StrictAdjustedHistoryDiagnostic",
"StrictAdjustedHistoryError",
Expand Down
299 changes: 299 additions & 0 deletions src/quant_platform_kit/ibkr/order_outcome.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions src/quant_platform_kit/notifications/_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading