Skip to content

Port the Interactive Brokers adapter onto ibapi 5.0.0 - #5041

Merged
cjdsellers merged 9 commits into
developfrom
ib-adapter-port
Oct 8, 2026
Merged

cjdsellers merged 9 commits into
developfrom
ib-adapter-port

Conversation

@faysou

@faysou faysou commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR rewrites the Rust Interactive Brokers adapter on the published ibapi 5.0.0 release
(from 3.3.0) and fixes the correctness gaps an audit and paper-account runs found in it:
venue rejections that never reached the strategy, reconciliation against the wrong account code,
no resubscription after a socket loss, IB sentinel prices emitted as quotes, and fills replayed
after a restart. The execution engine, live reconciliation, risk engine, and portfolio fixes this
work needed are now on develop, so outside the adapter the PR only adds model identifiers and a
strategy fix. Futures and futures-option identifiers are unchanged: the adapter still emits the IB
local symbol, such as YMM6.XCBT.

Area What changes
Interactive Brokers adapter Rewrite on ibapi 5.0.0: restructured modules and the execution, data, and connection fixes.
Model identifiers Generic spread IDs and futures symbol parsing.
Strategy Trigger modifies for trailing stop orders.
Docs, examples, and Python surface Rewritten integration guide, runnable examples and notebooks, updated stubs.
flowchart LR
    subgraph IB["crates/adapters/interactive_brokers"]
        EX[Execution client]
        DC[Data client]
        SC[Shared connection]
    end
    subgraph Core["Core crates"]
        EE["ExecutionEngine
        (nautilus-execution)"]
        MI["Model identifiers"]
    end
    IBAPI["ibapi 5.0.0 (crates.io)"]
    EX -- "OrderWithFills, external DUP- orders" --> EE
    EX -- spread IDs --> MI
    SC --> IBAPI
    EX --> SC
    DC --> SC
Loading

The PR is one commit on top of develop.

Changes outside the adapter

Trailing stop trigger modifies

Strategy::modify_order and modify_orders rejected a trigger price for trailing stop orders
(orders do not have a STOP trigger price), although the risk engine already projects it and the
order model applies it on OrderUpdated. Both now accept a trigger for TRAILING_STOP_MARKET and
TRAILING_STOP_LIMIT; other order types without a trigger are still refused.

Model identifiers

  • new_generic_spread_id and parse_generic_spread_id_legs join nautilus_model::identifiers
    with Python bindings, so IB combo contracts round-trip through generic spread IDs from both
    languages. Leg ratios parse as digits only, because parse::<i64> accepts a leading sign and
    would flip the sign the surrounding parentheses encode.
  • IB futures symbol parsing moves to nautilus_model::identifiers::futures. It has one consumer
    today; the Databento alignment that would share it is a follow-up. Keeping it in the adapter
    crate changes no behavior if a reviewer prefers that.

Interactive Brokers adapter

Module layout

The previous crate mounted core_orders.rs, core_updates.rs, and core_tracking.rs into one
impl block through #[path], threaded about twenty separately locked maps as parameters, and
repeated its stream loop eight times. The rewrite splits by responsibility:

Before After
execution/core_orders.rs execution/commands.rs: submit, modify, cancel, and their denials
execution/core_updates.rs, core_tracking.rs execution/updates.rs, order_state.rs, incarnations.rs
Parallel Arc<Mutex<map>> fields OrderTracker in execution/core.rs: per-order state behind one lock
execution/core_tests.rs execution/core/tests.rs
data/core_streams.rs data/streams.rs: one StreamContext and run_stream driver for every stream
common/parse.rs common/symbology.rs (Symbology value) and common/spreads.rs
common/connection.rs, common/types.rs, error.rs Removed: superseded by ibapi reconnect or unused
flowchart TD
    subgraph execution
        core[core: client, lifecycle, OrderTracker]
        commands[commands]
        updates[updates]
        order_state[order_state]
        incarnations[incarnations]
        account[account]
        espreads[spreads: combo legs]
        transform[transform: policy and tags]
    end
    subgraph data
        dcore[core: subscriptions]
        streams[streams: run_stream]
        dcache[cache: quotes and sentinels]
        convert[convert and parse]
    end
    subgraph common
        shared[shared_client]
        symbology[symbology]
        cspreads[spreads]
    end
    commands --> core
    updates --> core
    updates --> incarnations
    core --> order_state
    core --> account
    updates --> espreads
    commands --> transform
    dcore --> streams
    streams --> dcache
    streams --> convert
    core --> shared
    dcore --> shared
    core --> symbology
    dcore --> symbology
    symbology --> cspreads
Loading

Execution

  • Acceptance follows venue acknowledgment (order status or openOrder), not local send success.
    PreSubmitted maps to Accepted, so orders held for goodAfterTime reconcile as open.
  • Rejections reach the strategy. Before acceptance, order rejection codes (200 to 399) and the
    Inactive status emit OrderRejected. Other error codes, which IB also sends for orders it keeps
    working (10349 sets the time in force from an order preset), trigger a venue query, and the order
    is rejected only when IB lists it neither as open nor as completed. For an accepted order, a notice
    or Inactive emits a modify rejection when a modify is pending and leaves the order working; a
    notice also emits a cancel rejection when a cancel is pending (161), and otherwise schedules a
    venue query. A venue query that fails, times out, or ends early leaves the order working. A
    pending modify is resolved first, so Inactive after a refused modify keeps even a partially
    filled order working; otherwise Inactive after a partial fill emits a cancel, since the order
    state machine forbids rejecting a partially filled order. Order-bound notices come from
    OrderBound in ibapi 4.2.0.
  • Callbacks correlate by account, native client and order ID, permanent ID, and client order
    reference. A binding notice binds a route only in the subscription that owns the identity, and a
    fill never rewrites a route already used for cancellation. A route with no binding, owned by
    another API client, or mapping to several permanent IDs stays unresolved and is reported, never
    resolved by an account-wide cancel.
  • Distinct permanent IDs sharing one client order reference stay separate physical orders. The
    engine models one order per client order ID, so each additional order is reported under its own
    ID, DUP-<account length>:<account ID>:PERM-<permanent ID>, and the engine creates it as an
    external order under the instrument's external order claim, otherwise EXTERNAL. Late statuses
    cannot reopen a filled duplicate, and cancelling the original also cancels the group's working
    members, including after the original closes ([Interactive Brokers] Detect duplicate live orders after modify during connectivity loss #4564).
  • An execution for an order the adapter does not track, a duplicate included, is held while the
    adapter queries the order details and executions (order_details_with_fills). The adapter then
    emits ExecutionReport::OrderWithFills, so the engine creates the order at its full quantity and
    applies the real fills with their trade IDs and commissions. One query runs per order, bounded by
    request_timeout and at most 3 seconds, which stays below the live node's default
    position_check_threshold_ms of 5 seconds. A failed, empty, or timed-out query logs a warning and
    forwards the held fills as plain reports. A duplicate status that shows fills the engine has not
    received resolves them the same way, so the engine never infers a fill that a real execution
    later repeats.
flowchart TD
    X[execDetails] --> T{Order tracked?}
    T -- yes --> F[OrderFilled]
    T -- no --> H["Hold fill
    (one query per order)"]
    H --> Q["Order details and executions
    min(request_timeout, 3 s)"]
    Q -- found --> W[OrderWithFills]
    W --> R[Held fills the query missed]
    Q -- failed, empty, or timed out --> P[Held fills as plain reports]
Loading
  • Only unsolicited execution details are live fills. reqExecutions replies, which ibapi also
    broadcasts on the update stream, are ignored there, so reconciliation queries no longer replay
    the day's fills.
  • Combo parent fills come from the authoritative combo execution, with zero commission since IB
    reports commissions on the legs only. Each leg execution resolves from its own contract and
    becomes an OrderFilled event on the leg instrument under the spread order's strategy, which the
    engine applies to the leg position without an order of its own. Fill deduplication survives
    terminal eviction long enough to absorb replays.
  • IB reports the combo execution before the leg executions and sends no commissionReport for it,
    so combo executions no longer wait for one. The spread fill is held until the sent leg
    quantities cover it, then goes out as the last event of its group, so code reacting to it sees
    the leg positions and cash already applied. A spread fill whose legs do not arrive within 5
    seconds is sent with a warning. Previously the spread fill waited for the 5 second commission
    timeout on every combo fill.
  • On connect, the execution client loads the legs of every open or completed combo order by
    contract ID, builds the spread, and publishes both, so startup reconciliation restores combo
    orders the node did not load. IB serves no contract details for a BAG. Startup fill reports
    resolve the combo-level execution's spread through its order's contract, and leave out the leg
    executions, which share the combo order's IDs; leg positions reconcile from IB's position reports.
    Mass status sets a missing average price on a filled order report from its fills.
  • A modify starts from IB's current copy of the order and changes only quantity, price, or trigger
    price, so goodAfterTime, the OCA group, and outsideRth survive, including on restored orders.
    A trailing order's trigger is IB's trailing stop price, both in openOrder updates and in the
    pending-modify acknowledgement; a modify that leaves it unchanged ignores the stop IB moves with
    the market. IB accepts a new trailing stop price only with a new trailing amount (10067), so a
    trigger modify of a trailing order carries params={"trailing_offset": <offset>} in the order's
    offset units; without it the modify is rejected before it is sent.
  • Trailing orders accept BASIS_POINTS offsets (sent as IB's trailing percent) as well as PRICE
    offsets, for single orders as for order lists; other offset types are denied.
  • A modify only matches open orders of this client and account.
  • Cancels use the tracked TWS order ID, so the originating client cancels without a cross-client
    lookup; a duplicate member, which shares its raw order ID, uses its permanent ID. A cancel
    resolved from the venue emits OrderCanceled under the target order's own strategy. A strategy
    cancel, which already published OrderPendingCancel, only updates the adapter's tracking;
    cancel-all and group cancels, which the strategy does not mark, still emit it.
  • Cancel-all with only a strategy cancels per order. Instrument-wide requests keep account,
    instrument, and side filters across cached orders, tracked orders, and duplicate groups; an empty
    side-filtered selection cancels nothing, and duplicate members of unknown side are skipped.
  • Every order in a list is resolved and transformed before the first reaches IB; a list that cannot
    be prepared is denied as a whole (ORDER_LIST_INVALID). A partially failed order list gives every
    order a definite state; sent predecessors are cancelled at the venue. The order ID counter stays inside the client's partition when seeded from open
    orders. Pre-submit denials carry coded reasons.
  • The raw IB account code is derived once from the configured account_id and used for orders,
    modifies, cancels, account, position, and PnL subscriptions, and execution filters. A client
    named IB_LIVE sends U1234567, not IB_LIVE-U1234567, so startup fill reports no longer fail
    with TWS error 321 ([Interactive Brokers] generate_fill_reports sends the prefixed AccountId as the execution filter's account code: rejects with error 321 #5007).
  • The account path reads one reqAccountUpdates snapshot after the summary and stores every key in
    AccountState.info, so values such as PostExpirationExcess reach Python ([Interactive Brokers] Account values outside the fixed reqAccountSummary tag list are unreachable #5032). Balances and
    margins use each row's own currency; ratio rows such as Cushion are skipped without a warning
    ([Interactive Brokers] Cannot build an Account when the account summary carries no Currency tag #4987).
  • The account summary subscription stays open for the life of the connection. IB sends no further
    End after the first snapshot, so a refresh completes once rows stop arriving for one second,
    and the latest value per tag and currency persists across cycles. query_account and the stream
    send ExecutionEvent::Account through an execution event sender captured on the core thread,
    not the thread-local message bus from a runtime worker ([Interactive Brokers] Execution client never refreshes AccountState after connect #5193, [Interactive Brokers] query_account sends AccountState via the thread-local msgbus from a tokio worker (suspected) #5194).
  • A market depth Reset notice, delivered as data by ibapi 5.0.0, clears the instrument book and
    the L2 order ID map. ibapi 5.0.0 is inside the 3 day dependency cooldown, so it is allowed in
    Cargo.toml and the cargo-vet exemption moves to 5.0.0 until an audit is recorded.
  • Report collection for single orders, bulk orders, fills, and positions runs on a runtime worker
    through the four ExecutionReportTask hooks. An owned report client in reports.rs holds the IB
    client, the instrument provider, the account codes, and the request timeout, and reads no cache.
    The inline report methods call the same code, so both paths share filters, report identity, and
    errors. While the client is disconnected the hooks return None and the inline path reports the
    same not connected error. generate_mass_status keeps the inline path (Standardize execution report generation hooks across adapters #5216).
  • Own-fill position deltas are recorded when the execution arrives, so the position stream cannot
    race a fill into a spurious external position change. Position tracking keeps streaming past
    PositionEnd.

Reconciliation and restart recovery

  • A restarted node tracks external orders a previous session left working, resolved from their
    PERM- venue order IDs, so their updates, fills, and cancels reach the strategy. Reports carry
    the bracket parent (parent_order_id, OTO), OCA group (OCO or OUO by OCA type), display
    quantity, completion time, and GTD expire_time. The first openOrder after a restart sends no
    OrderUpdated unless a value changed.
  • Mass status records its bounded report window through set_report_window. Unresolvable orders
    and positions and missing commission reports fail the request instead of dropping records. Live
    execution still emits a zero-commission fill with a warning after five seconds without a
    commission report, because a venue fill cannot stay withheld.
  • The synthetic position-derived order reports are removed. A position without order history
    returns a position report, and the engine's inferred-fill reconciliation resolves it.
  • Market-type orders report no limit price; IB's zero limit price had been taken as a fill price.
    Completed orders that an OCA group reduced to zero without a fill are skipped.
  • On connect, the client publishes the instrument of every open account position the cache lacks,
    so startup no longer aborts with instrument missing from cache. Cached orders whose instrument
    IB no longer resolves log a warning instead of failing connect (Interactive Brokers: a replayed execution is dropped when the instrument provider has not yet loaded its instrument #4932).
  • IB's position average cost includes commissions. When the reported fills of an instrument net to
    exactly its position quantity, the position report carries the entry price of those fills, so a
    position opened with a commission reconciles across a restart; otherwise it keeps IB's average
    cost.

Market data

Connection

  • The ibapi transport reconnects; the adapter resubscribes through run_stream and reports
    disconnection when the transport shuts down or a required stream cannot be restored.
  • The shared-client registry holds an in-flight entry per key, so concurrent acquisitions converge
    on one connection, and a cancelled waiter leaves no reserved reference.
  • Both clients track their tasks with TaskHandles and cancellation instead of best-effort
    try_lock cleanup. Dockerized gateway readiness reflects the current session's login.
  • Gateway and TWS versions below protocol 213 fail before StartApi with an explicit error, and
    connection timeouts are bounded ([Interactive Brokers] v2.0.0rc2 historical client connect leaves IB Gateway 10.41 API unresponsive (accept-queue wedge requiring Gateway restart) #4796).

Configuration and Python surface

  • load_contracts is a typed ConfiguredContract list, including the per-contract chain keys, and
    filter_sec_types parses into IbSecurityType with errors on unknown values. A conId alone is
    a valid contract.
  • The gateway password is redacted in Debug and Serialize.
  • Trigger method, liquidity, and OCA type conversions are strict. Time-in-force and trigger codes
    that ibapi 4.2.0 reports as Unknown (and GTX) map to the defaults with a warning, matching
    how 4.1.0 decoded them.
  • ibapi 4.2.0 bounds async subscriptions with a crate-private decoder trait, so run_stream
    iterates over a small private trait implemented per streamed item type. The dependency carries
    no git, branch, rev, or path key.
  • The Python package exports the full __all__, and __init__.pyi adds all_last_trades,
    subscription_idle_timeout_secs, and InteractiveBrokersSubscriptionIdle.
  • Futures identifiers keep the venue local symbol. The year-digit setting is pinned to
    pass-through without a configuration field, because switching to two-digit years would rewrite
    identifiers already stored in catalogs and caches.

Related issues/PRs

Issue Behavior covered
#4470 (reply) Strategy-only cancellation stays per-order by default; instrument-wide requests retain account, instrument, and side scope. Other adapters remain follow-ups.
#4564 (reply) Distinct permanent IDs sharing one client reference stay separate physical orders, each additional one an external order under its own client order ID; real fills are preserved and cancelling the original cancels uniquely bound working members.
#4796 (reply) Protocol 213 is the documented minimum; unsupported Gateway or TWS versions fail before StartApi with an explicit error. Connection timeouts stay bounded.
#4932 (reply) The execution instrument provider seeds after cache restoration and qualifies instruments of cached orders that are missing; an instrument IB cannot resolve is skipped with a warning.
#4946 (reply) Resolved on develop; the adapter relies on the risk engine's account routing for broker-routed instruments.
#4947 (reply) Trade, tick-by-tick quote, realtime-bar, and depth streams that end unexpectedly log a warning before resubscribing.
#4948 (reply) Expected unrepresentable fractional market-data quantities log at DEBUG; other parse failures keep their warnings.
#4949 (reply) Optional subscription-idle custom data; market data rearms the timer and farm notices do not mask inactivity.
#4960 (reply) all_last_trades defaults to AllLast; False selects Last.
#4970 (reply) Callbacks correlate by account, native client and order identity, permanent ID, and reference; fills do not rewrite cancellation routes.
#4973 (reply) V1-only serializer issue; no v2 code change. The reply records the distinction.
#4987 (reply) Account summaries build balances and margins from each row's own currency; ratio rows such as Cushion no longer log currency warnings.
#5007 (reply) Fill report requests use the raw IB account code, so startup reconciliation returns historical fills instead of TWS error 321.
#5032 (reply) One reqAccountUpdates snapshot merges into AccountState.info keyed by IB name, so values such as PostExpirationExcess reach Python.
#5058 Cancel-all honors order_side in every selection path; an empty side-filtered selection cancels nothing, and duplicate members of unknown side are skipped.
#5057 IB PreSubmitted maps to Accepted, so orders held for goodAfterTime reconcile as open. External orders restored at startup join the adapter's order tracking, so their later IB updates reach the strategy and cancels route. An openOrder sends OrderUpdated only when quantity, price, or trigger price change. Restored orders keep their bracket and OCA links, display quantity, and completion time, and a modify keeps IB-only attributes such as goodAfterTime, the OCA group, and outsideRth. Filled orders from the previous session reconcile from their real fills, and GTD orders carry their goodTillDate as expire_time. Startup acceptance ordering is on develop.
#5135 build_options_chain loads the options of stock and index underlyings: the chain request names no exchange for them and keeps the chains of the requested exchange, and loaded options are looked up on that exchange. A continuous future loads the futures options of its futures whatever their expiry.
#5136 Option and futures-option expiries beyond IB's listed trading sessions take the last listed session's closing time in the contract time zone, so NVDA 261030C00230000 expires at 16:00 New York.
#5142 Historical bar requests survive IB's 2188 advisory: ibapi 4.2.0 routes it as a notice and delivers the delayed bars. The v1 adapter's request error handling is not part of v2.
#5060 reqExecutions replies that ibapi also broadcasts on the order update stream are ignored there, so startup and periodic execution queries no longer replay the day's fills as live fills. The adapter holds a fill for an untracked order until it reports the order with its real fills, so the fill does not create a reduce-only order. The position-check grace period is on develop.
#5193 (reply) The execution client keeps the account summary subscription open and emits an AccountState each time IB pushes changed values. IB sends no further End after the first snapshot, so a refresh completes after one second without new rows, and the latest value per tag and currency persists across cycles.
#5194 (reply) query_account and the account stream send ExecutionEvent::Account through an execution event sender captured on the core thread, not the thread-local message bus from a runtime worker.
#5216 (comment) The four granular report hooks collect on a worker with the same code as the inline methods. Hook output is not yet compared with inline output on the same responses, and the single-order and fill hooks have no live run.
#5218 (comment) IB reports a combo execution before its legs and sends no commission report for it. The spread fill no longer waits for one; it is held until the sent leg quantities cover it and then goes out last, so the spread fill follows its complete component group as proposed there. A spread fill without legs is sent after 5 seconds with a warning.

The issues stay open; this table records implementation coverage.

Type of change

  • Bug fix (non-breaking).
  • New feature (non-breaking).
  • Improvement (non-breaking).
  • Breaking change (impacts existing behavior).
  • Documentation update.

Breaking change details

  • ibapi moves from =3.3.0 to =4.2.0. Nothing in the repository outside the adapter uses it.
  • Adapter Rust API removed or replaced: ConnectionManager and ConnectionWatchdog, the error.rs
    taxonomy (InteractiveBrokersError, InteractiveBrokersErrorKind, ErrorCategory, and the
    classification functions), the ib_contract_to_instrument_id_* and instrument_id_to_ib_contract
    functions (now Symbology), and the provider's load_async, load_ids_async, and
    load_contract_with_return_async. parse_market_depth_operation returns Option<BookAction>.
  • Python: ErrorCategory and InteractiveBrokersErrorKind are no longer exported.
  • Configuration: load_contracts and filter_sec_types are typed and reject unknown keys or
    security types that were previously ignored.
  • Instrument identifiers are unchanged, so existing catalogs and caches keep resolving.

Deferred follow-ups

  • Two-digit futures contract years, the futures_year_digits configuration surface, and the
    Databento symbology alignment that depends on it.
  • The structured data-engine failure marker for partial request failures, with the response,
    streaming, and historical-execution modules that read it.
  • Keeping a duplicate broker order under the original strategy, which needs an engine hook with IB
    as its only user.
  • Cancel-all side and scope handling in other adapters (Define CancelAllOrders scoping and prevent cross-strategy cancellation #4470).
  • The tick-scheme registry.
  • Matching-engine synthetic leg fills.

Documentation

  • docs/integrations/interactive_brokers.md is rewritten for the new adapter: connection and
    market data modes, an architecture section with diagrams (reconnect and resubscribe, order
    identity and cancellation, execution order flow), data and execution capability matrices,
    account state, reconciliation including restart recovery and why execution-query replies are not
    live fills, symbology and generic spreads, a configuration reference, the Python enum reference,
    testing, and troubleshooting. Futures identifiers are documented as the IB local symbol.
  • examples/live/interactive_brokers runs at any date: contract selection goes through
    _common.py, the notebooks are inlined instead of routing through order_example_driver.py,
    notebooks/option_chain_example.py loads the SPY, SPX, ES, and ESTX50 option chains through one
    instrument request, and
    historical_download.py writes through ParquetDataCatalog. The Rust exec tester requires
    NAUTILUS_IB_RUN=1 to connect and NAUTILUS_IB_LIVE_ORDERS=1 to submit orders.

Release notes

Not added. RELEASES.md is maintainer-owned.

Testing

  • Affected code paths are covered by the test suite.
  • Added and updated tests to cover new and changed logic.

Core tests:

  • Model: generic spread ID construction, leg parsing, sign handling, and round trip; futures symbol
    parsing, formatting, and year resolution.

Adapter tests (offline, against parsed ibapi structs):

  • Raw account code derivation under hyphenated client names and composite configured values.
  • Cancel-all targets for BUY, SELL, and unsided requests across two strategies, untracked cached
    orders, and duplicate groups, excluding other instruments and accounts.
  • PreSubmitted mapping, OrderUpdated only on changed values, and execution details with request
    ID -1, 0, and a positive query ID, where only the first two record a live fill.
  • Bracket and OCA linking, OCA type mapping, display quantity, completion time, GTD parsing, the
    reported price of market, stop, and limit orders, and a modify that changes only the requested
    field of limit, stop, and trailing orders.
  • One bar stream per bar type, the size-aware -1 sentinel, informational error notices before
    acceptance resolving through the venue query, Inactive on an accepted order with and without a
    pending modify, a refused cancel emitting OrderCancelRejected, a trailing modify acknowledged by
    its trailing stop price, the cancel selector for tracked and duplicate orders, and an order list
    whose trailing child cannot be transformed failing before any order is sent.
  • A trailing trigger modify carrying a price or basis-point trailing_offset, the local rejection
    without it, the trailing offset types a single order accepts, and pending-cancel tracking that
    marks an order once without emitting.
  • A duplicate execution and a duplicate status reported under the DUP- client order ID, resolved
    duplicate fills gating later statuses, and one held-fill resolution per order.
  • Strategy: a trigger modify of a trailing stop market and a trailing stop limit order reaches the
    risk engine.
  • Account summary: repeated End cycles, the quiet-period flush for refreshes without an End, and
    delivery of an account state from a worker thread.
  • A depth Reset clears the book and the next update restarts the sequence.
  • A combo fill in IB's order (combo execution without a commission report, then each leg with its
    report) emits both leg fills and then the spread fill, and a replay emits nothing. A spread fill
    waits until split leg executions cover its quantity, and one without legs is sent after 5 seconds.
  • The report hooks return None while disconnected, and the inline fill report fails with the same
    not connected error. The fill-report parsing tests run through the shared report context.

Paper TWS runs (account DU187075):

  • After an ES fill the account state refreshed: locked margin moved from 139,353.60 to 174,401.34
    USD within two seconds and produced a new AccountState. Before the fix the account stayed at
    its connect-time values. query_account itself was not called in these runs.
  • During a run with a filled ES order, the bulk order and position report collections ran on a
    tokio-rt-worker thread and returned successfully (12 and 4 times). The single-order and fill
    hooks did not run, because normal operation never triggered them.
  • A client registered as IB_PAPER received its account summary and positions; before the fix the
    summary was filtered out.
  • Two ESZ6 put positions the tester never loads are recovered from venue reports at startup
    instead of aborting it. A one-lot ESZ6.XCME order, previously denied by the risk engine, was
    submitted, accepted, and filled.
  • After the rebase, a goodAfterTime market order placed before the node started reconciled as
    ACCEPTED and its fill during the run took the ESZ6.XCME position from 1 to 0. That order was
    known from startup, so the run does not exercise the held-fill path, which the adapter tests
    cover.
  • A position in 1 NVDA bought at 230.89 with a $1.00 commission (avgCost 231.89) aborted startup
    reconciliation before the average cost fix and reconciled after it.
  • Raw ibapi combo orders (ES calendar on CME, SPY call vertical on SMART) showed IB's order: the
    combo execution first, then the leg executions, with commission reports for the legs only.
    spread_example.py then bought and flattened a one-lot ES put spread: each order emitted both
    leg fills and then the spread fill within 2 ms, with no commission timeout warning, and both leg
    positions opened and closed.
  • option_chain_example.py loaded 8,536 options: SPY, SPX, ESZ6 futures options, and ESTX50 within
    their expiry windows. NVDA options loaded through build_options_chain expire at 16:00 New York
    (20:00Z on 2026-10-30, 21:00Z on 2026-11-06 after the DST change).
  • A restart recovered a GTC stop and a goodAfterTime market order from one OCA group as OUO
    linked ACCEPTED orders, and a bracket as an OTO parent with display quantity 1. Strategy
    cancels completed; in another run the held order filled at its goodAfterTime and IB cancelled
    the stop. Modifying the restored stop's trigger from 5000 to 4900 left its OCA group and
    outsideRth unchanged.
  • Before the execution-query replay filter, the node ended short 1 while IB held +1; after it, the
    node matched IB throughout. A restart with the day's filled external orders applied every fill in
    venue order after a synthetic opening for the prior-day position and ended at +1.
  • A Python strategy submitted two GTD limits, modified one, and read the cache in every callback;
    a restarted node reconciled both as ACCEPTED GTD orders and the strategy cancelled them.
  • Trailing stops on ESZ6 were accepted and their openOrder updates carried IB's trailing stop
    price as the trigger; limit orders were modified seven times, each acknowledged by OrderUpdated,
    and cancelled, with no errors.
  • Trailing stops modified 46 times with a trailing_offset: all 24 modifies that changed the amount
    were acknowledged with the new trailing stop price, and all 22 that repeated it were rejected by
    IB with 10067. A direct probe confirmed IB rejects a stop-only change for price and percent
    offsets alike. Each strategy cancel produced one OrderPendingCancel.
  • An ES put spread bought and flattened at market filled both leg positions open and closed through
    leg OrderFilled events. A restart afterwards reconciled the day's 20 orders, applied one combo
    fill to each of the 8 combo orders, matched IB's leg positions, and logged no warnings.

Local runs after the rebase onto develop:

Check Result
nautilus-interactive-brokers, lib and tests 604 passed
nautilus-model and nautilus-trading 3,984 passed, 1 skipped
Python tests/unit/adapters/interactive_brokers 23 passed
ib-exec-tester example, --features examples builds clean
cargo-test-core-local 19,952 passed, 1 failed
make pytest passed
make pre-commit passed

The core-local failure,
websocket::client::rust_tests::connection_rate_limit_gates_initial_connect_and_reconnect in
nautilus-network, fails the same way on develop.

@faysou
faysou force-pushed the ib-adapter-port branch 2 times, most recently from 0fd49cd to d4bcc92 Compare September 21, 2026 11:20
@tradatious

Copy link
Copy Markdown

Note that 4.2.0 just got released.

@faysou

faysou commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

@tradatious thanks there would be a cooldown period for the new verison so it depends on when the PR will get merged. We'll update to latest version at some point anyway, it's simple to upgrade. On the other hand we could also avoid a cooldown, as it's a library regularly used and if there's no new dependency.

@tradatious

Copy link
Copy Markdown

Two issues that appear to be new:

Stale cache tears down session

A stale cached instrument stops the execution client from connecting. preload_cached_instruments loads every instrument referenced by the client's cached orders that the provider doesn't already hold (order_state.rs#L127-L145) and requires each load to come back with exactly the requested id (the ensure! at L173-L176). A miss fails connect, and the session is torn down (core.rs#L1108-L1115).

The old adapter preloaded too, but only for spread instruments. It also only warned and continued on a miss (core_tracking.rs#L55-L79 at the base). The rewrite expands the preload to every cached order and makes a miss fatal. An expired contract or a delisted symbol on a months-old cached order would now stop the client starting, requiring a cache purge to recover.

Reconciling before trading makes sense, but refusing to start over an unresolvable instrument doesn't seem great. Perhaps restore the old warn-and-skip behaviour for that order, or gate the strictness behind a config flag?

Wrong account code sent for non-default client names

The account code sent to IB is now wrong for any client not named IB. The Nautilus AccountId is built as {client name}-{IB account}, where the client name is the key the IB execution client is registered under in exec_clients (factories.rs#L202-L211). With the default name that gives IB-U1234567; a node running two IB accounts as IB_LIVE and IB_PAPER gets IB_LIVE-U1234567 and IB_PAPER-U7654321.

To talk to IB the adapter has to recover the raw code, and raw_ib_account_code does that by stripping a literal IB- prefix (account.rs#L45-L51). That works for the default name only: IB_LIVE-U1234567 has no such prefix and is sent to IB verbatim as the account, and a client named IB-TEST (the name your factory tests use) loses the IB- off the front of the client name and sends TEST-U1234567.

This change now makes orders go through raw_ib_account_code. The old adapter split on the first hyphen for orders (core_orders.rs#L119-L124 at the base), which handled any name; only its report filters had the prefix-only bug. The rewrite unified onto the prefix helper for single orders (commands.rs#L100) and order lists (L418), so the inconsistency was fixed in the wrong direction. Modify is unaffected; it reuses the order object IB returned.

Maybe the raw code should be derived once from the configured account_id and carried alongside the Nautilus id, instead of parsing the composite id.

@faysou

faysou commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review, I'll update.

@faysou

faysou commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @tradatious, thanks for the careful read. Both are right and both are fixed in 2fe1bc5.

Stale cache. preload_cached_instruments now warns and skips an instrument IB cannot resolve instead of failing connect, restoring the previous warn-and-continue behaviour. The warning names the instrument, and orders on it stay unreconciled rather than blocking startup.

Account code. The raw IB code is derived once at construction from the configured account_id (ib_account_code) and carried on the execution client, then passed to every place that talks to IB: single orders, order lists, modifies, cancels and cancel resolution, the order and execution streams, account summary, PnL, positions, and execution filters. raw_ib_account_code and the transform's account policy are gone, so nothing parses the composite ID any more. A configured bare code wins; a configured composite such as IB-U1234567 falls back to the account part of the Nautilus ID. Unit tests cover IB-TEST and IB_LIVE names, and a paper TWS session registered as IB_PAPER now receives its account summary and positions under IB_PAPER-DU187075, where before the summary was filtered out.

@faysou
faysou force-pushed the ib-adapter-port branch 2 times, most recently from 8553534 to 865737c Compare September 22, 2026 15:23
@tradatious

Copy link
Copy Markdown

Thank you, that was fast! And the changes look great.

One small issue, in case you want to close it: if a consumer configures account_id in the composite form with a hyphenated client name, e.g. IB-TEST-U7654321, the fallback takes the part after the first hyphen and sends TEST-U7654321. Perhaps that isn't a concern, but the config field is documented only as "Account ID". Documenting the field as the bare IB code would be good, and perhaps also take the segment after the last hyphen in the fallback?

@faysou

faysou commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @tradatious, both done in 3c271bb. The account_id field is now documented as the raw IB code (DU123456), with the Nautilus ID becoming {client name}-{code}, in the Rust config, the Python getter, and the docs table. The fallback takes the segment after the last hyphen, so a composite IB-TEST-U7654321 sends U7654321; the derivation test covers that case alongside the bare and IB_LIVE ones.

@tradatious

Copy link
Copy Markdown

Awesome, thank you @faysou!

@faysou

faysou commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

You're welcome, thank you for reviewing too.

@zzlatev-openclaw-neo

Copy link
Copy Markdown

Does this port support requesting a previously unseen instrument after the node starts, then trading it without reconnecting while another position remains active?

At e2fc015, I traced request_instrument into the data provider and node cache, but couldn’t find the subsequent hydration of the execution provider. The core has an on_instrument hook, but the IB execution client appears to retain the default no-op, while tracked-fill handling requires that provider entry.

Is there a supported higher-level path I’ve missed, or is this a remaining gap in the port?

@faysou
faysou force-pushed the ib-adapter-port branch 4 times, most recently from a18992a to 31d2067 Compare September 23, 2026 16:47
@honvl

honvl commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Test report from #5057: we built this PR at 31d2067 and ran it against an IB paper account (CME MNQ futures). The setup is a LiveNode on run_async() with the IB data and execution clients, reconciliation on, and a Python strategy.

The #5057 restart case now recovers. A process was stopped with a position open and two orders still working at IB in one OCA group (ocaType=2): a GTC STOP_MARKET and a DAY MARKET with goodAfterTime, which IB holds as PreSubmitted. After a restart with the same trader ID and client ID:

  • both orders were reconciled as ACCEPTED external orders, and their updates were tracked;
  • the strategy cancelled both by PERM- venue order ID and flattened the position.

There was no "Trader ID not found", no Unhandled order status SUBMITTED, and no missing stop.

The in-session path also passed, long and short:

  • a GTD limit entry;
  • the protective stop, the goodAfterTime exit and a limit target in one OCA group;
  • a stop-trigger modify;
  • the IB-released goodAfterTime fill;
  • OCA sibling cancels.

The Unexpected order status in venue report ... Submitted warning that 2.0.0rc6.dev20260921 logged on every reconciliation pass is gone.

Two observations with this build:

  1. RuntimeError: Already borrowed on order callbacks. Every submit_order and modify_order from the Python strategy logged it for on_order_initialized, on_order_event and, on the modify, on_order_pending_update. That was 26 occurrences across entries, protective orders and a stop modify. The same strategy code on 2.0.0rc6.dev20260921 logged none. The orders themselves are unaffected, but those events never reach Python. The branch carries develop changes made after that wheel, so this may not be specific to this PR.
    [ERROR] TRADER-001.MyStrategy: Python on_order_initialized failed:
    RuntimeError: Already borrowed
    [ERROR] TRADER-001.MyStrategy: Python on_order_event failed:
    RuntimeError: Already borrowed
    
  2. A previous session's GTD orders aren't rebuilt at reconciliation. It fails with Failed to create order from report: Invalid OrderInitialized event: expire_time is required for GTD order. The parsed status report doesn't seem to carry goodTillDate as expire_time, which is the same shape as the stop trigger_type fix. In our case these were already-filled GTD entries, and the venue position report covered the position.

Build: Rust 1.98.1, maturin 1.15.0, release profile with LTO off, macOS arm64, Python 3.12.12, IB Gateway (paper). We're happy to rerun on a later commit.

@faysou

faysou commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

The complexity of IB never stops. Luckily we have several people reporting bugs and the help of modern tools. Using a cache like redis improves stability between sessions, but if we can rebuild everything without a cache even better.

@faysou
faysou force-pushed the ib-adapter-port branch 2 times, most recently from 8709ab7 to 8af617e Compare September 23, 2026 21:32
@faysou

faysou commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @honvl, thanks for the detailed test report, it helped a lot. Both issues are fixed in 8af617e, which is also rebased on the latest develop.

  1. RuntimeError: Already borrowed on order callbacks. You were right that this isn't IB-specific. The Python Strategy methods took a mutable borrow, so PyO3 held the strategy object exclusively while submit_order or modify_order ran. The order events are dispatched inside that call, so any callback that touched the strategy again (the default callback, or an override reading self.cache) failed, and the event never reached Python. The callbacks were already failing on 2.0.0rc6.dev20260921, but the errors were dropped silently; a recent develop change started logging them. Python Strategy methods now take a shared borrow, and order callbacks run normally during a command.
  2. Previous-session GTD orders not rebuilt. The status report now carries IB's goodTillDate as expire_time, so reconciliation recreates them as GTD orders. An unparsable date is reported as GTC with a warning instead of failing the report, and IB still enforces the actual expiry.

I checked both on a paper account: a Python strategy submitted two GTD limit orders (one from inside a callback), modified one, and read the cache in every callback without errors. After a restart with the same client ID, both orders were reconciled as ACCEPTED GTD orders and cancelled by the strategy.

A rerun on 8af617e would be welcome if you have time.

@honvl

honvl commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Retested at 8af617e (built from source), same setup: IB paper account, CME MNQ futures, LiveNode on run_async(), Python strategy.

  • Already borrowed: fixed. No occurrences across the night's submits and modifies (the previous build logged 26 on the same runs). A backtest check agrees: with order callbacks that read self.cache, 2.0.0rc6.dev20260921 delivered 3 of 5 callbacks during submit_order/modify_order (on_order_initialized dropped), and 8af617e delivers all 5.
  • Previous-session GTD orders: fixed. After a restart, the earlier session's GTD entry is rebuilt (as FILLED) instead of failing on expire_time.
  • Everything from the first report still passes. Restart recovery: the working GTC stop and goodAfterTime exit reconcile as ACCEPTED, are cancelled by PERM- ID, and the position is flattened. The in-session long and short paths pass too: GTD entry, OCA protection, stop modify, IB-released goodAfterTime exit, OCA cancels. No status warnings.
  • WhatIf through the adapter: 1 MNQ and 1 NQ per side, all answered PreSubmitted in 0.04–0.13 s with a positive initial margin, and none left open.

Thanks for the quick fixes.

@tradatious

Copy link
Copy Markdown

FWIW in case you want to update all in one go, rust-ibapi just released 5.0.

@faysou

faysou commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

ok thanks

@faysou faysou changed the title Port the Interactive Brokers adapter onto ibapi 4.2.0 Port the Interactive Brokers adapter onto ibapi 5.0.0 Oct 5, 2026
faysou and others added 9 commits October 6, 2026 15:46
Rewrite the Interactive Brokers adapter against the published ibapi 4.2.0
release, replacing the 3.3.0 surface.

The adapter owns the two IB behaviors that no other venue reports:

- A second permanent order ID behind one client order reference is
  reported under its own client order ID, so the engine creates it as an
  external order. Cancelling the original still cancels the whole group.
- An execution for an order the adapter does not track is held while the
  adapter queries the order details and executions, then emitted as an
  order report with its real fills. A failed or empty query forwards the
  held fills unchanged.

Outside the adapter, the change adds generic spread IDs and futures symbol
parsing to the model identifiers, and lets strategies modify the trigger of
trailing stop orders.

Futures and futures-option instrument identifiers are unchanged: the adapter
still emits the local symbol IB reports, such as `YMM6.XCBT`.
- Keep futures symbol parsing with its IB consumers
- Preserve parser behavior and tests while removing model exports
The execution client emitted one AccountState at connect and never
refreshed it: the account summary subscription was dropped after the first
End marker, so balances and margins stayed at their connect-time values for
the whole session. query_account also built its AccountState on a tokio
worker thread and sent it through the thread-local message bus, which has
no registered endpoint there, so the result was dropped.

The client now keeps the account summary subscription open in a session
task. After the initial snapshot IB pushes changed values without a further
End marker, so a refresh completes once rows stop arriving for one second.
Connect, refreshes, and query_account all deliver through an execution
event sender captured before spawning.

Fixes #5193
Fixes #5194
A market depth Reset notice now clears the instrument book and the L2
order id map, because TWS discards its side of the book and the stream
stays open. The unused realtime bar size accessor is removed because 5.0
dropped the type it returned, and test fixtures drop the removed trade
tick type field.

ibapi 5.0.0 is inside the 3 day dependency cooldown, so it is allowed in
Cargo.toml and the cargo-vet exemption moves to 5.0.0. The cooldown check
still fails until the version is 3 days old or an audit is recorded.
Clippy on the pinned 1.99.0 toolchain rejects the loop over one key. The
strike check now matches the includeExpired check below it.
ibapi 5.0.0 is inside the 3 day dependency cooldown. The audit is recorded
at the PR author's direction, who states they vetted the release, so CI can
run before the cooldown ends. Remove the cooldown allow entry in Cargo.toml
once the release is 3 days old.
The Rust formatting hook requires a blank line above a while loop that does not share an identifier with the line above it.
Report collection for single orders, bulk orders, fills, and positions
decoded IB responses on the single-threaded live node core, even though
the requests were asynchronous. The collection reads no cache: it needs
only the IB client, the instrument provider, the account codes, and the
request timeout.

The collection moves to an owned report client that holds those inputs,
and the four execution report task hooks return it as an
ExecutionReportTask, so decoding and report construction run on a runtime
worker. The inline report methods call the same code, so both paths share
one implementation and the same filters, report identity, and errors.
While the client is disconnected the hooks return None and the inline
path reports the same not connected error.

Mass status collection still uses the inline path.

Part of the adapter rollout tracked in #5216.
IB reports the combo execution of a filled spread before the leg
executions and sends no commissionReport for it. The adapter held every
execution until its commission report arrived, so the spread fill only
went out when the 5 second commission timeout fired, after the legs and
with a warning on every combo fill.

Combo executions no longer wait for a commission report, the commission
is charged on the legs. The spread fill is held on its tracked order
until the sent leg quantities cover it, then goes out, so it is the last
event of its group and code reacting to it sees the leg positions and
cash already applied. A spread fill whose legs do not arrive within 5
seconds is sent with a warning.

Checked on a paper account: a market ES put spread and its flattening
order each emitted both leg fills and then the spread fill, within 2 ms.

@cjdsellers cjdsellers left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @faysou,

Thank you for all the work on this and for your patience through the review rounds. I appreciate the time you've put into addressing the feedback, adding tests, and validating the fixes on paper accounts.

@cjdsellers
cjdsellers merged commit f81f951 into develop Oct 8, 2026
32 checks passed
@cjdsellers
cjdsellers deleted the ib-adapter-port branch October 8, 2026 02:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants