Record statuses, executions, fills, and commissions
An order status is a mutable aggregate; an execution is a fill-level fact. Keep both. Persist each execution under its exact execId, correlate it to the order with permId plus account/client/order identifiers, and merge the later commission report by the same execId. Never declare an order financially complete merely because its status became Filled: commission data can arrive afterward.
This page covers recording only. Placement and cancellation safety are in Place, modify, and cancel an order; restart recovery is the next workflow slice.
The four records have different jobs
| Record | Identity | What it establishes | What it does not establish |
|---|---|---|---|
OrderStatus observation | permId when assigned, otherwise client ID + order ID, plus the full observed payload | The latest order-level status, cumulative filled/remaining quantities, average/last fill prices, and hold/cap fields | A unique fill ledger or final commission |
Execution | Exact execId | One partial or full execution, including allocated account, quantity, price, time, exchange, permId, client/order IDs, and orderRef | Commission-inclusive cost or an immutable order aggregate |
Fill | Its Execution.execId | The pinned library's bundle of contract, execution, mutable commission object, and receive/execution time | Durable storage; IB.fills() is session memory |
CommissionReport | execId | Commission, currency, realized P&L, yield, and redemption-date fields for an execution | Proof that all executions for the order have arrived |
IBKR documents that each partial fill has its own execId. A correction arrives as another execDetails callback whose execId differs after the final period. Combo leg executions can also use an additional identifier segment to distinguish separate fills. Preserve every exact ID and the full payload, but do not infer “correction” from a shared prefix alone; classify/version a correction only from authoritative reconciliation context. See IBKR Campus: Execution object and ExecID behavior.
Callback and event order
For live activity, the common high-level path is:
orderStatusEventupdates the mutable order projection. It may repeat or be skipped for an immediately executing order.execDetailsEvent(trade, fill)andtrade.fillEvent(trade, fill)report a new exactexecIdknown to the currentIBinstance.commissionReportEvent(trade, fill, report)andtrade.commissionReportEvent(...)update that fill's existing commission object later.trade.filledEvent(trade)is an order-status transition event; it does not replace fill or commission processing.
This is not a guaranteed total order. Status can lead or lag the sum of known executions. Duplicate commission events are possible at the library event surface even though duplicate live execDetails values are suppressed by exact execId. An application handler therefore needs its own durable idempotency boundary for all three record types.
Pinned ib_async 2.1.0 treats live callbacks and requested history differently. Live fills use the wrapper receive time and emit live events. Results belonging to reqExecutionsAsync use Execution.time, are appended to that request result, and do not emit the live fill events. A repeated requested callback can appear twice in the returned list even though the session cache retains only one fill under that execId. Deduplicate requested results yourself. execDetailsEnd completes the execution list, not a separate commission-completion stream.
Minimal read-only paper observer
This example does not submit, modify, or cancel an order. It connects read-only to the paper port, subscribes before requesting the current execution window, and deduplicates the returned snapshot. The documentation validator compiles and signature-checks it; CI does not execute main, open a Gateway, or access an account.
from __future__ import annotations
import asyncio
from ib_async import CommissionReport, ExecutionFilter, Fill, IB, Trade
def execution_key(fill: Fill) -> str:
exec_id = fill.execution.execId
if not exec_id:
raise ValueError("execution is missing execId")
return exec_id
async def main() -> None:
ib = IB()
seen: set[str] = set()
def on_status(trade: Trade) -> None:
status = trade.orderStatus
print("status", status.permId, status.status, status.filled, status.remaining)
def on_fill(trade: Trade, fill: Fill) -> None:
exec_id = execution_key(fill)
if exec_id not in seen:
seen.add(exec_id)
execution = fill.execution
print("execution", exec_id, execution.acctNumber, execution.shares)
def on_commission(
trade: Trade, fill: Fill, report: CommissionReport
) -> None:
if report.execId != execution_key(fill):
raise RuntimeError("commission/fill execId mismatch")
print("commission", report.execId, report.commission, report.currency)
ib.orderStatusEvent += on_status
ib.execDetailsEvent += on_fill
ib.commissionReportEvent += on_commission
try:
await ib.connectAsync(
host="127.0.0.1",
port=4002,
clientId=13,
timeout=10,
readonly=True,
raiseSyncErrors=True,
)
expected_accounts = frozenset({"PAPER_ACCOUNT_A"})
if frozenset(ib.managedAccounts()) != expected_accounts:
raise RuntimeError("authorized paper account scope mismatch")
requested = await ib.reqExecutionsAsync(
ExecutionFilter(acctCode="PAPER_ACCOUNT_A")
)
for fill in requested:
exec_id = execution_key(fill)
if exec_id not in seen:
seen.add(exec_id)
print("requested execution", exec_id)
finally:
ib.orderStatusEvent -= on_status
ib.execDetailsEvent -= on_fill
ib.commissionReportEvent -= on_commission
ib.disconnect()
if __name__ == "__main__":
asyncio.run(main())
Printing is only an inspection aid. A production system must durably write before acknowledging an execution to downstream accounting, releasing reserved quantity, or reporting a settled cost.
Production ledger with durable boundaries
The owner below accepts a synchronous persist function whose successful return means the record is durable—for example, a committed database transaction or an fsync-backed local write-ahead log. A merely queued in-process message is not enough. The owner updates its idempotency maps only after that durable return, poisons itself if the writer fails, rejects account-scope drift, retains corrections as distinct executions, and refuses a commission whose execution is not durable.
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import datetime, timezone
from math import isfinite
from ib_async import CommissionReport, ExecutionFilter, Fill, IB, Trade
class ExecutionLedger:
def __init__(
self,
authorized_accounts: frozenset[str],
connection_epoch: str,
persist: Callable[[dict[str, object]], None],
) -> None:
if not authorized_accounts:
raise ValueError("authorized account scope cannot be empty")
if not connection_epoch:
raise ValueError("connection epoch cannot be empty")
self.authorized_accounts = authorized_accounts
self.connection_epoch = connection_epoch
self.persist = persist
self.failed = False
self.reconcile_required = False
self._last_status: dict[
tuple[int, int, int, str, str], tuple[object, ...]
] = {}
self._executions: dict[str, tuple[object, ...]] = {}
self._execution_context: dict[str, dict[str, object]] = {}
self._execution_observations: set[tuple[str, str, str]] = set()
self._commissions: dict[str, tuple[object, ...]] = {}
self._commission_observations: set[tuple[str, str, str]] = set()
self._handlers: tuple[Callable[..., None], ...] | None = None
def _ensure_healthy(self) -> None:
if self.failed:
raise RuntimeError("ledger is poisoned; reconcile before continuing")
if self.reconcile_required:
raise RuntimeError("connection recycle and reconciliation required")
def _write(self, record: dict[str, object]) -> None:
self._ensure_healthy()
try:
self.persist(record)
except Exception:
self.failed = True
raise
@staticmethod
def _aware(value: datetime, field: str) -> str:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{field} must be timezone-aware")
return value.isoformat()
def record_status(self, trade: Trade, observed_at: datetime) -> bool:
self._ensure_healthy()
status = trade.orderStatus
account = trade.order.account
if not account:
raise ValueError("status account identity is missing")
if account not in self.authorized_accounts:
raise ValueError("status account is outside authorized scope")
if status.permId <= 0 and not trade.order.orderRef:
raise ValueError("status lacks a durable order identity")
identity = (
status.permId,
status.clientId,
status.orderId,
account,
trade.order.orderRef,
)
payload = (
status.status,
status.filled,
status.remaining,
status.avgFillPrice,
status.lastFillPrice,
status.parentId,
status.whyHeld,
status.mktCapPrice,
)
if self._last_status.get(identity) == payload:
return False
record: dict[str, object] = {
"kind": "order_status",
"source": "live_callback",
"connection_epoch": self.connection_epoch,
"observed_at": self._aware(observed_at, "observed_at"),
"account": account,
"order_ref": trade.order.orderRef,
"con_id": trade.contract.conId,
"perm_id": status.permId,
"client_id": status.clientId,
"order_id": status.orderId,
"status": status.status,
"filled": status.filled,
"remaining": status.remaining,
"avg_fill_price": status.avgFillPrice,
"last_fill_price": status.lastFillPrice,
"parent_id": status.parentId,
"why_held": status.whyHeld,
"market_cap_price": status.mktCapPrice,
}
self._write(record)
self._last_status[identity] = payload
return True
def record_fill(
self,
trade: Trade | None,
fill: Fill,
observed_at: datetime | None = None,
source: str = "live_callback",
reconciliation_run_id: str = "",
request_filter: dict[str, object] | None = None,
) -> bool:
self._ensure_healthy()
execution = fill.execution
exec_id = execution.execId
if not exec_id:
raise ValueError("execution is missing execId")
if not source:
raise ValueError("execution source is missing")
if source == "reqExecutions" and not reconciliation_run_id:
raise ValueError("requested execution requires reconciliation run ID")
filter_snapshot = dict(request_filter or {})
if execution.acctNumber not in self.authorized_accounts:
raise ValueError("execution account is outside authorized scope")
if execution.side not in {"BOT", "SLD"}:
raise ValueError("execution side must be BOT or SLD")
if not isfinite(float(execution.shares)) or execution.shares <= 0:
raise ValueError("execution shares must be positive and finite")
if not isfinite(float(execution.price)):
raise ValueError("execution price must be finite")
if fill.contract.conId <= 0:
raise ValueError("execution contract must have a qualified conId")
if trade is not None:
if trade.contract.conId != fill.contract.conId:
raise ValueError("live Trade contract does not match execution")
trade_perm_id = (
trade.order.permId
if trade.order.permId > 0
else trade.orderStatus.permId
)
if trade_perm_id > 0 or execution.permId > 0:
if trade_perm_id != execution.permId:
raise ValueError("live Trade permId does not match execution")
else:
if (
not trade.order.orderRef
or not execution.orderRef
or trade.order.orderRef != execution.orderRef
):
raise ValueError("live Trade lacks matching durable correlation")
if trade.order.clientId != execution.clientId:
raise ValueError("live Trade clientId does not match execution")
if trade.order.orderId != execution.orderId:
raise ValueError("live Trade orderId does not match execution")
execution_time = self._aware(execution.time, "execution time")
fill_time = self._aware(fill.time, "fill time")
observed_time = self._aware(
observed_at or datetime.now(timezone.utc), "observed_at"
)
observation_key = (exec_id, source, reconciliation_run_id)
payload = (
fill.contract.conId,
execution_time,
execution.acctNumber,
execution.exchange,
execution.side,
execution.shares,
execution.price,
execution.permId,
execution.clientId,
execution.orderId,
execution.liquidation,
execution.cumQty,
execution.avgPrice,
execution.orderRef,
execution.evRule,
execution.evMultiplier,
execution.modelCode,
execution.lastLiquidity,
execution.pendingPriceRevision,
)
existing = self._executions.get(exec_id)
if existing is not None:
if existing != payload:
raise RuntimeError(f"conflicting execution for execId {exec_id}")
if observation_key not in self._execution_observations:
self._write(
{
"kind": "execution_observation",
"exec_id": exec_id,
"source": source,
"connection_epoch": self.connection_epoch,
"reconciliation_run_id": reconciliation_run_id,
"request_filter": filter_snapshot,
"observed_at": observed_time,
}
)
self._execution_observations.add(observation_key)
return False
record: dict[str, object] = {
"kind": "execution",
"source": source,
"connection_epoch": self.connection_epoch,
"reconciliation_run_id": reconciliation_run_id,
"request_filter": filter_snapshot,
"exec_id": exec_id,
"execution_time": execution_time,
"fill_time": fill_time,
"observed_at": observed_time,
"account": execution.acctNumber,
"con_id": fill.contract.conId,
"symbol": fill.contract.symbol,
"security_type": fill.contract.secType,
"currency": fill.contract.currency,
"contract_exchange": fill.contract.exchange,
"primary_exchange": fill.contract.primaryExchange,
"local_symbol": fill.contract.localSymbol,
"trading_class": fill.contract.tradingClass,
"multiplier": fill.contract.multiplier,
"expiry_or_contract_month": fill.contract.lastTradeDateOrContractMonth,
"exchange": execution.exchange,
"side": execution.side,
"shares": execution.shares,
"price": execution.price,
"perm_id": execution.permId,
"client_id": execution.clientId,
"order_id": execution.orderId,
"liquidation": execution.liquidation,
"cumulative_quantity": execution.cumQty,
"average_price": execution.avgPrice,
"order_ref": execution.orderRef,
"economic_value_rule": execution.evRule,
"economic_value_multiplier": execution.evMultiplier,
"model_code": execution.modelCode,
"last_liquidity": execution.lastLiquidity,
"pending_price_revision": execution.pendingPriceRevision,
"matched_live_trade": trade is not None,
}
self._write(record)
self._executions[exec_id] = payload
self._execution_context[exec_id] = {
"source": source,
"reconciliation_run_id": reconciliation_run_id,
"request_filter": filter_snapshot,
}
self._execution_observations.add(observation_key)
return True
def record_commission(
self,
trade: Trade | None,
fill: Fill,
report: CommissionReport,
observed_at: datetime | None = None,
source: str = "live_callback",
reconciliation_run_id: str = "",
request_filter: dict[str, object] | None = None,
) -> bool:
self._ensure_healthy()
exec_id = report.execId
if exec_id != fill.execution.execId:
raise ValueError("commission execId does not match fill")
if exec_id not in self._executions:
raise RuntimeError("execution is not durable")
if not source:
raise ValueError("commission source is missing")
filter_snapshot = dict(request_filter or {})
if not report.currency:
raise ValueError("commission currency is missing")
numeric = (
report.commission,
report.realizedPNL,
report.yield_,
)
if not all(isfinite(float(value)) for value in numeric):
raise ValueError("commission fields must be finite")
payload: tuple[object, ...] = (
report.commission,
report.currency,
report.realizedPNL,
report.yield_,
report.yieldRedemptionDate,
)
observed_time = self._aware(
observed_at or datetime.now(timezone.utc), "observed_at"
)
observation_key = (exec_id, source, reconciliation_run_id)
existing = self._commissions.get(exec_id)
if existing is not None:
if existing != payload:
raise RuntimeError(f"conflicting commission for execId {exec_id}")
if observation_key not in self._commission_observations:
self._write(
{
"kind": "commission_observation",
"exec_id": exec_id,
"source": source,
"connection_epoch": self.connection_epoch,
"reconciliation_run_id": reconciliation_run_id,
"request_filter": filter_snapshot,
"observed_at": observed_time,
}
)
self._commission_observations.add(observation_key)
return False
record: dict[str, object] = {
"kind": "commission",
"exec_id": exec_id,
"connection_epoch": self.connection_epoch,
"source": source,
"reconciliation_run_id": reconciliation_run_id,
"request_filter": filter_snapshot,
"execution_origin": dict(self._execution_context[exec_id]),
"observed_at": observed_time,
"commission": report.commission,
"currency": report.currency,
"realized_pnl": report.realizedPNL,
"yield": report.yield_,
"yield_redemption_date": report.yieldRedemptionDate,
"matched_live_trade": trade is not None,
}
self._write(record)
self._commissions[exec_id] = payload
self._commission_observations.add(observation_key)
return True
def attach(self, ib: IB) -> None:
self._ensure_healthy()
if self._handlers is not None:
raise RuntimeError("ledger is already attached")
def on_status(trade: Trade) -> None:
self.record_status(trade, datetime.now(timezone.utc))
def on_fill(trade: Trade, fill: Fill) -> None:
self.record_fill(trade, fill, datetime.now(timezone.utc))
def on_commission(
trade: Trade, fill: Fill, report: CommissionReport
) -> None:
self.record_commission(
trade, fill, report, datetime.now(timezone.utc)
)
ib.orderStatusEvent += on_status
ib.execDetailsEvent += on_fill
ib.commissionReportEvent += on_commission
self._handlers = (on_status, on_fill, on_commission)
def detach(self, ib: IB) -> None:
if self._handlers is None:
return
on_status, on_fill, on_commission = self._handlers
ib.orderStatusEvent -= on_status
ib.execDetailsEvent -= on_fill
ib.commissionReportEvent -= on_commission
self._handlers = None
def capture_cached_commissions(
self,
ib: IB,
reconciliation_run_id: str = "",
request_filter: dict[str, object] | None = None,
) -> frozenset[str]:
self._ensure_healthy()
missing: set[str] = set()
for fill in ib.fills():
exec_id = fill.execution.execId
if exec_id not in self._executions:
continue
report = fill.commissionReport
if report.execId and report.currency:
self.record_commission(
None,
fill,
report,
source="reqExecutions_cache"
if reconciliation_run_id
else "session_cache",
reconciliation_run_id=reconciliation_run_id,
request_filter=request_filter,
)
elif exec_id not in self._commissions:
missing.add(exec_id)
return frozenset(missing)
async def backfill(
self,
ib: IB,
account: str,
reconciliation_run_id: str,
timeout: float = 15,
) -> frozenset[str]:
self._ensure_healthy()
if account not in self.authorized_accounts:
raise ValueError("backfill account is outside authorized scope")
if not reconciliation_run_id:
raise ValueError("reconciliation run ID cannot be empty")
if not isfinite(timeout) or timeout <= 0:
raise ValueError("backfill timeout must be positive and finite")
exec_filter = ExecutionFilter(acctCode=account)
filter_record: dict[str, object] = {
"client_id": exec_filter.clientId,
"account": exec_filter.acctCode,
"time": exec_filter.time,
"symbol": exec_filter.symbol,
"security_type": exec_filter.secType,
"exchange": exec_filter.exchange,
"side": exec_filter.side,
}
pending = ib.reqExecutionsAsync(exec_filter)
try:
fills = await asyncio.wait_for(
asyncio.shield(pending), timeout=timeout
)
except asyncio.CancelledError:
self.reconcile_required = True
raise
except Exception:
self.reconcile_required = True
raise
for fill in fills:
self.record_fill(
None,
fill,
source="reqExecutions",
reconciliation_run_id=reconciliation_run_id,
request_filter=filter_record,
)
return self.capture_cached_commissions(
ib,
reconciliation_run_id=reconciliation_run_id,
request_filter=filter_record,
)
Attach before starting or reconciling order activity, and always detach in a finally block. backfill returns exact execution IDs whose commissions are still missing. Re-run capture_cached_commissions under a bounded application-owned reconciliation deadline, passing that run ID and filter again, because the pinned wrapper can update an unmatched historical fill's commission object without emitting the live commission event. Commission records keep their own observation source/run/filter separately from the execution's first durable origin. If IDs remain missing, keep their financial state incomplete and retry/reconcile later; do not invent zero cost.
reqExecutionsAsync has no public per-request cancellation method in this pinned API. The owner shields the request so its own deadline/caller cancellation cannot silently cancel the library future, then sets reconcile_required. Recycle that connection epoch to clear unresolved request ownership and run an overlapping reconciliation before reusing the owner. If failed becomes true, stop downstream acknowledgements and order actions, retain the connection epoch and unresolved identifiers, and run reconciliation with a healthy ledger. Do not silently continue with only the library's in-memory state.
Partial fills and status reconciliation
One order can have several executions. Store every exact execId, then compare the sum of the currently effective execution versions with OrderStatus.filled. A mismatch is a reconciliation signal, not proof that either surface is wrong: callback timing, allocation visibility, correction processing, and busts can temporarily or permanently change what the application has observed.
Do not derive execution price from avgFillPrice, and do not apportion a total commission across fills. Execution.price and avgPrice exclude commissions; the report keyed to each execId supplies commission data. The official field description does not define a positive-only constraint, so the example validates finiteness and currency rather than inventing one.
Pinned ib_async normalizes protocol sentinel values for yield_ and realizedPNL to 0.0 before emitting the commission event. At this high-level event surface, zero can therefore mean either a real zero or a normalized unavailable value. Preserve the value and its source/version; do not infer realized profitability or completeness from zero alone.
Allocation-level reporting
The execution's acctNumber is the account to which the execution was allocated. Persist and authorize that field independently of the order's submitted account/group fields. IBKR states that an advisor receives execution details and commissions for the allocation order itself; use reqExecutions for a specific subaccount to retrieve allocation-level executions and commissions. Never manufacture child fills by dividing a master execution, and never attach a fill to a subaccount solely from quantity math. See IBKR Campus: Place Order.
For many accounts, issue scoped requests under an application-owned pacing policy and merge all results by exact execId plus authorized account. Record which account filter and reconciliation run produced each observation.
Memory, request, and durable-history boundaries
IB.fills() and IB.executions() are explicitly the fills/executions known to the current library session. They are useful projections, not recovery storage. Wrapper reset, process exit, or a new IB instance loses that cache.
reqExecutionsAsync completes at execDetailsEnd and returns matching fills. Current IBKR Campus sections describe the default as executions since midnight for the account/current trading day; TWS can expose up to seven days when its Trade Log setting is adjusted, while IB Gateway remains limited to the current trading day since midnight. A separate callback description on the same current page says “last 24 hours.” Because that conflicts with the more specific request and execution-history sections, this guide uses the current-day/current-trading-day boundary and treats the 24-hour sentence as non-authoritative for retention planning. See Execution Details, EClient.reqExecutions, and EWrapper.execDetails.
Pinned ExecutionFilter fields are clientId, acctCode, time, symbol, secType, exchange, and side; default zero/empty fields leave that dimension unscoped. Use the narrowest authorized account filter and a conservative overlap time accepted by the connected server. A filter narrows what the request asks for—it does not expand the connected client's visibility or extend server retention.
Therefore:
- persist live executions before business acknowledgement;
- request an overlapping current-day window after reconnect and deduplicate it;
- do not assume an execution remains retrievable after midnight, restart, or a Gateway/TWS configuration change;
- reconcile longer-lived accounting against durable application records and appropriate statement/reporting systems;
- keep completed-order history separate: it describes order snapshots, not a substitute fill ledger.
Failure handling checklist
- Duplicate status: suppress only a consecutive identical payload or retain it as an append-only observation; never submit another order because a status repeated.
- Duplicate execution: an identical exact
execIdis idempotent; a different payload under the same exact ID is a conflict requiring investigation. - Correction: retain the new exact
execIdand full payload. A common prefix is not proof because combo-leg IDs can have similar structure; version the effective accounting view only after authoritative reconciliation identifies the correction. - Late commission: merge only after the execution is durable. For requested history, re-capture the mutable session fill cache under a bounded deadline; a missing report leaves cost incomplete, not zero.
- Unknown commission: high-level
ib_asyncignores reports whoseexecIdis not in its fill cache. Recover by requesting executions/reconciling; do not invent a fill. - Writer failure: poison the consumer, stop acknowledgements/actions, preserve the unresolved callback context, and reconcile before resuming.
- Request timeout/cancellation:
reqExecutionsAsynchas no public request-cancel pair; recycle the connection epoch, subscribe first on the replacement, and request an overlapping window. - Disconnect: detach handlers only after callback ownership has been durably handed off; on the next connection, subscribe first and request an overlapping execution window.