rustrade_execution/client/ibkr/mod.rs
1//! Interactive Brokers ExecutionClient implementation.
2//!
3//! Uses the `ibapi` crate for IB TWS/Gateway connectivity. Supports equities,
4//! futures, options, and forex.
5//!
6//! # Testing Status
7//!
8//! **NOT TESTED in CI.** IBKR has not confirmed permission to use credentials
9//! for CI, and requires IB Gateway/TWS running locally.
10//!
11//! **Tested locally:** All execution tests (connection, orders, account streaming)
12//! run on paper trading accounts — no market data subscriptions required, except for
13//! [shortability](#shortability).
14//!
15//! # Connection
16//!
17//! Requires TWS or IB Gateway running locally with API enabled:
18//!
19//! | Application | Live Port | Paper Port |
20//! |-------------|-----------|------------|
21//! | TWS | 7496 | 7497 |
22//! | IB Gateway | 4001 | 4002 |
23//!
24//! Enable API in TWS/Gateway: Configure → API → Settings → Enable ActiveX and Socket Clients.
25//! For order placement, uncheck "Read-Only API".
26//!
27//! # Architecture
28//!
29//! - Connection: TCP socket to TWS/Gateway
30//! - Orders: Subscription-based events (OrderStatus, ExecutionData, CommissionReport)
31//! - Account: Subscription-based position and balance updates
32//!
33//! # Balances and carrying costs
34//!
35//! Balances are read from IB's account summary, one per currency in IB's ledger (`$LEDGER:ALL`),
36//! named by the currency's code. Every account the login manages is summed per currency.
37//! [`ExecutionClient::account_stream`] delivers no balances.
38//!
39//! - `total` is the currency's `TotalCashBalance` (else `CashBalance`): its cash, negative while
40//! the account borrows it. It is not converted into the base currency.
41//! - `accrued` ([`Balance::accrued`](crate::balance::Balance::accrued)) is the currency's
42//! `AccruedCash`: interest accrued this month and not yet posted, positive when the account
43//! earns it.
44//! - `free` is `total`, capped at the account's `AvailableFunds` converted into the currency at
45//! IB's ledger rate. IB reports `AvailableFunds` only for the whole account, so every currency
46//! is capped by the same funds and the `free` values do not add up. It is negative when `total`
47//! is. With several accounts, each is capped by its own funds before they are summed. Without a
48//! rate to convert the funds, `free` is `total`, logged at WARN.
49//!
50//! The ledger's `BASE` row, every currency converted into the base currency and summed, is not
51//! returned.
52//!
53//! As IB documents it, interest accrues daily through the month in `AccruedCash` and is posted to
54//! cash at the month's end or in the first days of the next. Until then `total` leaves it out, so
55//! the cash the account will hold is `total + accrued`. Stock borrow fees accrue daily too; IB
56//! does not document whether they accrue in `AccruedCash` or when they post. Fills carry none of
57//! these charges, so PnL computed from them excludes them too.
58//!
59//! # Fill fees
60//!
61//! A fill's [`Trade::fees`](crate::trade::Trade::fees) is the `commission` of the
62//! `CommissionReport` IB sends for its execution, in that report's currency, unchanged. That
63//! figure includes the regulatory fees IB passes through and any tax IB charges on the commission
64//! itself, such as GST. Taxes IB bills separately from commissions, such as stamp duty or a
65//! financial transaction tax, are not known to be included, so on markets that levy them `fees`
66//! may understate what a fill cost. A commission that is not a finite number, or is too large
67//! for a `Decimal`, becomes a zero fee, logged at WARN; IB should never send one for a real fill.
68//! An execution whose report IB did not send has a zero fee in
69//! [`UNKNOWN_FEE_ASSET`](execution::UNKNOWN_FEE_ASSET).
70//!
71//! # Shortability
72//!
73//! [`ShortabilityClient`](crate::client::ShortabilityClient) reads what IB says about lending a
74//! stock from its shortable market data ticks, so unlike the rest of this client it uses one of
75//! the account's market data lines while it runs. It asks for IB's delayed data, which IB serves
76//! only where the account has no live subscription, so an account without one still gets an
77//! answer. See `fetch_shortability` under [`IbkrClient`]'s trait implementations, and
78//! [`IbkrConfig::shortability_timeout`].
79//!
80//! # Limitations
81//!
82//! - **Order types**: Market, Limit, Stop, StopLimit, TrailingStop, TrailingStopLimit,
83//! and Bracket (entry + take-profit + stop-loss) supported. No Algo orders.
84//! - **TimeInForce**: No `post_only` (IB has no maker-only orders)
85//! - **Client order ids** travel as each order's IB order reference, so they are at most 128
86//! ASCII characters
87//!
88//! # Reconnection & Recovery
89//!
90//! `ibapi` reconnects its socket to TWS/Gateway by itself, with the same client ID.
91//! In-flight requests fail with `ConnectionReset` at the drop, and new sends are refused
92//! until the reconnect completes. Order sends classify that as a transient
93//! [`OrderError::Connectivity`]. If the reconnect fails, `ibapi` shuts the client down.
94//!
95//! [`ExecutionClient::account_stream`] stays open across a successful reconnect. It
96//! detects the gap and, once delivery is restored, recovers the fills TWS sent while
97//! the socket was down, emitting each exactly once: as a `Trade`, or as a
98//! `TradeAmended` when it corrects an earlier execution. It then reports how each order
99//! it held as live ended during the gap, if it did. The same applies when TWS loses its
100//! own link to IB's servers (notice 1100) and later restores it (1101/1102). See that
101//! method for the details.
102//!
103//! Each order carries its client order id as its IB order reference, which IB lists with
104//! the order, its executions and its completion. So a client finds its orders by the id
105//! they were placed with even across a restart: [`ExecutionClient::account_snapshot`] and
106//! [`ExecutionClient::fetch_open_orders`] list them under it and track them again, and
107//! [`OrderStatusClient`] finds how they ended.
108//!
109//! Fills of orders that other API clients or TWS placed on the account are reported only when
110//! [`IbkrConfig::other_clients_fills`] is set, as trades of origin
111//! [`External`](crate::trade::TradeOrigin::External). A fill IB flags as an IB-initiated
112//! liquidation is reported either way, as a
113//! [`Liquidation`](crate::trade::TradeOrigin::Liquidation), on the stream, by fill recovery and by
114//! `fetch_trades`, whenever IB sends it to this connection. The stream reports one as it happens
115//! only when IB tags its execution with this client's ID: `ibapi` drops a live execution tagged
116//! with another client's, so such a fill reaches only fill recovery and `fetch_trades`. Which ID
117//! IB gives a liquidation's execution is not documented.
118//!
119//! # Caller Responsibilities
120//!
121//! 1. **Fill reconciliation**: when fill recovery fails repeatedly, the stream reports the
122//! gap as `AccountEventKind::FillRecoveryGaveUp` and stays open. Read the gap's fills
123//! with [`ExecutionClient::fetch_trades`]. A `VenueRetention` gap is the part IB no longer
124//! holds: `fetch_trades` cannot read it, so reconcile it from another source.
125//! 2. **Permanent disconnect**: when `ibapi` gives up reconnecting,
126//! [`IbkrClient::disconnect`] is called, or TWS/Gateway ends the API session,
127//! `account_stream` ends with `StreamTerminated`, and `account_stream` on the
128//! shut-down client fails, so replace the client, by reconnecting with
129//! [`IbkrClient::connect_sync`] and choosing the client ID: the caller's decision. The old
130//! client's connection is closed in each case, so TWS/Gateway frees its ID once it has
131//! processed the close (see [`IbkrClient::disconnect`]). A new `IbkrClient` tracks the
132//! orders the old one placed once an account snapshot or `fetch_open_orders` lists them;
133//! until then their events are dropped.
134//! 3. **Stale state cleanup**: Periodically call [`IbkrClient::clear_stale_executions`],
135//! [`IbkrClient::clear_stale_order_ids`], and [`IbkrClient::clear_stale_pending_cancels`]
136//!
137//! **Rationale**: IBKR uses TCP to local TWS/Gateway, not cloud WebSocket. `ibapi`
138//! owns the transient reconnect. Replacing a client that is gone for good requires IB
139//! Gateway availability and client ID coordination, decisions that belong in the
140//! caller's wrapper, not the library.
141//!
142//! # See Also
143//!
144//! - [IB API Documentation](https://www.interactivebrokers.com/campus/ibkr-api-page/trader-workstation-api/)
145//! - `rustrade_data::exchange::ibkr` for market data
146
147pub mod account;
148mod connect;
149pub mod contract;
150mod ended_orders;
151pub mod execution;
152pub mod order;
153mod recovery;
154mod shortability;
155
156use crate::{
157 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, Snapshot, UnindexedAccountEvent,
158 UnindexedAccountSnapshot,
159 balance::AssetBalance,
160 client::{
161 BracketOrderClient, ClientInstrument, ExecutionClient, OrderStatusClient,
162 dedup::new_dedup_cache,
163 order_recovery::{KnownLiveOrders, SharedKnownLiveOrders},
164 },
165 error::{
166 ApiError, ConnectivityError, NotPlacedReason, OrderError, StreamTerminationReason,
167 UnindexedClientError,
168 },
169 order::{
170 Order, OrderKey, OrderKind, TimeInForce, UnindexedOrderSnapshot,
171 bracket::{
172 BracketOrderRequest as UnifiedBracketOrderRequest,
173 BracketOrderResult as UnifiedBracketOrderResult,
174 },
175 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
176 request::{
177 OrderRequestCancel, OrderRequestOpen, OrderResponseCancel, UnindexedOrderResponseCancel,
178 },
179 state::{Cancelled, Expired, Filled, Open, OrderState, UnindexedOrderState},
180 },
181 position::PositionReport,
182 trade::{Trade, TradeOrigin, TradesRead},
183};
184use account::{BalanceAggregator, PositionAggregator};
185use chrono::{DateTime, Utc};
186pub use connect::{ConnectOutcome, ContractSkipReason, IbkrConnectError, SkippedContract};
187use contract::ResolveContractError;
188use execution::{
189 ExecutionBuffer, ExecutionRevision, ExecutionScope, ResolvedExecution, parse_decimal_or_warn,
190 try_decimal_or_warn,
191};
192use futures::stream::BoxStream;
193use ibapi::{
194 accounts::{
195 AccountSummaryResult,
196 types::{AccountGroup, AccountId},
197 },
198 client::blocking::Client,
199};
200pub use order::{BracketOrderRequest, BracketOrderResult};
201use order::{
202 OrderContext, OrderIdMap, PendingCancels, build_ib_bracket_with_oca, build_ib_order,
203 side_to_action, time_in_force_to_ib,
204};
205use parking_lot::Mutex;
206use rust_decimal::Decimal;
207use rustrade_instrument::{
208 Side,
209 asset::name::AssetNameExchange,
210 exchange::ExchangeId,
211 ibkr::{ContractRegistry, ContractRegistryError},
212 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
213};
214use serde::{Deserialize, Serialize};
215pub use shortability::DEFAULT_SHORTABILITY_TIMEOUT;
216use smol_str::format_smolstr;
217use std::{
218 collections::HashSet,
219 ops::ControlFlow,
220 panic::{AssertUnwindSafe, catch_unwind},
221 sync::Arc,
222};
223use thiserror::Error;
224use tokio::sync::mpsc;
225use tracing::{debug, error, info, trace, warn};
226
227/// Configuration for the IBKR execution client.
228///
229/// `#[non_exhaustive]`, so a setting can be added without breaking a caller. Build one with
230/// [`IbkrConfig::new`] and the `with_*` methods, or deserialise one: every setting `new` does not
231/// take has a serde default.
232#[non_exhaustive]
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct IbkrConfig {
235 /// TWS/Gateway host (e.g., "127.0.0.1")
236 pub host: String,
237 /// TWS/Gateway port (7496=TWS live, 7497=TWS paper, 4001=GW live, 4002=GW paper)
238 pub port: u16,
239 /// Client ID (must be unique per connection)
240 pub client_id: i32,
241 /// Account ID (e.g., "DU123456" for paper).
242 ///
243 /// Currently unused — balance/position queries use "All" group.
244 /// Reserved for future multi-account routing (advisor accounts).
245 pub account: String,
246 /// Contracts to resolve and register on connect. [`IbkrClient::connect_sync`] fails if any
247 /// of them cannot be registered; [`IbkrClient::connect_sync_lenient`] connects without it
248 /// and reports it.
249 #[serde(default)]
250 pub contracts: Vec<ContractConfig>,
251 /// Also report fills of orders that other API clients, or TWS itself, placed on the account,
252 /// as trades of origin [`External`](crate::trade::TradeOrigin::External). Off by default.
253 ///
254 /// Off, this client reports its own orders' fills only. Leave it off when several API clients
255 /// each feed an engine from one account: each would otherwise apply the others' fills too.
256 ///
257 /// On, another client's execution is reported wherever this client reads it. Fill recovery
258 /// and [`fetch_trades`](ExecutionClient::fetch_trades) ask IB for every client's executions,
259 /// and IB answers with them. IB does not send another API client's execution to a connection
260 /// live, client id 0 included, so the account stream reports one only when its recovery reads
261 /// it after a disconnect: read the rest with `fetch_trades`. IB numbers orders per API
262 /// client, so such a trade's
263 /// [`order_id`](crate::trade::Trade::order_id) is IB's permanent id for the order (`perm:`
264 /// and the id) rather than the client's order id, which could name one of this client's.
265 ///
266 /// A fill of an order this client's id placed is its own, even when the client does not
267 /// track the order, such as one placed before a restart. IB reports orders entered in TWS
268 /// under API client id 0, so a client connected as 0 takes their fills for its own too, under
269 /// their IB order id as [`fetch_open_orders`](ExecutionClient::fetch_open_orders) lists the
270 /// orders, and reports them as [`Order`](crate::trade::TradeOrigin::Order) whatever this is
271 /// set to. To keep manual trading apart from this client's, connect under a non-zero id.
272 ///
273 /// Either way, a fill IB flags as an IB-initiated liquidation is reported, as
274 /// [`Liquidation`](crate::trade::TradeOrigin::Liquidation), since it moves the account's
275 /// position whoever placed the order. The account stream reports one live only under this
276 /// client's ID (see [the module docs](crate::client::ibkr)).
277 #[serde(default)]
278 pub other_clients_fills: bool,
279 /// How long [`fetch_shortability`](crate::client::ShortabilityClient::fetch_shortability)
280 /// waits for IB's shortability ticks before answering with what has arrived, or failing when
281 /// nothing has. [`DEFAULT_SHORTABILITY_TIMEOUT`] by default.
282 ///
283 /// The request holds one of the account's market data lines for up to this long. Zero ends
284 /// every request before a tick can arrive, so each one fails with a timeout.
285 /// Serialised as whole milliseconds, under `shortability_timeout_ms`.
286 #[serde(
287 rename = "shortability_timeout_ms",
288 default = "default_shortability_timeout",
289 with = "duration_millis"
290 )]
291 pub shortability_timeout: std::time::Duration,
292}
293
294/// `#[serde(default = "…")]` takes a function, not a constant.
295fn default_shortability_timeout() -> std::time::Duration {
296 DEFAULT_SHORTABILITY_TIMEOUT
297}
298
299/// A [`Duration`](std::time::Duration) as a whole number of milliseconds, so a config file
300/// writes `10000` rather than the `{ secs, nanos }` serde gives it by default.
301mod duration_millis {
302 use serde::{Deserialize, Deserializer, Serializer};
303 use std::time::Duration;
304
305 pub(super) fn serialize<S: Serializer>(
306 duration: &Duration,
307 serializer: S,
308 ) -> Result<S::Ok, S::Error> {
309 // Saturates rather than fails: no timeout needs more than u64::MAX milliseconds.
310 serializer.serialize_u64(u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
311 }
312
313 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
314 deserializer: D,
315 ) -> Result<Duration, D::Error> {
316 u64::deserialize(deserializer).map(Duration::from_millis)
317 }
318}
319
320impl IbkrConfig {
321 /// A config connecting to TWS or Gateway at `host`:`port` as API client `client_id`, with no
322 /// contracts to register, only this client's fills reported, and the default
323 /// [`shortability_timeout`](Self::shortability_timeout).
324 pub fn new(
325 host: impl Into<String>,
326 port: u16,
327 client_id: i32,
328 account: impl Into<String>,
329 ) -> Self {
330 Self {
331 host: host.into(),
332 port,
333 client_id,
334 account: account.into(),
335 contracts: Vec::new(),
336 other_clients_fills: false,
337 shortability_timeout: DEFAULT_SHORTABILITY_TIMEOUT,
338 }
339 }
340
341 /// This config with [`contracts`](Self::contracts) to register on connect.
342 #[must_use]
343 pub fn with_contracts(self, contracts: Vec<ContractConfig>) -> Self {
344 Self { contracts, ..self }
345 }
346
347 /// This config with [`other_clients_fills`](Self::other_clients_fills) set.
348 #[must_use]
349 pub fn with_other_clients_fills(self, other_clients_fills: bool) -> Self {
350 Self {
351 other_clients_fills,
352 ..self
353 }
354 }
355
356 /// This config with [`shortability_timeout`](Self::shortability_timeout) set.
357 #[must_use]
358 pub fn with_shortability_timeout(self, shortability_timeout: std::time::Duration) -> Self {
359 Self {
360 shortability_timeout,
361 ..self
362 }
363 }
364}
365
366/// Pre-configured contract for startup registration.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct ContractConfig {
369 pub name: String,
370 pub symbol: String,
371 pub security_type: String,
372 pub exchange: String,
373 pub currency: String,
374 #[serde(default)]
375 pub last_trade_date: Option<String>,
376 /// Strike price for options. Uses f64 because `ibapi::Contract.strike` is f64.
377 #[serde(default)]
378 pub strike: Option<f64>,
379 #[serde(default)]
380 pub right: Option<String>,
381}
382
383impl ContractConfig {
384 /// Convert this config into an [`ibapi::contracts::Contract`].
385 ///
386 /// # Errors
387 ///
388 /// Returns a [`ContractConfigError`](contract::ContractConfigError) instead of
389 /// silently fabricating a wrong contract when the config is incomplete or
390 /// unsupported:
391 /// - [`UnrecognizedSecurityType`](contract::ContractConfigError::UnrecognizedSecurityType)
392 /// — `security_type` is not one of `STK`/`FUT`/`OPT`/`CASH`.
393 /// - [`MissingLastTradeDate`](contract::ContractConfigError::MissingLastTradeDate)
394 /// — `last_trade_date` is absent on a `FUT` or `OPT` contract.
395 /// - [`MissingStrike`](contract::ContractConfigError::MissingStrike) — `strike`
396 /// is absent on an `OPT` contract.
397 /// - [`MissingOptionRight`](contract::ContractConfigError::MissingOptionRight) —
398 /// `right` is absent on an `OPT` contract.
399 /// - [`UnrecognizedOptionRight`](contract::ContractConfigError::UnrecognizedOptionRight)
400 /// — `right` is present but not one of `C`/`CALL`/`P`/`PUT` (case-insensitive).
401 ///
402 /// A security type added here must also be added to `security_types_of`, or every entry naming
403 /// it fails `validate_config`. Nothing enforces that: add the type to
404 /// `security_types_cover_every_type_to_contract_builds` too.
405 fn to_contract(&self) -> Result<ibapi::contracts::Contract, contract::ContractConfigError> {
406 use contract::ContractConfigError as E;
407 Ok(match self.security_type.as_str() {
408 "STK" => contract::stock_contract(&self.symbol, &self.exchange, &self.currency),
409 "FUT" => contract::futures_contract(
410 &self.symbol,
411 self.last_trade_date
412 .as_deref()
413 .ok_or(E::MissingLastTradeDate)?,
414 &self.exchange,
415 &self.currency,
416 ),
417 "OPT" => contract::option_contract(
418 &self.symbol,
419 self.last_trade_date
420 .as_deref()
421 .ok_or(E::MissingLastTradeDate)?,
422 self.strike.ok_or(E::MissingStrike)?,
423 self.right.as_deref().ok_or(E::MissingOptionRight)?,
424 &self.exchange,
425 &self.currency,
426 )?,
427 "CASH" => contract::forex_contract(&self.symbol, &self.currency),
428 other => {
429 return Err(E::UnrecognizedSecurityType {
430 security_type: other.to_string(),
431 });
432 }
433 })
434 }
435}
436
437/// The `ContractConfig::security_type`s that describe an instrument of `kind`: the inverse of the
438/// mapping `SUPPORTED_KINDS` states. Empty for a kind this client cannot trade, which
439/// `SUPPORTED_KINDS` rejects before any config is checked.
440fn security_types_of(kind: InstrumentKindDiscriminant) -> &'static [&'static str] {
441 match kind {
442 InstrumentKindDiscriminant::Spot => &["STK", "CASH"],
443 InstrumentKindDiscriminant::Future => &["FUT"],
444 InstrumentKindDiscriminant::Option => &["OPT"],
445 InstrumentKindDiscriminant::Perpetual | InstrumentKindDiscriminant::Cfd => &[],
446 }
447}
448
449/// Account group for "All" accounts (used by reqAccountSummary).
450static ACCOUNT_GROUP_ALL: std::sync::LazyLock<AccountGroup> =
451 std::sync::LazyLock::new(|| AccountGroup("All".to_string()));
452
453/// How long `account_snapshot` waits for IB's next report of an account's positions before
454/// giving up on that account's listing.
455///
456/// Each account's read ends at IB's end-of-listing marker. This only bounds a stall before it.
457const POSITION_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
458
459/// Maximum time to await an initial `OrderStatus` on an order-placement
460/// subscription before giving up. Bounds the placement loops so a silent TWS
461/// (e.g. a half-open socket) cannot hang them indefinitely. A timeout yields
462/// [`PlacementOutcome::NoStatus`]; the order may still have been submitted, so
463/// callers resolve the final state via the order-update/account stream.
464const PLACEMENT_STATUS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
465
466/// IBKR "Order Message" codes that are informational rather than rejections.
467///
468/// ibapi classifies the `200..=399` range as order rejections
469/// (`ORDER_REJECTION_CODE_RANGE`) — bar 202 and, since 4.1.0, 317, both of
470/// which are resolved ahead of the range — but IBKR uses code 399 as a generic order
471/// message — e.g. *"Your order will not be placed at the exchange until
472/// 09:30:00 US/Eastern"* — for an order that is in fact **accepted and held**
473/// (it proceeds to `PreSubmitted`). We therefore report the order as
474/// live-but-pending ([`PlacementOutcome::HeldPending`]) rather than rejected
475/// when one of these codes arrives.
476///
477/// # ibapi 4.0 splits code 399 by message text
478///
479/// `is_warning_message` now classifies a 399 whose text carries a `Warning:`
480/// line as a warning, and `classify_error` routes a warning owned by a
481/// request-bound subscription as a non-terminal `RoutedItem::Notice` rather
482/// than a stream-ending `RoutedItem::Error`. `timeout_iter_data` drops notices,
483/// so the two forms now reach [`await_order_placement`] differently:
484///
485/// - **399 carrying a `Warning:` line** — dropped, and the subscription stays
486/// open. The eventual `OrderStatus(PreSubmitted)` therefore arrives on the
487/// placement subscription itself and yields
488/// [`PlacementOutcome::Accepted`] without this list being consulted. Under
489/// ibapi 3.x that status was reachable only via the order-update/account
490/// stream, because the notice had already closed the subscription.
491/// - **399 without one** — still `Err(Error::Notice)`, still closes the
492/// subscription, and is still matched against this list.
493///
494/// Re-verified against ibapi 4.1.0, which reworked this classification: the
495/// warning band widened to `2100..=2199`, `classify_error`'s predicate became
496/// `is_informational_code`, and 317 joined the data advisories. The change is
497/// strictly additive and 399 is in neither the advisory nor the system list, so
498/// both forms above route exactly as described. Re-verified against ibapi 5.0.0, which
499/// routes an error frame by whether its id is an order's or a request's, and moves 316, 354
500/// and 366 out of the order-rejection category: 399 routes to its order as before, and the
501/// warning predicate is unchanged.
502///
503/// The list stays load-bearing for the second form. It documents the known gap
504/// between ibapi's range heuristic and IBKR's actual protocol semantics; if
505/// IBKR adds further informational codes, in the 200-399 range or outside it as
506/// 404 is, an out-of-RTH placement test will surface them and they can be added here.
507///
508/// # Code 404: held for a short-sale locate
509///
510/// *"Shares for this order are not immediately available for short sale. The order will be
511/// held while we attempt to locate the shares."* IBKR keeps the order working and transmits it
512/// once shares are found, or ends it when its time in force runs out. ibapi 5.0.0 classifies 404
513/// as `NoticeCategory::Error` (it is outside every range), so it arrives as `Err(Error::Notice)`
514/// and ends the placement subscription, like the second form of 399 above. Reporting it as a
515/// rejection would tell the caller a live order is dead while it can still fill.
516const INFORMATIONAL_ORDER_CODES: &[i32] = &[399, 404];
517
518/// Outcome of awaiting the initial status on an order-placement subscription.
519///
520/// The authoritative acceptance/rejection signal in the IBKR protocol is the
521/// `OrderStatus` event, not an error notice — see [`await_order_placement`].
522///
523/// Every variant carries a distinct order-placement outcome that callers must
524/// dispatch on; dropping a value would silently discard the order's status.
525#[must_use]
526#[derive(Debug)]
527enum PlacementOutcome {
528 /// The order was acknowledged by IB and its order-id mapping must be
529 /// retained so the account stream can resolve its terminal/fill state. This
530 /// covers the working statuses (`Submitted`/`PreSubmitted`/`PendingSubmit`),
531 /// an immediate `Filled`, and the transitional cancel states
532 /// (`ApiCancelled`/`PendingCancel`) that precede a confirmed terminal status.
533 /// Carries the filled quantity reported with the status.
534 Accepted { filled: f64 },
535 /// A terminal rejection: a `Cancelled`/`Inactive` `OrderStatus`, a genuine
536 /// non-informational TWS notice, or an ibapi error other than transport
537 /// loss (see [`is_transport_loss`]). Carries the human-readable reason.
538 Rejected(String),
539 /// The order exists, but the placement subscription could not classify its
540 /// state: either an informational notice (see
541 /// [`INFORMATIONAL_ORDER_CODES`]) closed the subscription, or TWS reported
542 /// a status ibapi does not model (`OrderStatusKind::Unknown`). Either way
543 /// the order is live/held and its authoritative status will arrive via the
544 /// order-update/account stream. Carries the notice or status text.
545 HeldPending(String),
546 /// The subscription ended — or [`PLACEMENT_STATUS_TIMEOUT`] elapsed, or the
547 /// transport was lost — without any terminal status or informational
548 /// notice. The order may or may not have been accepted; resolve via the
549 /// order-update/account stream.
550 NoStatus,
551}
552
553/// Whether `e` is a transport loss `ibapi` recovers from by reconnecting by itself, so that the
554/// same request may succeed once it has: a [`ConnectivityError`].
555///
556/// Narrower than [`is_transport_loss`]: `ibapi` returns `ConnectionFailed` only once it has given
557/// up reconnecting, and `Shutdown` once the client is shut down, so retrying cannot help.
558fn is_transient_transport_loss(e: &ibapi::Error) -> bool {
559 matches!(e, ibapi::Error::ConnectionReset | ibapi::Error::Io(_))
560}
561
562/// Whether `e` means the TWS transport was lost, as opposed to TWS or ibapi
563/// answering the request.
564///
565/// Since ibapi 4.2 the sync transport fails in-flight requests with one of these
566/// the moment the socket drops, and refuses new sends until the reconnect has
567/// completed. None of them says what TWS did with an order already written.
568fn is_transport_loss(e: &ibapi::Error) -> bool {
569 matches!(
570 e,
571 ibapi::Error::ConnectionReset
572 | ibapi::Error::ConnectionFailed
573 | ibapi::Error::Shutdown
574 | ibapi::Error::Io(_)
575 )
576}
577
578/// What `execution`'s trade is reported with, or `None` when it is not reported: another API
579/// client's execution that `scope` leaves out, or one in a contract this client has not
580/// registered.
581///
582/// An execution of this client's own order is reported even if the client no longer tracks the
583/// order: after a restart, or once its id was reaped, it is still this client's fill.
584fn resolve_execution(
585 execution: &ibapi::orders::ExecutionData,
586 scope: ExecutionScope,
587 contracts: &ContractRegistry,
588) -> Option<ResolvedExecution> {
589 let ib_order_id = execution.execution.order_id;
590 let con_id = execution.contract.contract_id;
591
592 let Some((order_id, origin)) = scope.classify(&execution.execution) else {
593 trace!(
594 ib_order_id,
595 api_client_id = execution.execution.client_id,
596 "ExecutionData for another API client's order, dropping \
597 (IbkrConfig::other_clients_fills is off)"
598 );
599 return None;
600 };
601 let Some(instrument) = contracts.get_name_by_con_id(con_id) else {
602 if origin == TradeOrigin::External {
603 // Another client's trading in a contract this one does not follow: expected, and on
604 // a busy account frequent.
605 debug!(
606 ib_order_id,
607 con_id,
608 exec_id = %execution.execution.execution_id,
609 "Another API client's ExecutionData in a contract this client has not \
610 registered, dropping"
611 );
612 } else {
613 // A fill of this client's order, or a liquidation, moved the account's position, and
614 // no trade will report it. One line per such execution, so no louder than the fills.
615 warn!(
616 ib_order_id,
617 con_id,
618 exec_id = %execution.execution.execution_id,
619 %origin,
620 "ExecutionData in a contract this client has not registered, dropping"
621 );
622 }
623 return None;
624 };
625 Some(ResolvedExecution {
626 instrument,
627 order_id,
628 origin,
629 })
630}
631
632/// How long a listing read waits for IB's next message before giving up on the read.
633///
634/// IB sends a listing in one burst and then marks its end, so a pause this long means TWS has
635/// stalled, not that the listing is long.
636const LISTING_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
637
638/// Read one IB listing, passing each item to `on_item`, until IB marks the end of the listing or
639/// `on_item` breaks.
640///
641/// `next` is the subscription's `next_timeout`. That answers `None` at the end marker, when its
642/// wait runs out, and when `ibapi` drops the subscription's channel. Only the first and last
643/// return early (see [`recovery::returned_early`]), so an early `None` is the end of the listing
644/// only while `connected` still says the client is connected.
645///
646/// # Errors
647/// [`UnindexedClientError::Internal`], naming `what`, when IB answers with an error, sends
648/// nothing for `stall_timeout` ([`LISTING_STALL_TIMEOUT`] outside tests) before the end of the
649/// listing, or the connection is gone when the listing stops. Either way what was read is not the
650/// whole listing, and must not be mistaken for it.
651///
652/// # Known limitation
653/// A connection that drops and is restored between the last item and that check reads as the end
654/// of the listing. `ibapi` fails a request in flight when its socket drops, which surfaces here as
655/// an error first, so this needs the drop to lose that error too.
656fn read_listing<T>(
657 what: &str,
658 stall_timeout: std::time::Duration,
659 connected: impl Fn() -> bool,
660 next: impl FnMut(
661 std::time::Duration,
662 ) -> Option<Result<ibapi::subscriptions::SubscriptionItem<T>, ibapi::Error>>,
663 on_item: impl FnMut(T) -> ControlFlow<()>,
664) -> Result<(), UnindexedClientError> {
665 read_listing_items(what, stall_timeout, connected, next, on_item)
666 .map_err(|e| UnindexedClientError::Internal(format!("{what}: {e}")))
667}
668
669/// Why a listing read ended short of IB's end-of-listing marker: see [`read_listing`].
670#[derive(Debug, Error)]
671enum ListingError {
672 /// IB answered the request with an error.
673 #[error(transparent)]
674 Ibapi(ibapi::Error),
675 /// The connection was gone when the listing stopped.
676 #[error("the connection dropped before the end of the listing")]
677 Dropped,
678 /// IB sent nothing for this long before the end of the listing.
679 #[error("IB sent nothing for {0:?} before the end of the listing")]
680 Stalled(std::time::Duration),
681}
682
683/// [`read_listing`], keeping why the read failed for a caller that tells IB's errors apart.
684/// `what` only names the listing in the notices logged during the read.
685fn read_listing_items<T>(
686 what: &str,
687 stall_timeout: std::time::Duration,
688 connected: impl Fn() -> bool,
689 mut next: impl FnMut(
690 std::time::Duration,
691 ) -> Option<Result<ibapi::subscriptions::SubscriptionItem<T>, ibapi::Error>>,
692 mut on_item: impl FnMut(T) -> ControlFlow<()>,
693) -> Result<(), ListingError> {
694 use ibapi::subscriptions::SubscriptionItem;
695
696 loop {
697 let waited = std::time::Instant::now();
698 match next(stall_timeout) {
699 Some(Ok(SubscriptionItem::Data(item))) => {
700 if on_item(item).is_break() {
701 return Ok(());
702 }
703 }
704 Some(Ok(SubscriptionItem::Notice(notice))) => {
705 debug!(%notice, what, "Notice during an IBKR listing read");
706 }
707 Some(Err(e)) => return Err(ListingError::Ibapi(e)),
708 None if recovery::returned_early(waited.elapsed(), stall_timeout) => {
709 if !connected() {
710 return Err(ListingError::Dropped);
711 }
712 return Ok(());
713 }
714 None => return Err(ListingError::Stalled(stall_timeout)),
715 }
716 }
717}
718
719/// IB's error code for a contract description that matches no contract ("No security definition
720/// has been found for the request").
721const NO_SECURITY_DEFINITION_CODE: i32 = 200;
722
723/// Resolve `contract` through IB's contract details: see [`IbkrClient::resolve_contract`].
724fn resolve_contract_blocking(
725 client: &Client,
726 contract: &ibapi::contracts::Contract,
727) -> Result<ibapi::contracts::Contract, ResolveContractError> {
728 let subscription = client
729 .contract_details_stream(contract)
730 .subscribe()
731 .map_err(contract_details_error)?;
732 let mut matches = Vec::new();
733 let read = read_listing_items(
734 "contract_details",
735 LISTING_STALL_TIMEOUT,
736 || client.is_connected(),
737 |timeout| subscription.next_timeout(timeout),
738 |details: ibapi::contracts::ContractDetails| {
739 matches.push(details.contract);
740 ControlFlow::Continue(())
741 },
742 );
743 classify_contract_details(read, matches)
744}
745
746/// The resolved contract from a contract-details read: its one match, or why there is not one.
747fn classify_contract_details(
748 read: Result<(), ListingError>,
749 mut matches: Vec<ibapi::contracts::Contract>,
750) -> Result<ibapi::contracts::Contract, ResolveContractError> {
751 match read {
752 Ok(()) => {}
753 Err(ListingError::Ibapi(e)) => return Err(contract_details_error(e)),
754 Err(e @ ListingError::Dropped) => {
755 return Err(ConnectivityError::Socket(e.to_string()).into());
756 }
757 Err(ListingError::Stalled(_)) => return Err(ConnectivityError::Timeout.into()),
758 }
759 if matches.len() > 1 {
760 return Err(ResolveContractError::Ambiguous { matches });
761 }
762 matches.pop().ok_or(ResolveContractError::NoMatch)
763}
764
765/// What an `ibapi` error answering a contract-details request means for the resolution.
766fn contract_details_error(e: ibapi::Error) -> ResolveContractError {
767 match e {
768 // IB answers a description that matches nothing with this error, not an empty listing.
769 ibapi::Error::Notice(notice) if notice.code == NO_SECURITY_DEFINITION_CODE => {
770 ResolveContractError::NoMatch
771 }
772 ibapi::Error::Notice(notice) => ResolveContractError::Refused {
773 code: notice.code,
774 message: notice.message,
775 },
776 e if is_transient_transport_loss(&e) => ConnectivityError::Socket(e.to_string()).into(),
777 e => ResolveContractError::Failed(e.to_string()),
778 }
779}
780
781/// What an `ibapi` error answering the request for the login's accounts means for the caller.
782///
783/// IB sending no answer is a stall a retry can clear. Any other error is `Internal`; see the call
784/// in `account_snapshot` for why.
785fn managed_accounts_error(e: ibapi::Error) -> UnindexedClientError {
786 match e {
787 ibapi::Error::Timeout => ConnectivityError::Timeout.into(),
788 e => UnindexedClientError::Internal(format!("managed accounts: {e}")),
789 }
790}
791
792/// Serializes the reads of IB's order listings.
793///
794/// IB answers an open-orders or completed-orders request without naming the request, so `ibapi`
795/// copies each answer to every such request of the kind in flight. Two reads at once would each
796/// see the other's orders, and one could stop at the other's end marker, short of its own
797/// listing. Each read holds this for its whole length.
798#[derive(Debug, Clone, Default)]
799struct ListingLock(Arc<Mutex<()>>);
800
801impl ListingLock {
802 /// Read IB's listing of every API client's open orders, each order followed by its status.
803 ///
804 /// # Errors
805 /// As [`read_listing`].
806 fn open_orders(
807 &self,
808 client: &Client,
809 ) -> Result<Vec<ibapi::orders::Orders>, UnindexedClientError> {
810 let _serial = self.0.lock();
811 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
812 let subscription = client
813 .all_open_orders()
814 .map_err(|e| UnindexedClientError::Internal(format!("open_orders: {e}")))?;
815 let mut listing = Vec::new();
816 read_listing(
817 "open_orders",
818 LISTING_STALL_TIMEOUT,
819 || client.is_connected(),
820 |timeout| subscription.next_timeout(timeout),
821 |item| {
822 listing.push(item);
823 ControlFlow::Continue(())
824 },
825 )?;
826 Ok(listing)
827 }
828
829 /// Read IB's listing of the account's completed orders: every API client's, and those
830 /// entered in TWS. Each item is handed to `on_item` as it arrives, since the listing can be
831 /// long and only a few of its orders are wanted.
832 ///
833 /// # Errors
834 /// As [`read_listing`].
835 fn completed_orders(
836 &self,
837 client: &Client,
838 mut on_item: impl FnMut(ibapi::orders::Orders),
839 ) -> Result<(), UnindexedClientError> {
840 let _serial = self.0.lock();
841 let subscription = client
842 .completed_orders(false)
843 .map_err(|e| UnindexedClientError::Internal(format!("completed_orders: {e}")))?;
844 read_listing(
845 "completed_orders",
846 LISTING_STALL_TIMEOUT,
847 || client.is_connected(),
848 |timeout| subscription.next_timeout(timeout),
849 |item| {
850 on_item(item);
851 ControlFlow::Continue(())
852 },
853 )
854 }
855}
856
857/// Read one account-summary listing into balances.
858///
859/// Errors and stalls are surfaced rather than a partial balance set returned (see
860/// [`read_listing`]). The subscription stays live after the listing, so its end is the `End` item,
861/// and a subscription that stops before it fails the read.
862fn read_account_summary(
863 stall_timeout: std::time::Duration,
864 connected: impl Fn() -> bool,
865 next: impl FnMut(
866 std::time::Duration,
867 ) -> Option<
868 Result<ibapi::subscriptions::SubscriptionItem<AccountSummaryResult>, ibapi::Error>,
869 >,
870) -> Result<BalanceAggregator, UnindexedClientError> {
871 let mut aggregator = BalanceAggregator::new();
872 let mut ended = false;
873 read_listing(
874 "account_summary",
875 stall_timeout,
876 connected,
877 next,
878 |summary| match summary {
879 AccountSummaryResult::Summary(s) => {
880 aggregator.process(&s);
881 ControlFlow::Continue(())
882 }
883 AccountSummaryResult::End => {
884 ended = true;
885 ControlFlow::Break(())
886 }
887 },
888 )?;
889 if !ended {
890 return Err(UnindexedClientError::Internal(
891 "account_summary: the subscription ended before the end of the listing".to_string(),
892 ));
893 }
894 Ok(aggregator)
895}
896
897/// Read one account's positions into `positions`, returning whether IB marked the end of the
898/// listing before [`POSITION_STALL_TIMEOUT`] passed without a report.
899///
900/// Positions-multi rather than `positions()`: its replies carry this request's ID, while
901/// `positions()` replies carry none and reach every open positions subscription, so another read's
902/// listing, end marker and all, could end this one early. IB documents the account parameter as
903/// optional only for a login with a single account, so it is always given.
904///
905/// Only reports for a registered instrument, and in `instruments` when that is given, are kept.
906fn read_account_positions(
907 client: &Client,
908 account: AccountId,
909 contracts: &ContractRegistry,
910 instruments: Option<&HashSet<InstrumentNameExchange>>,
911 positions: &mut PositionAggregator,
912) -> Result<bool, UnindexedClientError> {
913 use ibapi::accounts::{Position, PositionUpdateMulti};
914
915 let subscription = client
916 .positions_multi(Some(&account), None)
917 .map_err(|e| UnindexedClientError::Internal(format!("positions of {account}: {e}")))?;
918
919 for update in subscription.timeout_iter_data(POSITION_STALL_TIMEOUT) {
920 // Surface subscription errors rather than returning partial positions
921 // (a truncated snapshot could be misread as positions having closed).
922 let update = update.map_err(|e| {
923 UnindexedClientError::Internal(format!("positions subscription of {account}: {e}"))
924 })?;
925 let position = match update {
926 PositionUpdateMulti::Position(position) => Position {
927 account: position.account,
928 contract: position.contract,
929 position: position.position,
930 average_cost: position.average_cost,
931 },
932 // The subscription stays live after the listing, streaming changes; this read wants
933 // the listing alone. Dropping the subscription cancels it.
934 PositionUpdateMulti::PositionEnd => return Ok(true),
935 };
936 let Some(instrument) = contracts.get_name_by_con_id(position.contract.contract_id) else {
937 continue;
938 };
939 if instruments.is_some_and(|filter| !filter.contains(&instrument)) {
940 continue;
941 }
942 positions.process(instrument, position);
943 }
944 Ok(false)
945}
946
947/// Forward `updates`, from `ibapi`'s order-update subscription, to `sink` as account events,
948/// until the subscription ends or the account stream does.
949///
950/// A closed stream is noticed on the next update, whether or not that update would be
951/// forwarded, so the thread running this releases `ibapi`'s single order-update slot as soon as
952/// TWS sends anything.
953///
954/// `ibapi` ends the subscription with [`ibapi::Error::Shutdown`] when the client shuts down for
955/// good, and the stream is terminated with [`recovery::CLIENT_SHUT_DOWN`].
956fn forward_order_updates(
957 updates: impl IntoIterator<Item = Result<ibapi::orders::OrderUpdate, ibapi::Error>>,
958 sink: &recovery::EventSink,
959 scope: ExecutionScope,
960 contracts: &ContractRegistry,
961 order_ids: &OrderIdMap,
962 pending_cancels: &PendingCancels,
963 exec_buffer: &ExecutionBuffer,
964) {
965 use ibapi::orders::{OrderStatusKind, OrderUpdate};
966
967 for update in updates {
968 // Recovery ended the stream, or the consumer dropped it. Stop on this update rather than
969 // the next one that would be forwarded, which may never come.
970 if !sink.is_open() {
971 return;
972 }
973 let update = match update {
974 Ok(u) => u,
975 Err(ibapi::Error::Shutdown) => {
976 warn!("IBKR client shut down; terminating the account stream");
977 sink.terminate(StreamTerminationReason::Error(
978 recovery::CLIENT_SHUT_DOWN.to_string(),
979 ));
980 return;
981 }
982 Err(e) => {
983 // ibapi's own reconnect does not surface here (see the
984 // recovery module), so an error on the subscription is terminal.
985 // Surface it in-band as StreamTerminated(Error) so the caller
986 // gets a programmatic signal rather than inferring EOF.
987 // (best-effort — a no-op if the consumer already dropped rx.)
988 error!(error = %e, "Order stream subscription error");
989 sink.terminate(StreamTerminationReason::Error(e.to_string()));
990 return;
991 }
992 };
993 let event = match update {
994 // IB numbers orders per API client. `ibapi` keeps other clients' order statuses off
995 // this stream, but only by reading the client id from the frame, so this check stays.
996 OrderUpdate::OrderStatus(status) if status.client_id != scope.api_client_id => {
997 trace!(
998 ib_order_id = status.order_id,
999 api_client_id = status.client_id,
1000 "OrderStatus for another API client's order, dropping"
1001 );
1002 None
1003 }
1004 OrderUpdate::OrderStatus(status) => {
1005 let ib_id = status.order_id;
1006 // Single-lock methods for the terminal statuses, to avoid read+write. Each ends
1007 // the order, freeing its client id. Only `Cancelled`/`Inactive` remove its entry:
1008 // a `Filled` order's is retained so late-arriving ExecutionData/CommissionReport
1009 // events still resolve it (reaped later by `OrderIdMap::clear_stale`).
1010 let lookup_result = match status.status {
1011 OrderStatusKind::Cancelled | OrderStatusKind::Inactive => {
1012 order_ids.remove_and_get_context(ib_id)
1013 }
1014 OrderStatusKind::Filled => order_ids.release_client_id(ib_id),
1015 _ => order_ids.get_client_id_and_context(ib_id),
1016 };
1017
1018 match lookup_result {
1019 // An order that already ended, whose id now names a later order: IB re-sent
1020 // a status for it. Forwarded under the id, it would be read as the later
1021 // order's, and a re-sent `Filled` would end that order.
1022 Some((client_id, _)) if order_ids.names_other_order(&client_id, ib_id) => {
1023 debug!(
1024 ib_order_id = ib_id,
1025 cid = %client_id,
1026 "OrderStatus for an ended order whose client order id names a later order, dropping"
1027 );
1028 None
1029 }
1030 Some((client_id, ctx)) => {
1031 let order =
1032 make_order_from_status(&status, client_id, &ctx, pending_cancels);
1033 Some(UnindexedAccountEvent {
1034 exchange: ExchangeId::Ibkr,
1035 kind: AccountEventKind::OrderSnapshot(Snapshot::new(order)),
1036 })
1037 }
1038 None => {
1039 debug!(ib_order_id = ib_id, "OrderStatus for unknown order ID");
1040 None
1041 }
1042 }
1043 }
1044 OrderUpdate::ExecutionData(exec) => {
1045 // ibapi copies an executions request's answers here too; they
1046 // are not fills happening now, and recovery emits its own.
1047 if recovery::is_replayed_execution(&exec) {
1048 trace!(
1049 exec_id = %exec.execution.execution_id,
1050 request_id = exec.request_id,
1051 "ExecutionData answering an executions request, skipping"
1052 );
1053 continue;
1054 }
1055 let Some(resolved) = resolve_execution(&exec, scope, contracts) else {
1056 continue;
1057 };
1058 // Logged on arrival, so the correction is seen even if no commission report
1059 // ever completes it.
1060 if ExecutionRevision::parse(&exec.execution.execution_id)
1061 .is_some_and(|revision| revision.is_correction())
1062 {
1063 warn!(
1064 exec_id = %exec.execution.execution_id,
1065 instrument = %resolved.instrument,
1066 price = exec.execution.price,
1067 shares = exec.execution.shares,
1068 "IBKR corrected an execution; it will be reported as TradeAmended if its \
1069 commission report arrives"
1070 );
1071 }
1072
1073 exec_buffer.add_execution(exec, resolved);
1074 None
1075 }
1076 OrderUpdate::CommissionReport(report) => {
1077 if let Some(trade) = exec_buffer.complete_with_commission(&report)
1078 && !sink.send_execution(trade)
1079 {
1080 return;
1081 }
1082 None
1083 }
1084 _ => None,
1085 };
1086
1087 if let Some(e) = event
1088 && !sink.send(e)
1089 {
1090 // The consumer dropped rx, or recovery ended the stream: either
1091 // way nothing more may be sent.
1092 return;
1093 }
1094 }
1095
1096 // The subscription iterator ended without an error (e.g. clean
1097 // disconnect/unsubscribe, or ibapi giving up reconnecting). Still a
1098 // terminal stream death — surface it in-band so the consumer doesn't have
1099 // to infer it from channel EOF.
1100 sink.terminate(StreamTerminationReason::Error(
1101 "IBKR order-update stream ended".to_string(),
1102 ));
1103}
1104
1105/// The message a worker thread panicked with.
1106fn panic_message(panic_info: &(dyn std::any::Any + Send)) -> String {
1107 panic_info
1108 .downcast_ref::<&str>()
1109 .map(|s| s.to_string())
1110 .or_else(|| panic_info.downcast_ref::<String>().cloned())
1111 .unwrap_or_else(|| "unknown panic".to_string())
1112}
1113
1114/// Classify the error from a send (`place_order` / `cancel_order`) that failed
1115/// before TWS acknowledged anything, carrying `message` as the reason.
1116///
1117/// A dropped or reconnecting transport (`ConnectionReset`, `Io`) is a transient
1118/// [`OrderError::Connectivity`], which may be retried once the connection is back.
1119/// ibapi raises `ConnectionReset` before writing anything, so that request did not
1120/// reach TWS; `Io` comes from the write itself, which may have sent part of it. An
1121/// open refused with `ConnectionReset` is reported by [`open_send_error`] instead.
1122/// `Shutdown` and `ConnectionFailed` mean ibapi has given up reconnecting, so they
1123/// are non-transient, like any other refusal ([`ApiError::OrderRejected`]).
1124///
1125/// # Known limitation
1126///
1127/// ibapi refuses a send with `ConnectionReset` whenever the session is not
1128/// connected, including after it has shut down for good, so a send refused on a
1129/// dead client still reads as transient here. The client's streams ending is
1130/// the signal that it will not come back.
1131fn send_error<AssetKey, InstrumentKey>(
1132 e: &ibapi::Error,
1133 message: String,
1134) -> OrderError<AssetKey, InstrumentKey> {
1135 if is_transient_transport_loss(e) {
1136 OrderError::Connectivity(ConnectivityError::Socket(message))
1137 } else {
1138 OrderError::Rejected(ApiError::OrderRejected(message))
1139 }
1140}
1141
1142/// Classify the error from a `place_order` that failed before TWS acknowledged
1143/// anything, as [`send_error`] does, except that a `ConnectionReset` says the order
1144/// was not placed: ibapi refuses the send with it before writing anything.
1145fn open_send_error<AssetKey, InstrumentKey>(
1146 e: &ibapi::Error,
1147 message: String,
1148) -> OrderError<AssetKey, InstrumentKey> {
1149 if matches!(e, ibapi::Error::ConnectionReset) {
1150 OrderError::NotPlaced(NotPlacedReason::NotSent(ConnectivityError::Socket(message)))
1151 } else {
1152 send_error(e, message)
1153 }
1154}
1155
1156/// Forget the IB order id `ib_order_id` of an open that failed with `error`: the order was never
1157/// sent, or TWS rejected it (Cancelled/Inactive or a genuine notice).
1158///
1159/// Unless the open's outcome is unknown ([`OrderError::is_outcome_unknown`]): then TWS may have
1160/// placed the order, so the mapping stays, to route its status and fills on the account stream
1161/// while the order is looked up. If it was never placed, the mapping holds its client order id
1162/// until [`IbkrClient::clear_stale_order_ids`] clears it.
1163fn release_failed_open<AssetKey, InstrumentKey>(
1164 order_ids: &OrderIdMap,
1165 ib_order_id: i32,
1166 error: &OrderError<AssetKey, InstrumentKey>,
1167) {
1168 if !error.is_outcome_unknown() {
1169 order_ids.remove_by_ib_id(ib_order_id);
1170 }
1171}
1172
1173/// Drive an order-placement subscription to its initial [`PlacementOutcome`].
1174///
1175/// Shared by the single-order and bracket-leg placement paths. Consumes events
1176/// from a `place_order` subscription (typically `sub.timeout_iter_data(..)`)
1177/// until a decisive outcome is reached.
1178///
1179/// # Why notices are not blanket rejections
1180///
1181/// ibapi delivers a TWS error frame that it does not classify as a warning as
1182/// `Err(Error::Notice)`, and then closes the subscription. IB emits informational order
1183/// messages (e.g. code 399, order held until RTH, or 404, held for a short-sale locate) the
1184/// same way, so treating every `Err` as a rejection would falsely reject orders that are
1185/// actually live. We instead key off the notice code: known informational
1186/// codes yield [`PlacementOutcome::HeldPending`]; transport loss yields
1187/// [`PlacementOutcome::NoStatus`], because the order may already be live; all
1188/// other notices and errors yield [`PlacementOutcome::Rejected`]. The `OrderStatus`
1189/// event — when one is delivered before the closing notice — remains
1190/// authoritative.
1191///
1192/// ibapi 4.0 widened that warning classification beyond the `2100..=2169` range
1193/// to include code 0 and the `Warning:` form of code 399, and now routes a
1194/// warning owned by a request-bound subscription as a non-terminal notice that
1195/// `timeout_iter_data` drops rather than an `Err` that ends the stream. See
1196/// [`INFORMATIONAL_ORDER_CODES`] for what that changes here.
1197fn await_order_placement<I>(events: I) -> PlacementOutcome
1198where
1199 I: IntoIterator<Item = Result<ibapi::orders::PlaceOrder, ibapi::Error>>,
1200{
1201 use ibapi::orders::PlaceOrder;
1202
1203 for event in events {
1204 let event = match event {
1205 Ok(event) => event,
1206 // Informational order message (e.g. 399 held until RTH, 404 held for a
1207 // short-sale locate): the order is accepted; ibapi has closed the
1208 // subscription, so stop here and let the update stream carry the real status.
1209 Err(ibapi::Error::Notice(n)) if INFORMATIONAL_ORDER_CODES.contains(&n.code) => {
1210 return PlacementOutcome::HeldPending(format!("[{}] {}", n.code, n.message));
1211 }
1212 // The socket dropped before TWS reported a status. ibapi 4.2 fails
1213 // in-flight requests at the drop rather than after the reconnect, so
1214 // this can arrive after TWS received the order: its fate is unknown,
1215 // not rejected.
1216 Err(e) if is_transport_loss(&e) => {
1217 warn!(error = %e, "transport lost while awaiting order placement; status unknown");
1218 return PlacementOutcome::NoStatus;
1219 }
1220 // Genuine notice (e.g. 201 reject) or another ibapi error.
1221 Err(e) => return PlacementOutcome::Rejected(e.to_string()),
1222 };
1223
1224 if let PlaceOrder::OrderStatus(status) = event {
1225 use ibapi::orders::OrderStatusKind;
1226 match status.status {
1227 OrderStatusKind::Submitted
1228 | OrderStatusKind::PreSubmitted
1229 | OrderStatusKind::PendingSubmit
1230 // A marketable order can fill before any working status is
1231 // delivered on the placement subscription — ibapi sends
1232 // `OrderStatus(Filled)` directly in that case. The order is
1233 // live, not rejected; its authoritative terminal/fill state
1234 // arrives via the account stream. Report it accepted so the
1235 // order-id mapping is retained and the stream's
1236 // executions/commissions are captured (treating it as a
1237 // rejection would remove the mapping and drop those events).
1238 | OrderStatusKind::Filled => {
1239 return PlacementOutcome::Accepted {
1240 filled: status.filled,
1241 };
1242 }
1243 OrderStatusKind::Cancelled | OrderStatusKind::Inactive => {
1244 return PlacementOutcome::Rejected(status.status.to_string());
1245 }
1246 // Transitional cancel states: a cancellation was already requested
1247 // (e.g. a fast cancel racing the placement ack), but the order was
1248 // submitted — we have a placement subscription — and is still live.
1249 // `ApiCancelled` always precedes a confirmed `Cancelled`; both it
1250 // and `PendingCancel` resolve to a terminal status on the account
1251 // stream, which is authoritative. Report accepted so the order-id
1252 // mapping is retained and the stream's terminal/execution/commission
1253 // events are captured (treating these as a rejection would remove
1254 // the mapping and drop those events).
1255 OrderStatusKind::ApiCancelled | OrderStatusKind::PendingCancel => {
1256 return PlacementOutcome::Accepted {
1257 filled: status.filled,
1258 };
1259 }
1260 // Pre-transmission state: ibapi has not yet sent the order to IB
1261 // (e.g. awaiting a security-definition lookup). This is not a
1262 // placement decision — keep waiting for a definitive status. The
1263 // `PLACEMENT_STATUS_TIMEOUT` backstop yields `NoStatus` if none
1264 // arrives.
1265 OrderStatusKind::ApiPending => {}
1266 // TWS reported a status ibapi does not model. The order exists
1267 // — TWS is reporting on it — but neither we nor ibapi can
1268 // classify it: `is_active()` and `is_terminal()` are both false
1269 // for this variant. Report it live-but-indeterminate, which
1270 // retains the order-id mapping so the account stream can
1271 // resolve the real state.
1272 //
1273 // Deliberately NOT falling through to the `PLACEMENT_STATUS_TIMEOUT`
1274 // backstop the way `ApiPending` does: that yields `NoStatus`,
1275 // and on the bracket path `NoStatus` cancels all three legs —
1276 // a destructive response to a status we merely failed to
1277 // recognise.
1278 OrderStatusKind::Unknown(ref raw) => {
1279 warn!(
1280 ib_order_id = status.order_id,
1281 status = raw.as_str(),
1282 "unmodelled IBKR order status during placement; \
1283 treating as live, account stream is authoritative"
1284 );
1285 return PlacementOutcome::HeldPending(format!(
1286 "unmodelled order status: {raw}"
1287 ));
1288 }
1289 }
1290 }
1291 // Non-status events (OpenOrder, ExecutionData, CommissionReport): keep
1292 // waiting for the OrderStatus.
1293 }
1294
1295 PlacementOutcome::NoStatus
1296}
1297
1298/// Interactive Brokers execution client.
1299///
1300/// # Clone Behavior
1301///
1302/// Cloning creates a shallow copy with shared `Arc` references to the underlying
1303/// IB connection, contract registry, order ID map, and execution buffer. All clones
1304/// share the same TWS/Gateway connection and state.
1305#[derive(Clone)]
1306pub struct IbkrClient {
1307 config: Arc<IbkrConfig>,
1308 client: Arc<Client>,
1309 contracts: ContractRegistry,
1310 order_ids: OrderIdMap,
1311 pending_cancels: PendingCancels,
1312 execution_buffer: ExecutionBuffer,
1313 next_order_id: Arc<Mutex<i32>>,
1314 /// The orders this client has seen live and not yet seen end, for the account stream's check
1315 /// of how they ended while it was disconnected.
1316 known_live: SharedKnownLiveOrders,
1317 /// Held across each read of IB's open-order and completed-order listings (see
1318 /// [`ListingLock`]).
1319 listings: ListingLock,
1320 /// The completions taken as an earlier order's under a reused client order id, each warned
1321 /// about once.
1322 earlier_completions: ended_orders::EarlierCompletions,
1323}
1324
1325impl std::fmt::Debug for IbkrClient {
1326 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1327 f.debug_struct("IbkrClient")
1328 .field("config", &self.config)
1329 .field("contracts_count", &self.contracts.len())
1330 .field("pending_orders", &self.order_ids.len())
1331 .finish()
1332 }
1333}
1334
1335impl IbkrClient {
1336 /// Connect to TWS/Gateway and register every contract in [`IbkrConfig::contracts`]
1337 /// (sync, blocking).
1338 ///
1339 /// Each contract is built from its config, resolved as
1340 /// [`resolve_contract`](Self::resolve_contract) does and registered as
1341 /// [`register_contract`](Self::register_contract) does. If any of them fails, the
1342 /// connection is dropped and [`IbkrConnectError::Contracts`] lists every contract that
1343 /// failed, not just the first, so that a client never starts without a contract its caller
1344 /// configured. To connect anyway and get the failures back, use
1345 /// [`connect_sync_lenient`](Self::connect_sync_lenient).
1346 ///
1347 /// The library does not retry. If every contract in [`IbkrConnectError::Contracts`] has a
1348 /// [`reason`](SkippedContract::reason) that [`is_transient`](ContractSkipReason::is_transient),
1349 /// connecting again may succeed. Otherwise fix the config, or connect with
1350 /// [`connect_sync_lenient`](Self::connect_sync_lenient).
1351 ///
1352 /// # Errors
1353 ///
1354 /// - [`IbkrConnectError::Connect`] if TWS/Gateway cannot be reached or refuses the
1355 /// connection.
1356 /// - [`IbkrConnectError::Contracts`] if any configured contract cannot be registered.
1357 pub fn connect_sync(config: IbkrConfig) -> Result<Self, IbkrConnectError> {
1358 let ConnectOutcome { client, skipped } = Self::connect_inner(config)?;
1359 if skipped.is_empty() {
1360 Ok(client)
1361 } else {
1362 // Dropping the only `ibapi` client closes the connection, freeing the client ID.
1363 drop(client);
1364 Err(IbkrConnectError::Contracts(skipped))
1365 }
1366 }
1367
1368 /// Connect to TWS/Gateway as [`connect_sync`](Self::connect_sync) does, but without each
1369 /// configured contract that cannot be registered, returning those in
1370 /// [`ConnectOutcome::skipped`].
1371 ///
1372 /// Orders for a skipped contract's instrument are refused as for any unregistered
1373 /// instrument, so check `skipped`. To retry one, call
1374 /// [`resolve_contract`](Self::resolve_contract) and then
1375 /// [`register_contract`](Self::register_contract) on the connected client.
1376 ///
1377 /// Blocking, like [`connect_sync`](Self::connect_sync): it waits for the connection and for
1378 /// one contract details request per configured contract, each of which can take up to 10
1379 /// seconds if IB stops answering. From async code, call it inside
1380 /// `tokio::task::spawn_blocking`.
1381 ///
1382 /// # Errors
1383 ///
1384 /// Returns an error if TWS/Gateway cannot be reached or refuses the connection.
1385 pub fn connect_sync_lenient(
1386 config: IbkrConfig,
1387 ) -> Result<ConnectOutcome, UnindexedClientError> {
1388 Self::connect_inner(config)
1389 }
1390
1391 /// Connect, then register each configured contract, collecting the ones that fail.
1392 fn connect_inner(config: IbkrConfig) -> Result<ConnectOutcome, UnindexedClientError> {
1393 let url = format!("{}:{}", config.host, config.port);
1394 info!(url = %url, client_id = config.client_id, "Connecting to IB");
1395
1396 let client = Client::connect(&url, config.client_id).map_err(|e| {
1397 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1398 })?;
1399
1400 let next_id = client.next_order_id();
1401
1402 let contracts = ContractRegistry::new();
1403 let skipped = connect::register_configured(&config.contracts, &contracts, |contract| {
1404 resolve_contract_blocking(&client, contract)
1405 });
1406
1407 info!(
1408 contracts = contracts.len(),
1409 skipped = skipped.len(),
1410 next_order_id = next_id,
1411 "Connected to IB"
1412 );
1413
1414 let client = Self {
1415 config: Arc::new(config),
1416 client: Arc::new(client),
1417 contracts,
1418 order_ids: OrderIdMap::new(),
1419 pending_cancels: PendingCancels::new(),
1420 execution_buffer: ExecutionBuffer::new(),
1421 next_order_id: Arc::new(Mutex::new(next_id)),
1422 known_live: KnownLiveOrders::shared(ExchangeId::Ibkr),
1423 listings: ListingLock::default(),
1424 earlier_completions: ended_orders::EarlierCompletions::new(),
1425 };
1426 Ok(ConnectOutcome { client, skipped })
1427 }
1428
1429 /// Which API clients' executions this client reports.
1430 fn execution_scope(&self) -> ExecutionScope {
1431 ExecutionScope {
1432 api_client_id: self.client.client_id(),
1433 other_clients: self.config.other_clients_fills,
1434 }
1435 }
1436
1437 /// Get the next order ID and increment the counter.
1438 fn allocate_order_id(&self) -> i32 {
1439 self.allocate_order_id_range(1)
1440 }
1441
1442 /// Allocate a contiguous range of order IDs atomically.
1443 ///
1444 /// Returns the first ID in the range. Caller uses `base`, `base+1`, ..., `base+(count-1)`.
1445 ///
1446 /// This is essential for bracket orders which require consecutive IDs (parent=N,
1447 /// take_profit=N+1, stop_loss=N+2). A single lock acquisition ensures no other
1448 /// concurrent `open_order` call can grab an ID in the middle of the range.
1449 ///
1450 /// # Panics
1451 ///
1452 /// Panics if `count` exceeds `i32::MAX`, or if the resulting range would overflow `i32::MAX`.
1453 #[allow(clippy::expect_used)] // Panic is correct: i32::MAX orders means system is broken
1454 fn allocate_order_id_range(&self, count: u32) -> i32 {
1455 let count_i32: i32 = count.try_into().expect("count exceeds i32::MAX");
1456 let mut id = self.next_order_id.lock();
1457 let base = *id;
1458 *id = id
1459 .checked_add(count_i32)
1460 .expect("order ID overflow: i32::MAX exceeded");
1461 base
1462 }
1463
1464 /// Resolve a contract description through IB's contract details, on this client's
1465 /// connection, into the one IB contract it matches.
1466 ///
1467 /// A contract built locally, such as `stock_contract("AAPL", "SMART", "USD")`, has no IB
1468 /// contract id until it is resolved, and [`register_contract`](Self::register_contract)
1469 /// refuses it. Resolve it here first:
1470 ///
1471 /// ```no_run
1472 /// # async fn example(client: rustrade_execution::client::ibkr::IbkrClient)
1473 /// # -> Result<(), Box<dyn std::error::Error>> {
1474 /// use rustrade_execution::client::ibkr::contract::stock_contract;
1475 ///
1476 /// let aapl = client
1477 /// .resolve_contract(&stock_contract("AAPL", "SMART", "USD"))
1478 /// .await?;
1479 /// client.register_contract("AAPL".into(), aapl)?;
1480 /// # Ok(())
1481 /// # }
1482 /// ```
1483 ///
1484 /// # Errors
1485 ///
1486 /// [`ResolveContractError::is_transient`] tells the failures worth retrying from the rest.
1487 ///
1488 /// - [`ResolveContractError::NoMatch`] when no IB contract matches the description.
1489 /// - [`ResolveContractError::Ambiguous`] when several do. Narrow the description, or pick
1490 /// one of the matches it carries.
1491 /// - [`ResolveContractError::Refused`] when IB answers the request with another error, such
1492 /// as a description it cannot validate. The same request is usually refused again; tell the
1493 /// errors worth retrying apart by IB's `code`.
1494 /// - [`ResolveContractError::Connectivity`] when the connection drops
1495 /// ([`ConnectivityError::Socket`]) or IB sends nothing for 10 s before finishing its answer
1496 /// ([`ConnectivityError::Timeout`]). Transient: retry once `ibapi` has reconnected.
1497 /// - [`ResolveContractError::Failed`] when the request cannot be made or read for any other
1498 /// reason, such as a client that has shut down or given up reconnecting.
1499 pub async fn resolve_contract(
1500 &self,
1501 contract: &ibapi::contracts::Contract,
1502 ) -> Result<ibapi::contracts::Contract, ResolveContractError> {
1503 let client = Arc::clone(&self.client);
1504 let contract = contract.clone();
1505 tokio::task::spawn_blocking(move || resolve_contract_blocking(&client, &contract))
1506 .await
1507 .map_err(|e| ResolveContractError::Failed(format!("task join: {e}")))?
1508 }
1509
1510 /// Register a contract IB has resolved for an instrument, so that orders can be placed on
1511 /// it and everything IB reports for it (fills, order listings, positions) is attributed to
1512 /// `name`.
1513 ///
1514 /// Resolve the contract first with [`resolve_contract`](Self::resolve_contract), or list it
1515 /// in [`IbkrConfig::contracts`] for [`connect_sync`](Self::connect_sync) to resolve.
1516 ///
1517 /// # Errors
1518 ///
1519 /// Leaves the registry unchanged and returns:
1520 /// - [`ContractRegistryError::Unresolved`] when the contract has no IB contract id. IB
1521 /// reports everything about a contract by that id, so without it the orders would fill
1522 /// with no fill ever reported.
1523 /// - [`ContractRegistryError::ContractIdTaken`] when the contract is already registered
1524 /// under another name.
1525 pub fn register_contract(
1526 &self,
1527 name: InstrumentNameExchange,
1528 contract: ibapi::contracts::Contract,
1529 ) -> Result<(), ContractRegistryError> {
1530 self.contracts.register(name, contract)
1531 }
1532
1533 /// Get the contract registry.
1534 pub fn contract_registry(&self) -> &ContractRegistry {
1535 &self.contracts
1536 }
1537
1538 /// Get the number of pending executions awaiting commission reports.
1539 ///
1540 /// Useful for monitoring whether commission reports are being received.
1541 /// A growing count may indicate IB connection issues or delayed reports.
1542 pub fn pending_execution_count(&self) -> usize {
1543 self.execution_buffer.pending_count()
1544 }
1545
1546 /// Clear stale executions older than the given duration.
1547 ///
1548 /// Returns the number of cleared entries.
1549 ///
1550 /// Call this periodically to prevent unbounded memory growth if commission
1551 /// reports are delayed or lost. A reasonable interval is 5-10 minutes with
1552 /// a max_age of 1 hour.
1553 pub fn clear_stale_executions(&self, max_age: std::time::Duration) -> usize {
1554 self.execution_buffer.clear_stale(max_age)
1555 }
1556
1557 /// Clear order ID mappings older than the given duration.
1558 ///
1559 /// Returns the number of cleared entries.
1560 ///
1561 /// # Why This Is Needed
1562 ///
1563 /// IB does not guarantee event ordering between `OrderStatus("Filled")` and
1564 /// `ExecutionData`/`CommissionReport`. For fast-filling orders (especially
1565 /// market orders), execution data may arrive AFTER the filled status — or
1566 /// the filled status may not arrive at all. So a `Filled` status frees the
1567 /// order's client order id but keeps its IB order ID's entry, and an order
1568 /// whose terminal status never arrives keeps both: its id cannot name a new
1569 /// order (see `open_order`'s "Client order ids") until this clears it.
1570 ///
1571 /// Call this periodically alongside `clear_stale_executions()`. A reasonable
1572 /// interval is 5-10 minutes with a max_age of 1 hour. An order still working
1573 /// past `max_age` is cleared too, and its id freed.
1574 pub fn clear_stale_order_ids(&self, max_age: std::time::Duration) -> usize {
1575 self.order_ids.clear_stale(max_age)
1576 }
1577
1578 /// Clear pending cancel entries older than the given duration.
1579 ///
1580 /// Returns the number of cleared entries.
1581 ///
1582 /// Pending cancels are tracked to differentiate user-initiated cancellation
1583 /// from time-based expiration. If a cancel request is submitted but the order
1584 /// never receives terminal status (e.g., network disconnect), the entry would
1585 /// remain indefinitely. Call this alongside other stale cleanup methods.
1586 pub fn clear_stale_pending_cancels(&self, max_age: std::time::Duration) -> usize {
1587 self.pending_cancels.clear_stale(max_age)
1588 }
1589
1590 /// Disconnect from IB Gateway.
1591 ///
1592 /// Shuts the `ibapi` client down for every clone sharing it: requests in flight fail, any
1593 /// active `account_stream()` ends with `StreamTerminated`, and later requests are refused.
1594 ///
1595 /// # Client ID release
1596 ///
1597 /// This closes the TCP connection, even while other clones of this client ([`IbkrClient`]
1598 /// implements [`Clone`] and shares one connection) or their subscriptions are still alive.
1599 /// TWS/Gateway frees the API client ID once it has processed the close, which can take a
1600 /// moment: [`connect_sync`](Self::connect_sync) under the same ID right after this call may be
1601 /// refused with IB error 326 ("client id is already in use"), and succeeds on a retry. The
1602 /// clones need not be dropped first. A clone that outlives the call is shut down, not
1603 /// reconnected.
1604 ///
1605 /// This is idempotent — calling it multiple times is safe.
1606 pub fn disconnect(&self) {
1607 debug!("Disconnecting IbkrClient");
1608 self.client.disconnect();
1609 }
1610
1611 /// Place a bracket order (entry + take-profit + stop-loss) with OCA linking.
1612 ///
1613 /// A bracket order consists of three linked orders:
1614 /// 1. **Entry**: Limit order to enter the position
1615 /// 2. **Take Profit**: Limit order to exit at profit target (OCA-linked to SL)
1616 /// 3. **Stop Loss**: Stop order to exit at loss limit (OCA-linked to TP)
1617 ///
1618 /// # OCA (One-Cancels-All) Behavior
1619 ///
1620 /// When either exit order fills, IB automatically cancels the other. This
1621 /// prevents the dangerous scenario where take-profit fills but stop-loss
1622 /// remains open, potentially opening an unintended opposing position.
1623 ///
1624 /// # Failure Semantics
1625 ///
1626 /// If any leg is rejected by IB (e.g., insufficient margin, invalid price),
1627 /// or cannot be sent, this method cancels every leg already sent and
1628 /// returns the legs as `Inactive`, except for legs of unknown fate (below).
1629 ///
1630 /// The same all-legs cancellation applies if any leg fails to report a
1631 /// status within the placement timeout, or the transport drops while
1632 /// waiting (an unknown/no-status outcome): leaving part of a bracket
1633 /// working while the rest is in an unknown state is unsafe, so every leg is
1634 /// cancelled and an error is returned.
1635 ///
1636 /// If any leg's client order id (the parent's, or the `_tp`/`_sl` id
1637 /// derived from it) is held by a live order, nothing is sent and every leg
1638 /// comes back `Inactive` with [`ApiError::DuplicateClientOrderId`]; the live
1639 /// order is unaffected. See `ExecutionClient::open_order`'s "Client order
1640 /// ids" for when an id is held.
1641 ///
1642 /// # Legs of Unknown Fate
1643 ///
1644 /// Cancelling does not settle a leg whose fate is unknown: a leg that
1645 /// reported no status, or whose rollback cancel could not be sent (ibapi
1646 /// refuses sends while its transport is down), may still be live or held
1647 /// at TWS. Such a leg comes back `Open` with zero fill and keeps its
1648 /// order-id mapping, as a no-status single order does, so the account
1649 /// stream can report how it ends. The other legs come back `Inactive` with
1650 /// the error. A leg IB rejected is settled even if its cancel was refused.
1651 /// So a failed bracket can return a mix of `Open` and `Inactive` legs, and
1652 /// every leg can be `Open` when none reported a status: an all-`Open`
1653 /// result is then indistinguishable from success, and the failure shows
1654 /// only in the log and on the account stream. Treat an `Open` leg as
1655 /// working until the account stream or
1656 /// [`ExecutionClient::fetch_open_orders`] says otherwise.
1657 ///
1658 /// # Cancellation Safety
1659 ///
1660 /// This future registers order ID mappings before submitting to IB. If
1661 /// cancelled mid-flight, orders may still be submitted and mappings will
1662 /// leak. Avoid cancelling this future; use IB's native order timeout via
1663 /// `TimeInForce` if timeout behavior is needed.
1664 ///
1665 /// # Example
1666 ///
1667 /// ```ignore
1668 /// let request = BracketOrderRequest {
1669 /// instrument: "AAPL".into(),
1670 /// strategy: StrategyId::new("my-strategy"),
1671 /// parent_cid: ClientOrderId::new("bracket-001"),
1672 /// side: Side::Buy,
1673 /// quantity: dec!(100),
1674 /// entry_price: dec!(150.00),
1675 /// take_profit_price: dec!(160.00), // +$10 profit target
1676 /// stop_loss_price: dec!(145.00), // -$5 stop loss
1677 /// time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1678 /// };
1679 /// let result = client.open_bracket_order(request).await;
1680 /// ```
1681 pub async fn open_bracket_order(&self, request: BracketOrderRequest) -> BracketOrderResult {
1682 let instrument = request.instrument.clone();
1683
1684 // Look up contract
1685 let contract = match self.contracts.get_contract(&instrument) {
1686 Some(c) => c,
1687 None => {
1688 return make_all_inactive_bracket(
1689 &request,
1690 OrderError::Rejected(ApiError::InstrumentInvalid(
1691 instrument,
1692 "contract not registered".to_string(),
1693 )),
1694 );
1695 }
1696 };
1697
1698 // Convert quantity to f64
1699 let quantity: f64 = match request.quantity.try_into() {
1700 Ok(q) => q,
1701 Err(_) => {
1702 return make_all_inactive_bracket(
1703 &request,
1704 OrderError::Rejected(ApiError::OrderRejected(format!(
1705 "quantity {} exceeds f64 range",
1706 request.quantity
1707 ))),
1708 );
1709 }
1710 };
1711
1712 // Convert prices to f64
1713 let entry_price: f64 = match request.entry_price.try_into() {
1714 Ok(p) => p,
1715 Err(_) => {
1716 return make_all_inactive_bracket(
1717 &request,
1718 OrderError::Rejected(ApiError::OrderRejected(format!(
1719 "entry_price {} exceeds f64 range",
1720 request.entry_price
1721 ))),
1722 );
1723 }
1724 };
1725 let tp_price: f64 = match request.take_profit_price.try_into() {
1726 Ok(p) => p,
1727 Err(_) => {
1728 return make_all_inactive_bracket(
1729 &request,
1730 OrderError::Rejected(ApiError::OrderRejected(format!(
1731 "take_profit_price {} exceeds f64 range",
1732 request.take_profit_price
1733 ))),
1734 );
1735 }
1736 };
1737 let sl_price: f64 = match request.stop_loss_price.try_into() {
1738 Ok(p) => p,
1739 Err(_) => {
1740 return make_all_inactive_bracket(
1741 &request,
1742 OrderError::Rejected(ApiError::OrderRejected(format!(
1743 "stop_loss_price {} exceeds f64 range",
1744 request.stop_loss_price
1745 ))),
1746 );
1747 }
1748 };
1749
1750 // Validate time_in_force and convert to IB wire format
1751 let ib_tif = match time_in_force_to_ib(&request.time_in_force) {
1752 Ok(tif) => tif,
1753 Err(e) => {
1754 return make_all_inactive_bracket(
1755 &request,
1756 OrderError::Rejected(ApiError::OrderRejected(format!(
1757 "TIF not supported by IB: {e}"
1758 ))),
1759 );
1760 }
1761 };
1762
1763 // Allocate 3 consecutive order IDs atomically
1764 let parent_ib_id = self.allocate_order_id_range(3);
1765 let tp_ib_id = parent_ib_id + 1;
1766 let sl_ib_id = parent_ib_id + 2;
1767
1768 // Build bracket orders with OCA linking
1769 let action = side_to_action(request.side);
1770 // Nothing has been sent yet, so on an error the three allocated IDs are simply skipped.
1771 let mut ib_orders = match build_ib_bracket_with_oca(
1772 parent_ib_id,
1773 action,
1774 quantity,
1775 entry_price,
1776 tp_price,
1777 sl_price,
1778 ib_tif,
1779 ) {
1780 Ok(orders) => orders,
1781 Err(e) => {
1782 return make_all_inactive_bracket(
1783 &request,
1784 OrderError::Rejected(ApiError::OrderRejected(e.to_string())),
1785 );
1786 }
1787 };
1788
1789 // Generate client order IDs for children
1790 let parent_cid = request.parent_cid.clone();
1791 let (tp_cid, sl_cid) = derive_child_cids(&parent_cid);
1792 // Each leg carries its own client order id as its order reference.
1793 for (ib_order, cid) in ib_orders.iter_mut().zip([&parent_cid, &tp_cid, &sl_cid]) {
1794 match order::order_ref(cid) {
1795 Ok(order_ref) => ib_order.order_ref = order_ref,
1796 Err(e) => {
1797 return make_all_inactive_bracket(
1798 &request,
1799 OrderError::Rejected(ApiError::OrderRejected(e.to_string())),
1800 );
1801 }
1802 }
1803 }
1804
1805 // Determine opposite side for exit orders
1806 let exit_side = match request.side {
1807 Side::Buy => Side::Sell,
1808 Side::Sell => Side::Buy,
1809 };
1810
1811 // Register order contexts for all three legs
1812 let parent_ctx = OrderContext {
1813 instrument: instrument.clone(),
1814 side: request.side,
1815 price: Some(request.entry_price),
1816 quantity: request.quantity,
1817 kind: OrderKind::Limit,
1818 time_in_force: request.time_in_force,
1819 };
1820 let tp_ctx = OrderContext {
1821 instrument: instrument.clone(),
1822 side: exit_side,
1823 price: Some(request.take_profit_price),
1824 quantity: request.quantity,
1825 kind: OrderKind::Limit,
1826 time_in_force: request.time_in_force,
1827 };
1828 let sl_ctx = OrderContext {
1829 instrument: instrument.clone(),
1830 side: exit_side,
1831 price: None, // Stop orders don't have a limit price
1832 quantity: request.quantity,
1833 kind: OrderKind::Stop {
1834 trigger_price: request.stop_loss_price,
1835 },
1836 time_in_force: request.time_in_force,
1837 };
1838
1839 // All three or none, before anything is sent: a leg under an id a live order holds would
1840 // take that order's mapping. On a refusal the three allocated IDs are simply skipped.
1841 if let Err(in_use) = self.order_ids.register_all([
1842 (parent_cid.clone(), parent_ib_id, parent_ctx),
1843 (tp_cid.clone(), tp_ib_id, tp_ctx),
1844 (sl_cid.clone(), sl_ib_id, sl_ctx),
1845 ]) {
1846 return make_all_inactive_bracket(
1847 &request,
1848 OrderError::Rejected(ApiError::DuplicateClientOrderId(in_use.to_string())),
1849 );
1850 }
1851 {
1852 let mut known = self.known_live.lock();
1853 for cid in [&parent_cid, &tp_cid, &sl_cid] {
1854 known.placing(cid);
1855 }
1856 }
1857
1858 // Place all three orders in spawn_blocking
1859 let client = self.client.clone();
1860 let result = tokio::task::spawn_blocking(move || {
1861 // Roll back legs already sent. Returns the ids whose cancel could not
1862 // be sent, and a suffix for the error message naming them. A refused
1863 // cancel must not vanish: ibapi refuses sends while the transport is
1864 // down, and the leg it names may then be live at TWS.
1865 let rollback = |ids: &[i32]| -> (Vec<i32>, String) {
1866 let failed: Vec<(i32, ibapi::Error)> = ids
1867 .iter()
1868 .filter_map(|&id| {
1869 let e = client.cancel_order(id, "").err()?;
1870 error!(order_id = id, error = %e, "bracket rollback cancel failed; leg may be live");
1871 Some((id, e))
1872 })
1873 .collect();
1874 if failed.is_empty() {
1875 return (Vec::new(), String::new());
1876 }
1877 let named: Vec<String> = failed.iter().map(|(id, e)| format!("{id} ({e})")).collect();
1878 let suffix = format!(
1879 "; rollback cancel failed for order ids {}, which may be live",
1880 named.join(", ")
1881 );
1882 (failed.into_iter().map(|(id, _)| id).collect(), suffix)
1883 };
1884
1885 // A later leg's send failed after the legs in `sent` were written:
1886 // roll them back. A leg whose cancel was refused may be held at TWS,
1887 // so it is unresolved. The failing leg itself is treated as never
1888 // having reached TWS, as in `send_error`.
1889 let leg_send_failure = |e: &ibapi::Error, message: String, sent: &[i32]| {
1890 let (cancel_failed, rollback) = rollback(sent);
1891 BracketFailure {
1892 error: leg_send_error(e, message, &rollback),
1893 unresolved: cancel_failed,
1894 }
1895 };
1896
1897 // Place parent (transmit=false, held until SL is sent)
1898 let parent_sub = match client.place_order(parent_ib_id, &contract, &ib_orders[0]) {
1899 Ok(s) => s,
1900 Err(e) => {
1901 return Err(BracketFailure {
1902 error: send_error(&e, format!("parent order failed: {e}")),
1903 unresolved: Vec::new(),
1904 });
1905 }
1906 };
1907
1908 // Place take-profit (transmit=false)
1909 let tp_sub = match client.place_order(tp_ib_id, &contract, &ib_orders[1]) {
1910 Ok(s) => s,
1911 Err(e) => {
1912 return Err(leg_send_failure(
1913 &e,
1914 format!("take_profit order failed: {e}"),
1915 &[parent_ib_id],
1916 ));
1917 }
1918 };
1919
1920 // Place stop-loss (transmit=true, triggers all)
1921 let sl_sub = match client.place_order(sl_ib_id, &contract, &ib_orders[2]) {
1922 Ok(s) => s,
1923 Err(e) => {
1924 return Err(leg_send_failure(
1925 &e,
1926 format!("stop_loss order failed: {e}"),
1927 &[parent_ib_id, tp_ib_id],
1928 ));
1929 }
1930 };
1931
1932 // Await the first status from each subscription. A held leg
1933 // (informational notice, e.g. 399 or 404) is treated as live with an
1934 // unknown fill (0.0) — the order is working, not rejected. `None`
1935 // means the subscription closed/timed out without a terminal status.
1936 let leg_status = |outcome: PlacementOutcome, leg: &str| match outcome {
1937 PlacementOutcome::Accepted { filled } => Some(Ok(filled)),
1938 PlacementOutcome::HeldPending(notice) => {
1939 warn!(leg, %notice, "bracket leg held/pending; tracking via stream");
1940 Some(Ok(0.0))
1941 }
1942 PlacementOutcome::Rejected(reason) => Some(Err(reason)),
1943 PlacementOutcome::NoStatus => None,
1944 };
1945
1946 let parent_status = leg_status(
1947 await_order_placement(parent_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
1948 "parent",
1949 );
1950 let tp_status = leg_status(
1951 await_order_placement(tp_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
1952 "take_profit",
1953 );
1954 let sl_status = leg_status(
1955 await_order_placement(sl_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
1956 "stop_loss",
1957 );
1958
1959 // Verify all three legs reported a successful status. A `None` here
1960 // means the subscription closed without producing a recognised event
1961 // — surface that as an error rather than silently treating it as
1962 // success. Any error or missing status cancels all legs.
1963 match (parent_status, tp_status, sl_status) {
1964 (Some(Ok(parent)), Some(Ok(tp)), Some(Ok(sl))) => Ok((parent, tp, sl)),
1965 (parent, tp, sl) => {
1966 let (cancel_failed, rollback) = rollback(&[parent_ib_id, tp_ib_id, sl_ib_id]);
1967 let unresolved = unresolved_legs(
1968 [(parent_ib_id, &parent), (tp_ib_id, &tp), (sl_ib_id, &sl)],
1969 &cancel_failed,
1970 );
1971 let show = |status: Option<Result<f64, String>>| match status {
1972 Some(status) => format!("{status:?}"),
1973 None => "no status (timed out or transport lost)".to_string(),
1974 };
1975 Err(BracketFailure {
1976 error: OrderError::Rejected(ApiError::OrderRejected(format!(
1977 "bracket order failed: parent={}, tp={}, sl={}{rollback}",
1978 show(parent),
1979 show(tp),
1980 show(sl)
1981 ))),
1982 unresolved,
1983 })
1984 }
1985 }
1986 })
1987 .await;
1988
1989 // Recorded only once sent, as for a single order.
1990 let result = match result {
1991 Ok(Ok((parent_filled, tp_filled, sl_filled))) => {
1992 let now = Utc::now();
1993 let parent_filled_dec = parse_decimal_or_warn(parent_filled, "parent.filled");
1994 let tp_filled_dec = parse_decimal_or_warn(tp_filled, "tp.filled");
1995 let sl_filled_dec = parse_decimal_or_warn(sl_filled, "sl.filled");
1996
1997 BracketOrderResult {
1998 parent: Order {
1999 key: OrderKey {
2000 exchange: ExchangeId::Ibkr,
2001 instrument: instrument.clone(),
2002 strategy: request.strategy.clone(),
2003 cid: parent_cid,
2004 },
2005 side: request.side,
2006 price: Some(request.entry_price),
2007 quantity: request.quantity,
2008 kind: OrderKind::Limit,
2009 time_in_force: request.time_in_force,
2010 state: OrderState::active(Open::new(
2011 VenueOrderId::Assigned(OrderId::new(format_smolstr!(
2012 "{}",
2013 parent_ib_id
2014 ))),
2015 now,
2016 parent_filled_dec,
2017 )),
2018 },
2019 take_profit: Order {
2020 key: OrderKey {
2021 exchange: ExchangeId::Ibkr,
2022 instrument: instrument.clone(),
2023 strategy: request.strategy.clone(),
2024 cid: tp_cid,
2025 },
2026 side: exit_side,
2027 price: Some(request.take_profit_price),
2028 quantity: request.quantity,
2029 kind: OrderKind::Limit,
2030 time_in_force: request.time_in_force,
2031 state: OrderState::active(Open::new(
2032 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", tp_ib_id))),
2033 now,
2034 tp_filled_dec,
2035 )),
2036 },
2037 stop_loss: Order {
2038 key: OrderKey {
2039 exchange: ExchangeId::Ibkr,
2040 instrument: instrument.clone(),
2041 strategy: request.strategy.clone(),
2042 cid: sl_cid,
2043 },
2044 side: exit_side,
2045 price: None, // Stop orders don't have a limit price
2046 quantity: request.quantity,
2047 kind: OrderKind::Stop {
2048 trigger_price: request.stop_loss_price,
2049 },
2050 time_in_force: request.time_in_force,
2051 state: OrderState::active(Open::new(
2052 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", sl_ib_id))),
2053 now,
2054 sl_filled_dec,
2055 )),
2056 },
2057 }
2058 }
2059 Ok(Err(failure)) => failed_bracket(
2060 &request,
2061 [parent_ib_id, tp_ib_id, sl_ib_id],
2062 failure,
2063 &self.order_ids,
2064 ),
2065 Err(join_err) => {
2066 // The placement task panicked, and which legs it sent was lost
2067 // with it. Unlike `failed_bracket`, all three are released: a
2068 // leg it did send is then untracked, but keeping unsent legs
2069 // Open would report orders that do not exist.
2070 self.order_ids.remove_by_ib_id(parent_ib_id);
2071 self.order_ids.remove_by_ib_id(tp_ib_id);
2072 self.order_ids.remove_by_ib_id(sl_ib_id);
2073
2074 make_all_inactive_bracket(
2075 &request,
2076 OrderError::Rejected(ApiError::OrderRejected(format!(
2077 "task join error: {join_err}"
2078 ))),
2079 )
2080 }
2081 };
2082 let mut known = self.known_live.lock();
2083 for leg in [&result.parent, &result.take_profit, &result.stop_loss] {
2084 known.placed(&leg.key, leg.quantity, leg.kind, &leg.state);
2085 }
2086 drop(known);
2087 result
2088 }
2089}
2090
2091/// Derive bracket child CIDs from the parent CID.
2092///
2093/// Convention: `{parent}_tp` for take-profit, `{parent}_sl` for stop-loss.
2094/// All bracket call sites must use this helper to keep the naming consistent.
2095fn derive_child_cids(parent_cid: &ClientOrderId) -> (ClientOrderId, ClientOrderId) {
2096 (
2097 ClientOrderId::new(format_smolstr!("{}_tp", parent_cid.0)),
2098 ClientOrderId::new(format_smolstr!("{}_sl", parent_cid.0)),
2099 )
2100}
2101
2102/// A failed bracket placement: the error each settled leg carries, and the legs
2103/// whose fate at TWS is unknown.
2104struct BracketFailure {
2105 error: OrderError<AssetNameExchange, InstrumentNameExchange>,
2106 /// IB order ids of legs that may be live at TWS: no placement status
2107 /// arrived for them, or their rollback cancel could not be sent.
2108 unresolved: Vec<i32>,
2109}
2110
2111/// The legs of a bracket that failed after all three were sent whose fate at
2112/// TWS is unknown: those without a placement status (`None`), and those that
2113/// were accepted but whose rollback cancel could not be sent.
2114///
2115/// A rejected leg (`Some(Err)`) is settled whether or not its cancel went out:
2116/// its terminal status was consumed by the placement subscription and will not
2117/// be replayed, so keeping it `Open` would leave a phantom order. An accepted
2118/// leg whose cancel went out is settled as being cancelled.
2119fn unresolved_legs<T, E>(
2120 legs: [(i32, &Option<Result<T, E>>); 3],
2121 cancel_failed: &[i32],
2122) -> Vec<i32> {
2123 legs.into_iter()
2124 .filter(|(id, status)| match status {
2125 None => true,
2126 Some(Ok(_)) => cancel_failed.contains(id),
2127 Some(Err(_)) => false,
2128 })
2129 .map(|(id, _)| id)
2130 .collect()
2131}
2132
2133/// Classify a later bracket leg's send failure, after the legs sent before it
2134/// were rolled back. `rollback` is the suffix naming the cancels that could
2135/// not be sent, empty when every cancel went out.
2136///
2137/// Retrying is only safe when every rollback cancel went out, so only then does
2138/// the failure keep [`send_error`]'s classification. Otherwise a leg may still
2139/// be held at TWS, and the failure is a non-transient rejection naming it.
2140fn leg_send_error(
2141 e: &ibapi::Error,
2142 message: String,
2143 rollback: &str,
2144) -> OrderError<AssetNameExchange, InstrumentNameExchange> {
2145 if rollback.is_empty() {
2146 send_error(e, message)
2147 } else {
2148 OrderError::Rejected(ApiError::OrderRejected(format!("{message}{rollback}")))
2149 }
2150}
2151
2152/// Build the result of a failed bracket placement, releasing the order-id
2153/// mappings of its settled legs.
2154///
2155/// A leg in `failure.unresolved` comes back `Open` with zero fill and keeps its
2156/// mapping, as single-order placement does for an unknown outcome, so the
2157/// account stream can still resolve it. Every other leg was never sent, was
2158/// rejected, or had its cancel sent: it comes back `Inactive` with
2159/// `failure.error`, and its mapping is removed.
2160fn failed_bracket(
2161 request: &BracketOrderRequest,
2162 [parent_ib_id, tp_ib_id, sl_ib_id]: [i32; 3],
2163 failure: BracketFailure,
2164 order_ids: &OrderIdMap,
2165) -> BracketOrderResult {
2166 let BracketFailure { error, unresolved } = failure;
2167 if !unresolved.is_empty() {
2168 warn!(
2169 %error,
2170 ?unresolved,
2171 "bracket placement failed with legs of unknown fate; returning them Open, tracking via stream"
2172 );
2173 }
2174
2175 let mut result = make_all_inactive_bracket(request, error);
2176 let now = Utc::now();
2177 for (leg, ib_id) in [
2178 (&mut result.parent, parent_ib_id),
2179 (&mut result.take_profit, tp_ib_id),
2180 (&mut result.stop_loss, sl_ib_id),
2181 ] {
2182 if unresolved.contains(&ib_id) {
2183 leg.state = OrderState::active(Open::new(
2184 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{ib_id}"))),
2185 now,
2186 Decimal::ZERO,
2187 ));
2188 } else {
2189 order_ids.remove_by_ib_id(ib_id);
2190 }
2191 }
2192 result
2193}
2194
2195/// Helper to create a BracketOrderResult with all legs inactive (same error).
2196fn make_all_inactive_bracket(
2197 request: &BracketOrderRequest,
2198 error: OrderError<AssetNameExchange, InstrumentNameExchange>,
2199) -> BracketOrderResult {
2200 let exit_side = match request.side {
2201 Side::Buy => Side::Sell,
2202 Side::Sell => Side::Buy,
2203 };
2204
2205 let parent_cid = request.parent_cid.clone();
2206 let (tp_cid, sl_cid) = derive_child_cids(&parent_cid);
2207
2208 BracketOrderResult {
2209 parent: Order {
2210 key: OrderKey {
2211 exchange: ExchangeId::Ibkr,
2212 instrument: request.instrument.clone(),
2213 strategy: request.strategy.clone(),
2214 cid: parent_cid,
2215 },
2216 side: request.side,
2217 price: Some(request.entry_price),
2218 quantity: request.quantity,
2219 kind: OrderKind::Limit,
2220 time_in_force: request.time_in_force,
2221 state: OrderState::inactive(error.clone()),
2222 },
2223 take_profit: Order {
2224 key: OrderKey {
2225 exchange: ExchangeId::Ibkr,
2226 instrument: request.instrument.clone(),
2227 strategy: request.strategy.clone(),
2228 cid: tp_cid,
2229 },
2230 side: exit_side,
2231 price: Some(request.take_profit_price),
2232 quantity: request.quantity,
2233 kind: OrderKind::Limit,
2234 time_in_force: request.time_in_force,
2235 state: OrderState::inactive(error.clone()),
2236 },
2237 stop_loss: Order {
2238 key: OrderKey {
2239 exchange: ExchangeId::Ibkr,
2240 instrument: request.instrument.clone(),
2241 strategy: request.strategy.clone(),
2242 cid: sl_cid,
2243 },
2244 side: exit_side,
2245 price: None, // Stop orders don't have a limit price
2246 quantity: request.quantity,
2247 kind: OrderKind::Stop {
2248 trigger_price: request.stop_loss_price,
2249 },
2250 time_in_force: request.time_in_force,
2251 state: OrderState::inactive(error),
2252 },
2253 }
2254}
2255
2256impl ExecutionClient for IbkrClient {
2257 const EXCHANGE: ExchangeId = ExchangeId::Ibkr;
2258
2259 // Mirrors the security types `ContractConfig::to_contract` can build: `STK` and `CASH` (forex)
2260 // are both outright holdings and so map to `Spot`, `FUT` to `Future`, `OPT` to `Option`.
2261 //
2262 // `Cfd` is absent deliberately. IBKR does offer CFDs, but this client builds no `SecurityType`
2263 // for them, so a CFD instrument would fail `to_contract` with `UnrecognizedSecurityType` at
2264 // registration rather than trade.
2265 //
2266 // Scope: this declares which `InstrumentKind`s the client can represent AT ALL. Whether each
2267 // `ContractConfig` describes the instrument it is keyed to is `validate_config`'s check.
2268 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
2269 InstrumentKindDiscriminant::Spot,
2270 InstrumentKindDiscriminant::Future,
2271 InstrumentKindDiscriminant::Option,
2272 ];
2273
2274 type Config = IbkrConfig;
2275 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
2276
2277 /// Rejects a `ContractConfig` that [`connect_sync`](Self::connect_sync) would refuse as invalid,
2278 /// or whose `security_type` contradicts the kind of the instrument it is keyed to by `name`:
2279 /// `STK` or `CASH` for a `Spot`, `FUT` for a `Future`, `OPT` for an `Option`. Every problem
2280 /// found is reported, not just the first.
2281 ///
2282 /// `Spot` covers both a stock and a currency pair, so `STK` and `CASH` cannot be told apart
2283 /// here: either is accepted for any `Spot` instrument.
2284 ///
2285 /// An entry keyed to no instrument on this exchange, and an instrument with no entry, are both
2286 /// accepted: a contract can also be registered later with
2287 /// [`register_contract`](Self::register_contract), which this cannot see.
2288 fn validate_config(
2289 config: &Self::Config,
2290 instruments: &[ClientInstrument<'_>],
2291 ) -> Result<(), String> {
2292 let problems = config
2293 .contracts
2294 .iter()
2295 .filter_map(|contract| {
2296 if let Err(error) = contract.to_contract() {
2297 return Some(format!("contract {:?}: {error}", contract.name));
2298 }
2299 let instrument = instruments
2300 .iter()
2301 .find(|instrument| instrument.name_exchange.as_ref() == contract.name)?;
2302 let expected = security_types_of(instrument.kind);
2303 if expected.is_empty() {
2304 // Unreachable through `ExecutionBuilder`, which checks `SUPPORTED_KINDS` first,
2305 // but a direct caller can pass any kind.
2306 return Some(format!(
2307 "contract {:?} is for an instrument of kind {}, which this client cannot \
2308 trade",
2309 contract.name, instrument.kind,
2310 ));
2311 }
2312 (!expected.contains(&contract.security_type.as_str())).then(|| {
2313 format!(
2314 "contract {:?} names security type {:?}, but its instrument's kind is \
2315 {}, which takes {}",
2316 contract.name,
2317 contract.security_type,
2318 instrument.kind,
2319 expected.join(" or "),
2320 )
2321 })
2322 })
2323 .collect::<Vec<_>>();
2324
2325 if problems.is_empty() {
2326 Ok(())
2327 } else {
2328 Err(problems.join("; "))
2329 }
2330 }
2331
2332 /// Create a new IBKR client by connecting to TWS/Gateway.
2333 ///
2334 /// # Blocking
2335 ///
2336 /// This method performs blocking TCP I/O and IB API handshake. If called
2337 /// from an async context, wrap in `tokio::task::spawn_blocking` or call
2338 /// from a dedicated thread.
2339 ///
2340 /// # Panics
2341 ///
2342 /// Panics if [`IbkrClient::connect_sync`] fails: when TWS/Gateway cannot be reached or
2343 /// refuses the connection, or when any contract in [`IbkrConfig::contracts`] cannot be
2344 /// registered. The `ExecutionClient` trait doesn't allow `new()` to return `Result`. Call
2345 /// [`IbkrClient::connect_sync`] or [`IbkrClient::connect_sync_lenient`] directly for
2346 /// fallible construction.
2347 #[track_caller]
2348 fn new(config: Self::Config) -> Self {
2349 #[allow(clippy::expect_used)] // Trait signature doesn't allow Result
2350 Self::connect_sync(config)
2351 .expect("failed to connect to IB or to register its configured contracts")
2352 }
2353
2354 /// Fetch account snapshot: balances, and the position and open orders of each registered
2355 /// instrument IB reports either in.
2356 ///
2357 /// # Positions
2358 ///
2359 /// Each instrument IB reports a position or an open order of this API client's in, and that
2360 /// is registered (and in `instruments`, when that is not empty), gets an
2361 /// `InstrumentAccountSnapshot`. Its `position` is
2362 /// [`PositionReport::Open`], carrying:
2363 /// - `quantity`: IB's signed position, negative when short, in shares for a stock and in
2364 /// contracts for a future or an option. `ibapi` hands it over as an `f64`, converted
2365 /// with `Decimal::try_from`, which rounds to the float's precision of about 15 significant
2366 /// digits (0.1 stays 0.1) rather than keeping its exact binary expansion.
2367 /// - `entry_price`: IB's average cost divided by the contract multiplier, so a future or an
2368 /// option is quoted as its orders are priced. It includes commissions. `None` when IB sends
2369 /// no average cost, or no multiplier for a contract other than a stock or a forex pair.
2370 /// - `time_exchange`: the time of the call, since IB does not timestamp positions.
2371 ///
2372 /// `unrealized_pnl` and the margin fields are `None`: IB's positions subscription does not
2373 /// carry them.
2374 ///
2375 /// A zero quantity, which IB reports for a position closed today, is
2376 /// [`PositionReport::Flat`]. So is each instrument in
2377 /// `instruments` that is registered with its contract ID but that IB did not list, provided IB
2378 /// marked the end of its listing (`PositionEnd`) during the read. Without that marker such
2379 /// instruments are left out, with a warning, since the listing may be incomplete.
2380 /// A requested instrument registered without a contract ID, or whose ID was registered again
2381 /// under another name, cannot be matched to IB's reports and is left out too.
2382 ///
2383 /// **Several accounts.** This client reads the positions of every account the login manages,
2384 /// one account at a time. When more than one account holds the same instrument, the first account to report a
2385 /// non-zero quantity is kept and the others are dropped with a warning; they are never summed.
2386 /// A consumer comparing the reported quantity with its own, as the `rustrade` engine's position
2387 /// drift check does, therefore compares against that one account.
2388 /// Which account comes first depends on the order IB lists the login's accounts in, so a
2389 /// caller holding the same instrument in several accounts should not rely on it.
2390 ///
2391 /// An instrument with an open order whose position the read could not establish, because IB
2392 /// did not mark the end of its listing, reports it
2393 /// [`PositionReport::Unreported`].
2394 ///
2395 /// # Orders
2396 ///
2397 /// Each instrument's `orders` are this API client's open orders in it, read as
2398 /// [`fetch_open_orders`](ExecutionClient::fetch_open_orders) reads them, from one listing of
2399 /// the account's open orders. An order this client does not track but placed under a client
2400 /// order id, such as one placed before a restart, is listed under that id, and tracked from
2401 /// then on.
2402 ///
2403 /// `orders_complete` is `true` when IB listed every open order to the end of the listing and
2404 /// each of the instrument's was read back under the client order id it was placed with and
2405 /// with its status. So it is `false` on an instrument with an order of this API client's
2406 /// that carries no client order id, which is listed under its IB order id: one placed by an
2407 /// earlier version of this client, or, for a client connected as 0, one entered in TWS. So it
2408 /// is too with an order whose shape this client never sends, one IB listed without its
2409 /// status, or one whose client order id names another order this client tracks. When the
2410 /// listing cannot be read, no instrument's orders are complete, with a warning, and the
2411 /// snapshot is still returned.
2412 ///
2413 /// # Errors
2414 ///
2415 /// - [`ConnectivityError::Timeout`] if IB does not answer the request for the login's
2416 /// accounts within 30 seconds. Transient: retry.
2417 /// - [`UnindexedClientError::Internal`] if a position quantity does not convert to a
2418 /// `Decimal`, if the request for the login's accounts fails otherwise or yields none (as it
2419 /// does when the session ends during it), if a positions subscription fails, or as
2420 /// [`fetch_balances`](ExecutionClient::fetch_balances).
2421 ///
2422 /// # Known Issue: ibapi Decode Errors
2423 ///
2424 /// You may see log errors like `"error decoding message: error occurred:
2425 /// unexpected message: Error"`. This is an upstream ibapi limitation where
2426 /// IB Error messages (type 4) on subscription channels aren't properly
2427 /// routed — they're logged but don't affect functionality.
2428 ///
2429 /// # Timeout
2430 ///
2431 /// Each account's positions read ends at IB's end-of-listing marker. If IB sends
2432 /// nothing for 5 seconds before it, that account's read gives up and the next
2433 /// one starts, so a login with N accounts can wait up to N × 5 seconds. The
2434 /// positions received are returned rather than blocking indefinitely, without
2435 /// the flat reports that need every account's listing to be complete.
2436 ///
2437 /// Each read uses IB's positions-multi request for that account, whose replies
2438 /// carry a request ID, so concurrent calls on clones of this client never read
2439 /// each other's listing.
2440 async fn account_snapshot(
2441 &self,
2442 assets: &[AssetNameExchange],
2443 instruments: &[InstrumentNameExchange],
2444 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
2445 // H-2 fix: Run balances and positions concurrently (independent IB requests)
2446 let client = self.client.clone();
2447 let contracts = self.contracts.clone();
2448 let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
2449 None
2450 } else {
2451 Some(instruments.iter().cloned().collect())
2452 };
2453
2454 let balances_future = self.fetch_balances(assets);
2455 let positions_filter = instruments_filter.clone();
2456 let positions_future = tokio::task::spawn_blocking(move || {
2457 let instruments_filter = positions_filter;
2458 // ibapi::Error is unstructured — apart from a timeout, we cannot distinguish
2459 // connection failures (transient, should retry) from API errors (e.g., invalid
2460 // request). Mapped to Internal (non-transient) conservatively; a caller needing
2461 // reconnect logic should drive it from connection state, not from the error type.
2462 let accounts = client.managed_accounts().map_err(managed_accounts_error)?;
2463 if accounts.is_empty() {
2464 // `ibapi` also answers with no accounts when the session ends mid-request.
2465 return Err(UnindexedClientError::Internal(
2466 "no managed accounts: IB listed none, or the session ended".to_string(),
2467 ));
2468 }
2469
2470 let mut positions = PositionAggregator::default();
2471 let mut listed_all = true;
2472 for account in accounts {
2473 listed_all &= read_account_positions(
2474 &client,
2475 AccountId(account),
2476 &contracts,
2477 instruments_filter.as_ref(),
2478 &mut positions,
2479 )?;
2480 }
2481 if listed_all {
2482 positions.listing_ended();
2483 }
2484
2485 // IB's reports are attributed by contract ID, so a requested instrument can be
2486 // reported flat only if its ID is registered and resolves back to it: with no ID, or
2487 // one another name took over, IB's position in it would be missed.
2488 let requested: Vec<_> = instruments_filter
2489 .iter()
2490 .flatten()
2491 .filter(|instrument| {
2492 contracts.get_contract(instrument).is_some_and(|contract| {
2493 contract.contract_id != 0
2494 && contracts.get_name_by_con_id(contract.contract_id).as_ref()
2495 == Some(*instrument)
2496 })
2497 })
2498 .cloned()
2499 .collect();
2500 Ok::<_, UnindexedClientError>((positions, requested))
2501 });
2502
2503 let client = self.client.clone();
2504 let contracts = self.contracts.clone();
2505 let order_ids = self.order_ids.clone();
2506 let listings = self.listings.clone();
2507 let orders_future = tokio::task::spawn_blocking(move || {
2508 let listing = listings.open_orders(&client)?;
2509 Ok::<_, UnindexedClientError>(open_orders_from_listing(
2510 listing,
2511 client.client_id(),
2512 &contracts,
2513 &order_ids,
2514 ))
2515 });
2516
2517 // ibapi routes responses by request_id via thread-safe channels (RwLock<HashMap>),
2518 // so concurrent requests on the same client are safe. The open-order listing is a shared
2519 // request, serialized by `ListingLock`.
2520 let (balances_result, positions_result, orders_result) =
2521 tokio::join!(balances_future, positions_future, orders_future);
2522 let balances = balances_result?;
2523 let (positions, requested) = positions_result
2524 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))??;
2525 let listed = match orders_result
2526 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
2527 {
2528 Ok(mut listed) => {
2529 if let Some(filter) = &instruments_filter {
2530 listed.retain(|listed| filter.contains(&listed.instrument));
2531 }
2532 Some(listed)
2533 }
2534 Err(error) => {
2535 warn!(
2536 %error,
2537 "Could not read IBKR's open orders for the account snapshot; no instrument's \
2538 orders are reported complete"
2539 );
2540 None
2541 }
2542 };
2543 if let Some(listed) = &listed {
2544 remember_listed(&self.known_live, listed);
2545 }
2546 let mut orders = SnapshotOrders::new(listed);
2547
2548 // An instrument with open orders is attributed by contract ID too, so its position is
2549 // reported along with them.
2550 let reported = positions.into_reports(
2551 Utc::now(),
2552 requested
2553 .iter()
2554 .chain(orders.instruments())
2555 .collect::<HashSet<_>>(),
2556 )?;
2557 let mut instrument_snapshots = Vec::with_capacity(reported.len());
2558 for (instrument, position) in reported {
2559 let (orders, orders_complete) = orders.take(&instrument);
2560 instrument_snapshots.push(InstrumentAccountSnapshot {
2561 instrument,
2562 orders,
2563 orders_complete,
2564 position,
2565 isolated: None,
2566 });
2567 }
2568 // Instruments with open orders whose positions the read did not finish listing.
2569 instrument_snapshots.extend(orders.into_rest().map(|(instrument, (orders, complete))| {
2570 InstrumentAccountSnapshot {
2571 instrument,
2572 orders,
2573 orders_complete: complete,
2574 position: PositionReport::Unreported,
2575 isolated: None,
2576 }
2577 }));
2578
2579 Ok(AccountSnapshot {
2580 exchange: ExchangeId::Ibkr,
2581 balances,
2582 instruments: instrument_snapshots,
2583 })
2584 }
2585
2586 /// Stream account events (order updates, fills, commissions).
2587 ///
2588 /// # Thread Lifecycle
2589 ///
2590 /// Spawns two background threads: `ibkr-order-stream` reads the blocking IB
2591 /// subscription, and `ibkr-recovery` watches for gaps in event delivery
2592 /// (see below). The watcher exits within about a second of the returned
2593 /// `BoxStream` being dropped or the stream ending, or once the recovery or
2594 /// order check in progress then ends. The reader exits when:
2595 /// - The returned `BoxStream` is dropped (channel closes), or the stream has
2596 /// ended, and the next IB event arrives
2597 /// - The IB subscription ends
2598 ///
2599 /// **Important:** If IB is stalled (no events flowing), the reader thread blocks
2600 /// on the iterator. Dropping the stream signals termination, but the thread won't
2601 /// observe it until the next IB event arrives. A shutdown does end the
2602 /// subscription, so the thread exits then.
2603 ///
2604 /// # Reconnects and Recovery
2605 ///
2606 /// `ibapi` reconnects its socket to TWS/Gateway by itself, and this stream
2607 /// stays open across that: a transient drop produces neither an error nor
2608 /// `StreamTerminated`. TWS does not resend what it sent while the socket was
2609 /// down, and TWS losing its own link to IB's servers (notice 1100) opens the
2610 /// same kind of gap. Once delivery is restored, this stream asks TWS for the
2611 /// last seven days of executions and emits the ones from the gap as `Trade` events. The
2612 /// gap is taken to start up to a poll interval before the drop was noticed,
2613 /// minus a lookback margin. Trades the stream already delivered are not
2614 /// emitted twice. The log line `Recovered IBKR fills after a gap in event
2615 /// delivery` marks one.
2616 ///
2617 /// When that read fails three times for a reason other than the transport
2618 /// dropping again, the gap is given up: the stream sends one
2619 /// `AccountEventKind::FillRecoveryGaveUp` covering every instrument, from the
2620 /// start of the gap to when it gave up, and stays open. Read that span with
2621 /// [`ExecutionClient::fetch_trades`].
2622 ///
2623 /// A gap that starts before the span IB surely holds (see `fetch_trades`' `held_since`) is
2624 /// reported too, even though the read succeeds: after the recovered trades, one
2625 /// `FillRecoveryGaveUp` with `FillRecoveryFailure::VenueRetention` covers the part before
2626 /// that span, ending the moment before `held_since`. `fetch_trades` cannot read that part
2627 /// either, so reconcile it from another source.
2628 ///
2629 /// **Orders that ended during the gap.** After the fills, recovered or given
2630 /// up, the stream checks how each order the client holds as live ended:
2631 /// those placed through it, listed by `fetch_open_orders` or
2632 /// `account_snapshot`, or reported live on this stream, and not yet seen to
2633 /// end. It lists the open orders once, and looks up each held order the
2634 /// listing no longer shows in IB's completed orders, as
2635 /// [`OrderStatusClient::fetch_ended_orders`] does. Each that ended is sent
2636 /// as an `OrderSnapshot` of its inactive state, under `StrategyId::unknown`,
2637 /// and its client order id is freed, as when the stream reports an order
2638 /// ending itself. An order IB lists as neither is dropped from the set with a
2639 /// warning. A check that fails is retried after 1, 2, 4, 8 and 16 minutes
2640 /// while connected, then given up with an error log: an order that ended
2641 /// then stays live in engine state until the next gap's check, so reconcile
2642 /// with `fetch_open_orders`. The client holds at most 4,096 orders as live,
2643 /// forgetting the oldest beyond that. The check reads IB's listings on the
2644 /// recovery thread, each bounded by a 10-second stall timeout, and the thread
2645 /// reads notices again once it ends.
2646 ///
2647 /// Call this within a Tokio runtime with its time driver enabled, as
2648 /// `#[tokio::main]` and `Runtime::new` build: the recovery thread runs the
2649 /// order check on the runtime this was called on.
2650 ///
2651 /// While TWS reports its link to IB's servers lost (1100), nothing marks the
2652 /// gap on this stream until the link is restored and the recovered fills
2653 /// arrive.
2654 ///
2655 /// The stream ends with `StreamTerminated` when the client shuts down for
2656 /// good, because `ibapi` gave up reconnecting, [`IbkrClient::disconnect`] was
2657 /// called, or TWS/Gateway ended the API session. Called on a client that has
2658 /// already shut down, this method fails, so replace the client.
2659 ///
2660 /// # Duplicate Events
2661 ///
2662 /// Orders submitted via `open_order()` will emit `OrderSnapshot` events on
2663 /// this stream in addition to the response from `open_order()` itself. This
2664 /// is inherent to IB's API — both the per-order subscription and the global
2665 /// order update stream receive the same `OrderStatus` events. Callers should
2666 /// deduplicate if needed.
2667 ///
2668 /// `Trade` events are delivered once each. Executions that answer an
2669 /// executions request, such as [`ExecutionClient::fetch_trades`], are not
2670 /// reported here as fills.
2671 ///
2672 /// # Corrections
2673 ///
2674 /// IB reports a corrected execution as a further execution whose id differs
2675 /// only in the digits after the final period (`….01.02` corrects `….01.01`).
2676 /// It is sent as `AccountEventKind::TradeAmended`, `Corrected`, with the
2677 /// correction as the replacement `Trade`, once its commission report arrives.
2678 /// Its `original` is the revision this stream delivered before, or, for an
2679 /// execution from before the stream, the revision the correction's id says
2680 /// it corrects. A revision older than one already delivered is dropped.
2681 /// Fills recovered after a gap are delivered originals first, so a
2682 /// correction recovered with them comes after every original in the gap,
2683 /// not in time order.
2684 /// Each correction is logged at `warn!` when it arrives, so one that never
2685 /// gets a commission report is still seen. IB documents no busts.
2686 ///
2687 /// The revisions delivered are remembered in a bounded cache, like the dedup
2688 /// cache. A correction of an execution the cache has forgotten names the
2689 /// previous revision by its id, which is the original unless an
2690 /// intermediate revision was delivered and forgotten too.
2691 ///
2692 /// One case loses a fill: a correction that arrives live after a reconnect,
2693 /// before recovery has delivered its original from the gap. The correction
2694 /// is sent naming the original, and recovery then drops the original as
2695 /// older than a revision already delivered, so the trade reaches the stream
2696 /// only as the amendment.
2697 ///
2698 /// # Filter Parameters
2699 ///
2700 /// The `assets` and `instruments` parameters are currently ignored. IB's
2701 /// `order_update_stream()` returns events for all orders on the account.
2702 /// Callers subscribing for a specific instrument will receive events for
2703 /// all instruments.
2704 async fn account_stream(
2705 &self,
2706 _assets: &[AssetNameExchange],
2707 _instruments: &[InstrumentNameExchange],
2708 ) -> Result<Self::AccountStream, UnindexedClientError> {
2709 let client = self.client.clone();
2710
2711 // M-8 fix: Wrap blocking subscription call in spawn_blocking
2712 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
2713 let (notices, order_sub) = tokio::task::spawn_blocking(move || {
2714 // Notices first, so a reconnect that lands while the order stream is being set up is
2715 // not missed.
2716 let notices = client.notice_stream()?;
2717 client.order_update_stream().map(|sub| (notices, sub))
2718 })
2719 .await
2720 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
2721 .map_err(|e| UnindexedClientError::Internal(format!("order updates: {e}")))?;
2722
2723 let scope = self.execution_scope();
2724 let contracts_clone = self.contracts.clone();
2725 let order_ids_clone = self.order_ids.clone();
2726 let pending_cancels_clone = self.pending_cancels.clone();
2727 let exec_buffer_clone = self.execution_buffer.clone();
2728
2729 let (tx, rx) = mpsc::unbounded_channel();
2730 let sink = recovery::EventSink::new(tx, new_dedup_cache(), self.known_live.clone());
2731
2732 // Spawned before the reader: if the reader fails to spawn, `rx` drops with the error and
2733 // the watcher sees its consumer gone.
2734 let watcher = recovery::RecoveryWatcher {
2735 client: self.client.clone(),
2736 notices,
2737 contracts: self.contracts.clone(),
2738 order_ids: self.order_ids.clone(),
2739 pending_cancels: self.pending_cancels.clone(),
2740 pending: self.execution_buffer.clone(),
2741 known: self.known_live.clone(),
2742 listings: self.listings.clone(),
2743 earlier_completions: self.earlier_completions.clone(),
2744 sink: sink.clone(),
2745 runtime: tokio::runtime::Handle::current(),
2746 scope,
2747 };
2748 let watcher_sink = sink.clone();
2749 std::thread::Builder::new()
2750 .name("ibkr-recovery".to_string())
2751 .spawn(move || {
2752 // Same panic policy as the reader below: a panic ends recovery, and a stream that
2753 // can no longer recover must say so rather than stay open.
2754 if let Err(panic_info) = catch_unwind(AssertUnwindSafe(|| watcher.run())) {
2755 let msg = panic_message(panic_info.as_ref());
2756 error!("IBKR recovery worker panicked: {msg}");
2757 watcher_sink.terminate(StreamTerminationReason::Error(format!(
2758 "IBKR recovery worker panicked: {msg}"
2759 )));
2760 }
2761 })
2762 .map_err(|e| UnindexedClientError::TaskFailed(format!("thread spawn: {e}")))?;
2763
2764 std::thread::Builder::new()
2765 .name("ibkr-order-stream".to_string())
2766 .spawn(move || {
2767 // Panic safety: parking_lot mutexes do not poison on panic, so shared state
2768 // (ContractRegistry, OrderIdMap, etc.) remains usable. catch_unwind unwinds only
2769 // the inner closure — `sink` lives in this outer thread closure and is still open
2770 // afterward, so the panic handler below emits a terminal StreamTerminated
2771 // (a panic is a terminal stream death like any other).
2772 let result = catch_unwind(AssertUnwindSafe(|| {
2773 forward_order_updates(
2774 order_sub.iter_data(),
2775 &sink,
2776 scope,
2777 &contracts_clone,
2778 &order_ids_clone,
2779 &pending_cancels_clone,
2780 &exec_buffer_clone,
2781 );
2782 }));
2783
2784 if let Err(panic_info) = result {
2785 let msg = panic_message(panic_info.as_ref());
2786 error!("Order stream worker panicked: {msg}");
2787 // A panic is a terminal stream death — surface it in-band so the consumer
2788 // gets a programmatic signal rather than inferring EOF.
2789 // Best-effort: a no-op if the consumer already dropped rx.
2790 sink.terminate(StreamTerminationReason::Error(format!(
2791 "IBKR order-update worker panicked: {msg}"
2792 )));
2793 }
2794 })
2795 .map_err(|e| UnindexedClientError::TaskFailed(format!("thread spawn: {e}")))?;
2796
2797 Ok(Box::pin(
2798 tokio_stream::wrappers::UnboundedReceiverStream::new(rx),
2799 ))
2800 }
2801
2802 /// Cancel an order.
2803 ///
2804 /// # Async Cancel Semantics
2805 ///
2806 /// Returns `Ok(Cancelled)` when the cancel request is **submitted**, not when
2807 /// the order is confirmed cancelled. The actual cancellation confirmation comes
2808 /// via `account_stream` as an `OrderStatus::Cancelled` event.
2809 ///
2810 /// The order ID mapping is retained until terminal status is received via
2811 /// `account_stream`, ensuring fill events arriving between cancel request and
2812 /// confirmation are not lost.
2813 async fn cancel_order(
2814 &self,
2815 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
2816 ) -> UnindexedOrderResponseCancel {
2817 let key = OrderKey {
2818 exchange: request.key.exchange,
2819 instrument: request.key.instrument.clone(),
2820 strategy: request.key.strategy.clone(),
2821 cid: request.key.cid.clone(),
2822 };
2823
2824 let ib_order_id = match self.order_ids.get_ib_id(&request.key.cid) {
2825 Some(id) => id,
2826 None => {
2827 return OrderResponseCancel {
2828 key,
2829 state: Err(crate::error::OrderError::Rejected(ApiError::OrderRejected(
2830 "order ID not found in map".to_string(),
2831 ))),
2832 };
2833 }
2834 };
2835
2836 let client = self.client.clone();
2837
2838 let result =
2839 tokio::task::spawn_blocking(move || client.cancel_order(ib_order_id, "")).await;
2840
2841 match result {
2842 Ok(Ok(_sub)) => {
2843 // Track user-initiated cancel for Cancelled vs Expired differentiation.
2844 // Insert only after cancel request succeeded — avoids stale entries if
2845 // the future is dropped before reaching this branch.
2846 self.pending_cancels.insert(ib_order_id);
2847
2848 // H-3 fix: Do NOT remove order_ids here. The mapping is needed to
2849 // correlate any fill events that arrive between now and when IB
2850 // confirms the cancel. Removal happens in account_stream when
2851 // OrderStatus::Cancelled is received.
2852 // IBKR cancel_order returns no filled qty, so it is unknown here; the
2853 // subsequent OrderStatus events report it.
2854 OrderResponseCancel {
2855 key,
2856 state: Ok(Cancelled::new(
2857 OrderId::new(format_smolstr!("{}", ib_order_id)),
2858 Utc::now(),
2859 None,
2860 )),
2861 }
2862 }
2863 Ok(Err(e)) => {
2864 error!(order_id = ib_order_id, error = %e, "Failed to cancel order");
2865 OrderResponseCancel {
2866 key,
2867 state: Err(send_error(&e, e.to_string())),
2868 }
2869 }
2870 Err(e) => {
2871 error!(order_id = ib_order_id, error = %e, "Task join error");
2872 OrderResponseCancel {
2873 key,
2874 state: Err(crate::error::OrderError::Rejected(ApiError::OrderRejected(
2875 e.to_string(),
2876 ))),
2877 }
2878 }
2879 }
2880 }
2881
2882 /// Submit an order to IB.
2883 ///
2884 /// # Client order ids
2885 ///
2886 /// The client order id is sent as the order's IB order reference, so it must be at most 128
2887 /// ASCII characters; any other is refused before anything is sent. TWS does not check the
2888 /// reference's uniqueness, so this client keeps each id unique itself: an open
2889 /// under an id that a live order holds is refused before anything is sent, as
2890 /// [`ApiError::DuplicateClientOrderId`], and the live order is unaffected. An order stops
2891 /// holding its id when the account stream reports it `Filled`, `Cancelled` or `Inactive`, or
2892 /// when [`clear_stale_order_ids`](IbkrClient::clear_stale_order_ids) reaps it, or when the
2893 /// account stream's check after a gap finds it ended. Without a running
2894 /// [`account_stream`](Self::account_stream), no order is reported ended, and an id is held
2895 /// until reaped.
2896 ///
2897 /// An open whose outcome is unknown ([`OrderError::is_outcome_unknown`]) keeps holding its id,
2898 /// as TWS may hold the order. If the order was never placed, the id is held until reaped, so
2899 /// retry such an open under a new id.
2900 ///
2901 /// A cancel names the live order under its id, so cancelling a filled order's id once it is
2902 /// freed is refused locally as not found, rather than at TWS.
2903 ///
2904 /// # Cancellation Safety
2905 ///
2906 /// This future registers the order ID mapping before submitting to IB.
2907 /// If the future is cancelled (e.g., via `tokio::select!` timeout) after
2908 /// registration but before completion:
2909 /// - The order may still be submitted to IB
2910 /// - The order ID mapping will leak (not cleaned up)
2911 /// - Subsequent fills will be processed via `account_stream`
2912 /// - The client order id stays held, so a retry under it is refused as
2913 /// [`ApiError::DuplicateClientOrderId`] until the account stream reports
2914 /// the order ended or `clear_stale_order_ids` reaps it. Retry under a
2915 /// fresh id. The same holds when this returns `Open` without a status
2916 /// from TWS.
2917 ///
2918 /// Callers should avoid cancelling this future mid-flight. If timeout
2919 /// behavior is needed, prefer setting IB's native order timeout via
2920 /// `TimeInForce` instead.
2921 async fn open_order(
2922 &self,
2923 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2924 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2925 let key = OrderKey {
2926 exchange: ExchangeId::Ibkr,
2927 instrument: request.key.instrument.clone(),
2928 strategy: request.key.strategy.clone(),
2929 cid: request.key.cid.clone(),
2930 };
2931
2932 // Margin is account-level at IBKR: no order flag borrows or repays, so no effect is
2933 // honoured.
2934 if let Some(effect) = request.state.margin_effect {
2935 return Order {
2936 key,
2937 side: request.state.side,
2938 price: request.state.price,
2939 quantity: request.state.quantity,
2940 kind: request.state.kind,
2941 time_in_force: request.state.time_in_force,
2942 state: OrderState::inactive(OrderError::UnsupportedMarginEffect(effect)),
2943 };
2944 }
2945
2946 let contract = match self.contracts.get_contract(request.key.instrument) {
2947 Some(c) => c,
2948 None => {
2949 return Order {
2950 key,
2951 side: request.state.side,
2952 price: request.state.price,
2953 quantity: request.state.quantity,
2954 kind: request.state.kind,
2955 time_in_force: request.state.time_in_force,
2956 state: OrderState::inactive(OrderError::Rejected(ApiError::InstrumentInvalid(
2957 request.key.instrument.clone(),
2958 "contract not registered".to_string(),
2959 ))),
2960 };
2961 }
2962 };
2963
2964 let quantity: f64 = match request.state.quantity.try_into() {
2965 Ok(q) => q,
2966 Err(_) => {
2967 return Order {
2968 key,
2969 side: request.state.side,
2970 price: request.state.price,
2971 quantity: request.state.quantity,
2972 kind: request.state.kind,
2973 time_in_force: request.state.time_in_force,
2974 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2975 format!("quantity {} exceeds f64 range", request.state.quantity),
2976 ))),
2977 };
2978 }
2979 };
2980
2981 let ib_order = match order::order_ref(&request.key.cid).and_then(|order_ref| {
2982 build_ib_order(
2983 request.state.side,
2984 quantity,
2985 &request.state.kind,
2986 request.state.price,
2987 &request.state.time_in_force,
2988 )
2989 .map(|order| ibapi::orders::Order { order_ref, ..order })
2990 }) {
2991 Ok(o) => o,
2992 Err(e) => {
2993 return Order {
2994 key,
2995 side: request.state.side,
2996 price: request.state.price,
2997 quantity: request.state.quantity,
2998 kind: request.state.kind,
2999 time_in_force: request.state.time_in_force,
3000 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
3001 e.to_string(),
3002 ))),
3003 };
3004 }
3005 };
3006
3007 let ib_order_id = self.allocate_order_id();
3008
3009 // Store order context for reconstructing Order from OrderStatus callbacks
3010 let context = OrderContext {
3011 instrument: request.key.instrument.clone(),
3012 side: request.state.side,
3013 price: request.state.price,
3014 quantity: request.state.quantity,
3015 kind: request.state.kind,
3016 time_in_force: request.state.time_in_force,
3017 };
3018 // Before anything is sent: TWS does not check the uniqueness of the order reference that
3019 // carries the client order id, so this is the only place a second live order under it can
3020 // be refused. A refused id simply skips the allocated ID.
3021 if let Err(in_use) = self
3022 .order_ids
3023 .register(request.key.cid.clone(), ib_order_id, context)
3024 {
3025 return Order {
3026 key,
3027 side: request.state.side,
3028 price: request.state.price,
3029 quantity: request.state.quantity,
3030 kind: request.state.kind,
3031 time_in_force: request.state.time_in_force,
3032 state: OrderState::inactive(OrderError::Rejected(
3033 ApiError::DuplicateClientOrderId(in_use.to_string()),
3034 )),
3035 };
3036 }
3037 // The id names no live order, so this is a new order under it, though an earlier one
3038 // under it ended.
3039 self.known_live.lock().placing(&request.key.cid);
3040
3041 let client = self.client.clone();
3042 let side = request.state.side;
3043 let price = request.state.price;
3044 let req_quantity = request.state.quantity;
3045 let kind = request.state.kind;
3046 let tif = request.state.time_in_force;
3047
3048 // Move both place_order AND subscription iteration into spawn_blocking
3049 // to avoid blocking Tokio worker threads on IB's synchronous iterator.
3050 let result = tokio::task::spawn_blocking(move || {
3051 let sub = match client.place_order(ib_order_id, &contract, &ib_order) {
3052 Ok(s) => s,
3053 Err(e) => return Err(open_send_error(&e, e.to_string())),
3054 };
3055
3056 match await_order_placement(sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)) {
3057 PlacementOutcome::Accepted { filled } => {
3058 let filled = parse_decimal_or_warn(filled, "status.filled");
3059 Ok(Some((ib_order_id, filled)))
3060 }
3061 // Cancelled/Inactive/unexpected status or a genuine notice/error.
3062 // Don't remove the mapping here — the outer match centralizes all
3063 // error-path removals.
3064 PlacementOutcome::Rejected(reason) => {
3065 Err(OrderError::Rejected(ApiError::OrderRejected(reason)))
3066 }
3067 // Held (informational notice, e.g. 399 until RTH or 404 for a short-sale
3068 // locate, or a status ibapi does not model): the order is live; its
3069 // authoritative status arrives via account_stream. Surface as
3070 // "no terminal status yet" (Open with zero fill), same as below.
3071 PlacementOutcome::HeldPending(notice) => {
3072 warn!(ib_order_id, %notice, "order held/pending; tracking via stream");
3073 Ok(None)
3074 }
3075 // Subscription exhausted/timed out without a terminal status.
3076 // Do NOT remove the mapping — ExecutionData/CommissionReport may
3077 // still arrive via account_stream; clear_stale_order_ids() handles
3078 // stale mappings.
3079 PlacementOutcome::NoStatus => Ok(None),
3080 }
3081 })
3082 .await;
3083
3084 // Recorded only once sent: an open refused before sending, as under a client order id a
3085 // live order holds, must not end that order in the set.
3086 let order = match result {
3087 Ok(Ok(Some((order_id, filled)))) => {
3088 // IB always returns order status via subscription - never immediate fills.
3089 // The filled quantity here is from the OrderStatus event, not a complete fill.
3090 Order {
3091 key,
3092 side,
3093 price,
3094 quantity: req_quantity,
3095 kind,
3096 time_in_force: tif,
3097 state: OrderState::active(Open::new(
3098 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", order_id))),
3099 Utc::now(),
3100 filled,
3101 )),
3102 }
3103 }
3104 Ok(Ok(None)) => {
3105 // Subscription exhausted without terminal status. The order WAS submitted
3106 // (we have an IB order ID), but we lost tracking. Return Open with zero
3107 // filled — caller can query via fetch_open_orders or wait for account_stream.
3108 warn!(
3109 ib_order_id,
3110 "Order subscription ended without terminal status, returning Open"
3111 );
3112 Order {
3113 key,
3114 side,
3115 price,
3116 quantity: req_quantity,
3117 kind,
3118 time_in_force: tif,
3119 state: OrderState::active(Open::new(
3120 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", ib_order_id))),
3121 Utc::now(),
3122 Decimal::ZERO,
3123 )),
3124 }
3125 }
3126 Ok(Err(error)) => {
3127 release_failed_open(&self.order_ids, ib_order_id, &error);
3128 Order {
3129 key,
3130 side,
3131 price,
3132 quantity: req_quantity,
3133 kind,
3134 time_in_force: tif,
3135 state: OrderState::inactive(error),
3136 }
3137 }
3138 Err(e) => {
3139 self.order_ids.remove_by_ib_id(ib_order_id);
3140 Order {
3141 key,
3142 side,
3143 price,
3144 quantity: req_quantity,
3145 kind,
3146 time_in_force: tif,
3147 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
3148 e.to_string(),
3149 ))),
3150 }
3151 }
3152 };
3153 self.known_live
3154 .lock()
3155 .placed(&order.key, order.quantity, order.kind, &order.state);
3156 order
3157 }
3158
3159 /// Fetch account balances.
3160 ///
3161 /// # Limitations
3162 ///
3163 /// The `time_exchange` field in returned balances uses `Utc::now()`, not
3164 /// the actual IB server timestamp. IB's account summary endpoint does not
3165 /// provide timestamps per balance update.
3166 ///
3167 /// # Errors
3168 ///
3169 /// [`UnindexedClientError::Internal`] if the request fails, IB answers with an
3170 /// error, IB sends nothing for 10 seconds before the end of the listing, or the
3171 /// connection drops during it.
3172 async fn fetch_balances(
3173 &self,
3174 assets: &[AssetNameExchange],
3175 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
3176 let client = self.client.clone();
3177 let assets_filter: Option<HashSet<AssetNameExchange>> = if assets.is_empty() {
3178 None
3179 } else {
3180 Some(assets.iter().cloned().collect())
3181 };
3182
3183 tokio::task::spawn_blocking(move || {
3184 // IB's reqAccountSummary expects a group name ("All" for all linked accounts),
3185 // not an account ID. Using account ID causes error 321 "Unified group name is invalid".
3186 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
3187 let sub = client
3188 .account_summary(&ACCOUNT_GROUP_ALL, account::BALANCE_SUMMARY_TAGS)
3189 .map_err(|e| UnindexedClientError::Internal(format!("account_summary: {e}")))?;
3190
3191 let mut balances = read_account_summary(
3192 LISTING_STALL_TIMEOUT,
3193 || client.is_connected(),
3194 |timeout| sub.next_timeout(timeout),
3195 )?
3196 .to_balances();
3197
3198 if let Some(ref filter) = assets_filter {
3199 balances.retain(|b| filter.contains(&b.asset));
3200 }
3201
3202 Ok(balances)
3203 })
3204 .await
3205 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
3206 }
3207
3208 /// Fetch the open orders this API client placed.
3209 ///
3210 /// IB numbers orders per API client, so orders other clients placed, and orders entered in
3211 /// TWS, are left out: their ids could name an order of this client's. IB reports orders
3212 /// entered in TWS under API client id 0, so a client connected as 0 keeps them.
3213 ///
3214 /// Each order is returned under the client order id it was placed with: the one this client
3215 /// tracks it under, with the kind, price and time in force it was placed with, or else the
3216 /// one its IB order reference carries, with those read back from IB's listing. An order
3217 /// this client does not track but placed under a client order id, such as one placed before
3218 /// a restart, is tracked from then on, so its fills and status reach the account stream and
3219 /// it can be cancelled. Every listed order of this client's is adopted so, whatever
3220 /// `instruments` asks for. An order without a reference, such as one placed by an earlier
3221 /// version of this client, or, for a client connected as 0, one entered in TWS, is returned
3222 /// under its IB order id; so is one whose reference names another order this client tracks.
3223 /// One whose order type or time in force this client never sends is left out, with a
3224 /// warning. Every order's filled quantity is the one IB reports with it.
3225 ///
3226 /// Each returned order named by the id it was placed with is held as live, for the account
3227 /// stream's check of how it ended after a gap.
3228 ///
3229 /// IB lists every client's open orders to every request for them, so this client and its
3230 /// clones read the listing one at a time.
3231 ///
3232 /// # Errors
3233 ///
3234 /// [`UnindexedClientError::Internal`] if the request fails, IB answers with an
3235 /// error, IB sends nothing for 10 seconds before the end of the listing, or the
3236 /// connection drops during it.
3237 async fn fetch_open_orders(
3238 &self,
3239 instruments: &[InstrumentNameExchange],
3240 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
3241 let client = self.client.clone();
3242 let contracts = self.contracts.clone();
3243 let order_ids = self.order_ids.clone();
3244 let listings = self.listings.clone();
3245 let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
3246 None
3247 } else {
3248 Some(instruments.iter().cloned().collect())
3249 };
3250
3251 let listed = tokio::task::spawn_blocking(move || {
3252 let listing = listings.open_orders(&client)?;
3253 let mut listed =
3254 open_orders_from_listing(listing, client.client_id(), &contracts, &order_ids);
3255 if let Some(filter) = &instruments_filter {
3256 listed.retain(|listed| filter.contains(&listed.instrument));
3257 }
3258 Ok::<_, UnindexedClientError>(listed)
3259 })
3260 .await
3261 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))??;
3262
3263 remember_listed(&self.known_live, &listed);
3264 Ok(listed
3265 .into_iter()
3266 .filter_map(|listed| listed.order)
3267 .collect())
3268 }
3269
3270 /// Fetch historical trades (executions).
3271 ///
3272 /// # Limitations
3273 ///
3274 /// - IB lists executions from the last 7 days at most, counted in a time zone of its own. One
3275 /// request reads them all, the span is applied client-side, and the read is always
3276 /// complete (`resume: None`) for what IB returned. A span starting more than 5 days ago
3277 /// has [`held_since`](TradesRead::held_since) set to 5 days ago, the most the read is sure
3278 /// to reach: fills before it may be missing. For older executions, use IB's Flex Query or
3279 /// Activity Statements.
3280 /// - Only the executions of orders this API client placed are returned, plus any
3281 /// execution IB flags as an IB-initiated liquidation, whoever placed its order, as a
3282 /// [`Liquidation`](crate::trade::TradeOrigin::Liquidation) trade. With
3283 /// [`IbkrConfig::other_clients_fills`] set, every other client's execution IB sends is
3284 /// returned too, as an [`External`](crate::trade::TradeOrigin::External) trade under
3285 /// IB's permanent id for its order. An execution in a contract this client has not
3286 /// registered is skipped either way.
3287 /// - Each trade's fees come from the commission report IB sends with its
3288 /// execution (see [Fill fees](crate::client::ibkr#fill-fees) for what that figure includes).
3289 /// An execution whose report IB did not send is returned with a zero
3290 /// fee in [`UNKNOWN_FEE_ASSET`](execution::UNKNOWN_FEE_ASSET), with a warning.
3291 /// - Trades are returned in `time_exchange` order, then by id. A corrected execution keeps the
3292 /// position of its first revision.
3293 ///
3294 /// # Errors
3295 ///
3296 /// [`UnindexedClientError::Internal`] if the request fails, IB answers with an
3297 /// error, IB sends nothing for 10 seconds before the end of the listing, or the
3298 /// connection drops during it.
3299 ///
3300 /// # Corrections
3301 ///
3302 /// IB reports a corrected execution as a further execution whose id differs
3303 /// only in the digits after the final period. Each execution is returned
3304 /// once, at its latest revision, under that revision's id: the replacement a
3305 /// `TradeAmended` on [`ExecutionClient::account_stream`] names.
3306 async fn fetch_trades(
3307 &self,
3308 start: DateTime<Utc>,
3309 end: DateTime<Utc>,
3310 instruments: &[InstrumentNameExchange],
3311 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
3312 if start > end {
3313 return Ok(TradesRead::complete(Vec::new()));
3314 }
3315 let held_since = execution::executions_held_since(start, Utc::now());
3316 let client = self.client.clone();
3317 let contracts = self.contracts.clone();
3318 let scope = self.execution_scope();
3319 let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
3320 None
3321 } else {
3322 Some(instruments.iter().cloned().collect())
3323 };
3324
3325 tokio::task::spawn_blocking(move || {
3326 // Every client's executions: `scope` decides which are reported (see
3327 // `ExecutionScope::classify`).
3328 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
3329 let sub = client
3330 .executions(execution::executions_filter())
3331 .map_err(|e| UnindexedClientError::Internal(format!("executions: {e}")))?;
3332
3333 let mut listing = Vec::new();
3334 read_listing(
3335 "executions",
3336 LISTING_STALL_TIMEOUT,
3337 || client.is_connected(),
3338 |timeout| sub.next_timeout(timeout),
3339 |item| {
3340 listing.push(item);
3341 ControlFlow::Continue(())
3342 },
3343 )?;
3344
3345 let trades = trades_from_executions(
3346 listing,
3347 scope,
3348 &contracts,
3349 start..=end,
3350 instruments_filter.as_ref(),
3351 );
3352 Ok(
3353 TradesRead::complete(execution::keep_latest_revisions(trades))
3354 .with_held_since(held_since),
3355 )
3356 })
3357 .await
3358 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
3359 }
3360}
3361
3362/// The trades [`ExecutionClient::fetch_trades`] returns from what one executions request
3363/// listed: the executions `scope` reports, in registered contracts, at a time in `span`, in
3364/// `instruments` when that is given. Each is paired with the commission report IB lists after it.
3365///
3366/// An execution IB listed no commission report for is a trade with an unknown fee, warned about.
3367/// The trades are in time order, then by id.
3368fn trades_from_executions(
3369 listing: impl IntoIterator<Item = ibapi::orders::Executions>,
3370 scope: ExecutionScope,
3371 contracts: &ContractRegistry,
3372 span: std::ops::RangeInclusive<DateTime<Utc>>,
3373 instruments: Option<&HashSet<InstrumentNameExchange>>,
3374) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
3375 use ibapi::orders::Executions;
3376
3377 let awaiting_commission = ExecutionBuffer::new();
3378 let mut trades = Vec::new();
3379 for item in listing {
3380 match item {
3381 Executions::ExecutionData(data) => {
3382 let exec = &data.execution;
3383 let Some((order_id, origin)) = scope.classify(exec) else {
3384 debug!(
3385 exec_id = %exec.execution_id,
3386 api_client_id = exec.client_id,
3387 "Execution of another API client's order, skipping"
3388 );
3389 continue;
3390 };
3391 // Unlike the account stream, not warned about: a read of a span lists whatever
3392 // the account traded, and a contract never registered is not one this client
3393 // follows.
3394 let Some(instrument) = contracts.get_name_by_con_id(data.contract.contract_id)
3395 else {
3396 continue;
3397 };
3398 if instruments.is_some_and(|filter| !filter.contains(&instrument)) {
3399 continue;
3400 }
3401 match execution::parse_ib_timestamp(&exec.time) {
3402 Some(time) if span.contains(&time) => {
3403 awaiting_commission.add_execution(
3404 data,
3405 ResolvedExecution {
3406 instrument,
3407 order_id,
3408 origin,
3409 },
3410 );
3411 }
3412 Some(_) => {}
3413 None => warn!(
3414 exec_id = %exec.execution_id,
3415 time = %exec.time,
3416 "Unparseable timestamp in execution, skipping"
3417 ),
3418 }
3419 }
3420 Executions::CommissionReport(report) => {
3421 if let Some(trade) = awaiting_commission.complete_with_commission(&report) {
3422 trades.push(trade);
3423 }
3424 }
3425 }
3426 }
3427
3428 let without_commission = awaiting_commission.take_without_commission();
3429 if !without_commission.is_empty() {
3430 warn!(
3431 count = without_commission.len(),
3432 "IBKR executions arrived without a commission report; returned with an unknown fee"
3433 );
3434 trades.extend(without_commission);
3435 }
3436 // In time order. IB lists commission reports after their executions, and a map holds those
3437 // without one, so neither gives that order.
3438 trades.sort_by(|a, b| {
3439 a.time_exchange
3440 .cmp(&b.time_exchange)
3441 .then_with(|| a.id.cmp(&b.id))
3442 });
3443 trades
3444}
3445
3446/// One of this API client's open orders, read back from an open-orders listing.
3447#[derive(Debug)]
3448struct ListedOpenOrder {
3449 instrument: InstrumentNameExchange,
3450 /// The order, or `None` when it cannot be read back: IB lists a shape this client never
3451 /// sends.
3452 order: Option<Order<ExchangeId, InstrumentNameExchange, Open>>,
3453 /// Whether the order is named by the client order id it was placed with, rather than by its
3454 /// IB order id.
3455 named_as_placed: bool,
3456 /// Whether IB listed the order's status, which carries its fill.
3457 paired_with_status: bool,
3458}
3459
3460impl ListedOpenOrder {
3461 /// Whether the order is reported exactly: read back, under the client order id it was placed
3462 /// with, and with the fill its status reports.
3463 fn is_exact(&self) -> bool {
3464 self.order.is_some() && self.named_as_placed && self.paired_with_status
3465 }
3466}
3467
3468/// This API client's open orders from what one open-orders request listed, by IB order id, each
3469/// read back as [`open_order_from_listing`] does. Orders in contracts not registered, and orders
3470/// that have ended, are left out.
3471fn open_orders_from_listing(
3472 listing: impl IntoIterator<Item = ibapi::orders::Orders>,
3473 api_client_id: i32,
3474 contracts: &ContractRegistry,
3475 order_ids: &OrderIdMap,
3476) -> Vec<ListedOpenOrder> {
3477 use ibapi::orders::Orders;
3478
3479 // IB lists each order, then its status, which carries the fill.
3480 let mut listed = std::collections::BTreeMap::new();
3481 let mut statuses = fnv::FnvHashMap::default();
3482 for item in listing {
3483 match item {
3484 Orders::OrderData(data) if data.order.client_id == api_client_id => {
3485 listed.insert(data.order_id, data);
3486 }
3487 Orders::OrderStatus(status) if status.client_id == api_client_id => {
3488 statuses.insert(status.order_id, status);
3489 }
3490 _ => {}
3491 }
3492 }
3493 listed
3494 .into_values()
3495 .filter_map(|data| {
3496 open_order_from_listing(&data, statuses.get(&data.order_id), contracts, order_ids)
3497 })
3498 .collect()
3499}
3500
3501/// The open order IB lists as `data`, with `status`, the status IB lists after it, or `None` when
3502/// it is not in a registered contract or has ended.
3503///
3504/// An order is named by the client order id it was placed with: the one this client tracks it
3505/// under, or else its order reference, which carries the id ([`order::order_ref`]). Either way an
3506/// order this client tracks keeps the shape it was placed with, and any other is read back by
3507/// [`order::order_shape_from_ib`]. An order named by its reference that this client does not track,
3508/// such as one placed before a restart, is adopted: tracked from then on under that id, so its
3509/// fills and status reach the account stream and it can be cancelled.
3510///
3511/// An order without a reference, such as one placed by an earlier version of this client, is named
3512/// by its IB order id, which is not an id it was placed with, so it is not
3513/// [exact](ListedOpenOrder::is_exact). So is one whose reference names another order this client
3514/// tracks, under that id or its IB order id, which cannot be adopted. Nor is one IB listed without
3515/// its status, whose fill is the order's own figure, which IB leaves zero.
3516fn open_order_from_listing(
3517 data: &ibapi::orders::OrderData,
3518 status: Option<&ibapi::orders::OrderStatus>,
3519 contracts: &ContractRegistry,
3520 order_ids: &OrderIdMap,
3521) -> Option<ListedOpenOrder> {
3522 let ib_id = data.order_id;
3523 let instrument = contracts.get_name_by_con_id(data.contract.contract_id)?;
3524
3525 // IB can still list an order that has just ended.
3526 let state = status.map_or(&data.order_state.status, |status| &status.status);
3527 if state.is_terminal() {
3528 debug!(
3529 ib_order_id = ib_id,
3530 ?state,
3531 "Listed open order has ended, skipping"
3532 );
3533 return None;
3534 }
3535 // The status carries the fill. Without one, the order's own figure, which IB may leave zero.
3536 let filled = status.map_or(data.order.filled_quantity, |status| status.filled);
3537 let filled = try_decimal_or_warn(filled, format_args!("filled quantity of order {ib_id}"))
3538 .unwrap_or(Decimal::ZERO);
3539 let order_ref = (!data.order.order_ref.is_empty())
3540 .then(|| ClientOrderId::new(data.order.order_ref.as_str()));
3541
3542 // The order this client tracks under IB order id `ib_id`, unless that id now names another:
3543 // IB numbers orders per API client and reuses the numbers across sessions.
3544 let tracked = order_ids
3545 .get_client_id_and_context(ib_id)
3546 .filter(|(cid, ctx)| {
3547 ctx.instrument == instrument && order_ref.as_ref().is_none_or(|r| r == cid)
3548 });
3549 let (cid, ctx, named_as_placed) = match tracked {
3550 Some((cid, ctx)) => (cid, ctx, true),
3551 None => {
3552 let shape = match order::order_shape_from_ib(&data.order) {
3553 Ok(shape) => shape,
3554 Err(reason) => {
3555 warn!(
3556 ib_order_id = ib_id,
3557 %instrument,
3558 %reason,
3559 "Open IBKR order cannot be read back, leaving it out"
3560 );
3561 return Some(ListedOpenOrder {
3562 instrument,
3563 order: None,
3564 named_as_placed: false,
3565 paired_with_status: status.is_some(),
3566 });
3567 }
3568 };
3569 let ctx = OrderContext {
3570 instrument: instrument.clone(),
3571 side: order::action_to_side(&data.order.action),
3572 price: shape.price,
3573 quantity: parse_decimal_or_warn(data.order.total_quantity, "total_quantity"),
3574 kind: shape.kind,
3575 time_in_force: shape.time_in_force,
3576 };
3577 match order_ref {
3578 Some(cid) if adopt_listed_order(order_ids, &cid, ib_id, &ctx) => (cid, ctx, true),
3579 // Not trackable under its id, which names another order here, so not reported
3580 // under it either: two orders would share it.
3581 Some(_) | None => (ClientOrderId::new(format_smolstr!("{ib_id}")), ctx, false),
3582 }
3583 }
3584 };
3585
3586 Some(ListedOpenOrder {
3587 instrument,
3588 order: Some(Order {
3589 key: OrderKey {
3590 exchange: ExchangeId::Ibkr,
3591 instrument: ctx.instrument,
3592 strategy: StrategyId::unknown(),
3593 cid,
3594 },
3595 side: ctx.side,
3596 price: ctx.price,
3597 quantity: ctx.quantity,
3598 kind: ctx.kind,
3599 time_in_force: ctx.time_in_force,
3600 state: Open::new(
3601 VenueOrderId::Assigned(execution::ib_order_id(ib_id)),
3602 Utc::now(),
3603 filled,
3604 ),
3605 }),
3606 named_as_placed,
3607 paired_with_status: status.is_some(),
3608 })
3609}
3610
3611/// The open orders of an account snapshot, by instrument, each instrument's with whether they are
3612/// all of its open orders.
3613#[derive(Debug)]
3614struct SnapshotOrders {
3615 by_instrument: fnv::FnvHashMap<InstrumentNameExchange, (Vec<UnindexedOrderSnapshot>, bool)>,
3616 /// Whether the listing was read to its end, so an instrument it shows no order on has none.
3617 read: bool,
3618}
3619
3620impl SnapshotOrders {
3621 /// The orders of `listed`, the listing read, or `None` when it could not be read.
3622 ///
3623 /// An instrument's orders are complete when each of them was read back
3624 /// [exactly](ListedOpenOrder::is_exact): IB lists every open order of the account, unpaged,
3625 /// so only one this client cannot name as placed, or read back, can be missing from them.
3626 fn new(listed: Option<Vec<ListedOpenOrder>>) -> Self {
3627 let read = listed.is_some();
3628 let mut by_instrument = fnv::FnvHashMap::default();
3629 for listed in listed.into_iter().flatten() {
3630 let exact = listed.is_exact();
3631 let (orders, complete): &mut (Vec<_>, bool) = by_instrument
3632 .entry(listed.instrument)
3633 .or_insert_with(|| (Vec::new(), true));
3634 *complete &= exact;
3635 if let Some(order) = listed.order {
3636 orders.push(order.into());
3637 }
3638 }
3639 Self {
3640 by_instrument,
3641 read,
3642 }
3643 }
3644
3645 /// The instruments with an open order.
3646 fn instruments(&self) -> impl Iterator<Item = &InstrumentNameExchange> {
3647 self.by_instrument.keys()
3648 }
3649
3650 /// Take `instrument`'s orders, and whether they are complete.
3651 fn take(&mut self, instrument: &InstrumentNameExchange) -> (Vec<UnindexedOrderSnapshot>, bool) {
3652 self.by_instrument
3653 .remove(instrument)
3654 .unwrap_or_else(|| (Vec::new(), self.read))
3655 }
3656
3657 /// The instruments not yet taken, with their orders.
3658 fn into_rest(
3659 self,
3660 ) -> impl Iterator<Item = (InstrumentNameExchange, (Vec<UnindexedOrderSnapshot>, bool))> {
3661 self.by_instrument.into_iter()
3662 }
3663}
3664
3665/// The client order ids of this API client's orders in an open-orders listing, for the account
3666/// stream's check of how the orders it holds as live ended: each order's reference, or else the
3667/// id this client tracks it under.
3668///
3669/// Unlike [`open_orders_from_listing`], nothing is read back or adopted, and an order IB lists
3670/// with a status that has ended is kept. IB's completed orders may not list it yet, and its end
3671/// reaches the account stream as that status, so the check need not look it up.
3672fn listed_cids(
3673 listing: impl IntoIterator<Item = ibapi::orders::Orders>,
3674 api_client_id: i32,
3675 order_ids: &OrderIdMap,
3676) -> fnv::FnvHashSet<ClientOrderId> {
3677 listing
3678 .into_iter()
3679 .filter_map(|item| match item {
3680 ibapi::orders::Orders::OrderData(data) if data.order.client_id == api_client_id => {
3681 if data.order.order_ref.is_empty() {
3682 order_ids.get_client_id(data.order_id)
3683 } else {
3684 Some(ClientOrderId::new(data.order.order_ref.as_str()))
3685 }
3686 }
3687 _ => None,
3688 })
3689 .collect()
3690}
3691
3692/// Hold each order of `listed` named by the client order id it was placed with as live, for the
3693/// account stream's check of how it ended if the stream misses its end. One named by its IB order
3694/// id could not be looked up by it once ended.
3695fn remember_listed(known: &SharedKnownLiveOrders, listed: &[ListedOpenOrder]) {
3696 let mut known = known.lock();
3697 for listed in listed.iter().filter(|listed| listed.named_as_placed) {
3698 if let Some(order) = &listed.order {
3699 known.live(&order.key, order.quantity, order.kind, &order.state);
3700 }
3701 }
3702}
3703
3704/// Track the listed open order `ib_id` under `cid`, the id its order reference carries, unless
3705/// either already names an order here. Returns whether it is tracked.
3706fn adopt_listed_order(
3707 order_ids: &OrderIdMap,
3708 cid: &ClientOrderId,
3709 ib_id: i32,
3710 ctx: &OrderContext,
3711) -> bool {
3712 match order_ids.adopt(cid.clone(), ib_id, ctx.clone()) {
3713 Ok(()) => {
3714 info!(
3715 ib_order_id = ib_id,
3716 %cid,
3717 instrument = %ctx.instrument,
3718 "Adopted an open IBKR order this client did not track"
3719 );
3720 true
3721 }
3722 Err(refused) => {
3723 warn!(
3724 ib_order_id = ib_id,
3725 %cid,
3726 reason = %refused,
3727 "Listed IBKR order cannot be tracked under its client order id; listing it under \
3728 its IB order id"
3729 );
3730 false
3731 }
3732 }
3733}
3734
3735/// How long after a good-till-date order's expiry a cancellation is still read as the expiry,
3736/// rather than as a cancellation before it: IB's clock and this host's differ, and the status
3737/// takes time to arrive.
3738const GTD_EXPIRY_TOLERANCE: chrono::Duration = chrono::Duration::seconds(5);
3739
3740/// Whether an order IB reports `Cancelled` at `at` expired rather than was cancelled: a cancel of
3741/// it was not requested through this client (`user_cancel`), and it is a day order, or a
3742/// good-till-date order at or past its expiry, give or take [`GTD_EXPIRY_TOLERANCE`]. An order of
3743/// any other time in force was cancelled by the broker or the exchange.
3744fn cancelled_at_expiry(user_cancel: bool, time_in_force: &TimeInForce, at: DateTime<Utc>) -> bool {
3745 !user_cancel
3746 && match time_in_force {
3747 TimeInForce::GoodUntilEndOfDay => true,
3748 TimeInForce::GoodTillDate { expiry } => at + GTD_EXPIRY_TOLERANCE >= *expiry,
3749 _ => false,
3750 }
3751}
3752
3753/// Build an Order from IB OrderStatus using stored OrderContext.
3754///
3755/// # IBKR Status Mapping
3756///
3757/// | `OrderStatusKind` | → OrderState | Rationale |
3758/// |---------------------------------------------|----------------------|---------------------------------------------|
3759/// | `Inactive` | OpenFailed(Rejected) | Order blocked by validation/margin/exchange |
3760/// | `Cancelled` | Cancelled or Expired | See differentiation logic below |
3761/// | `Filled` | FullyFilled | Order fully executed |
3762/// | `Submitted`/`PreSubmitted`/`PendingSubmit` | Active(Open) | Order working on exchange |
3763/// | `ApiPending`/`PendingCancel`/`ApiCancelled` | Active(Open) | Transitional; await a confirmed terminal |
3764/// | `Unknown(raw)` | Active(Open) + warn | Unmodelled status; stream is authoritative |
3765///
3766/// The match is exhaustive over `OrderStatusKind` (no wildcard), so a new ibapi
3767/// *variant* becomes a compile error rather than a silent `Active(Open)`.
3768///
3769/// Since ibapi 4.0 a new TWS *status string* no longer reaches that guard: it
3770/// decodes to `OrderStatusKind::Unknown(raw)` instead of failing the whole
3771/// subscription with `Error::Parse`. This function handles that variant
3772/// explicitly and logs it at `warn!`, so an unrecognised status is observable
3773/// rather than silent — but it is no longer a compile error.
3774///
3775/// # Cancelled vs Expired Differentiation
3776///
3777/// IBKR sends `"Cancelled"` status for both user-initiated cancellation and
3778/// time-based expiration. We differentiate using:
3779///
3780/// 1. If order ID is in `pending_cancels` (set by `cancel_order`) → `Cancelled`
3781/// 2. Else if `time_in_force == GoodUntilEndOfDay` → `Expired` (DAY order expired)
3782/// 3. Else if `time_in_force` is `GoodTillDate` and its expiry has passed, give or take
3783/// [`GTD_EXPIRY_TOLERANCE`] → `Expired`
3784/// 4. Else → `Cancelled` (broker-initiated or external cancellation)
3785///
3786/// # Known Limitation
3787///
3788/// If the broker cancels a DAY order before market close (e.g., insufficient margin),
3789/// or a GTD order is cancelled outside this client, as in TWS, within
3790/// [`GTD_EXPIRY_TOLERANCE`] of its expiry, it will be misclassified as `Expired`. This
3791/// is rare and acceptable given the alternative (forking ibapi to preserve order_id in
3792/// error callbacks).
3793fn make_order_from_status(
3794 status: &ibapi::orders::OrderStatus,
3795 client_id: ClientOrderId,
3796 ctx: &OrderContext,
3797 pending_cancels: &PendingCancels,
3798) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3799{
3800 use ibapi::orders::OrderStatusKind;
3801
3802 let ib_id = status.order_id;
3803 let order_id = execution::ib_order_id(ib_id);
3804
3805 // `None` when IB reports a fill that is not a number, so that an ended order does not report
3806 // an unknown fill as zero.
3807 let reported_fill = try_decimal_or_warn(
3808 status.filled,
3809 format_args!("status.filled of order {ib_id}"),
3810 );
3811 // A live order's fill only grows, so an unknown one reads as nothing filled until IB reports
3812 // more.
3813 let filled_qty = reported_fill.unwrap_or(Decimal::ZERO);
3814 let state = match status.status {
3815 OrderStatusKind::Inactive => {
3816 // "Inactive" means the order was accepted by IB but is not working:
3817 // validation failure, margin issue, exchange closed, share location hold.
3818 // This is a placement failure, not a cancellation.
3819 OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
3820 "IB status: Inactive (order blocked by validation/margin/exchange)".into(),
3821 )))
3822 }
3823 OrderStatusKind::Cancelled => {
3824 let now = Utc::now();
3825 if cancelled_at_expiry(pending_cancels.remove(ib_id), &ctx.time_in_force, now) {
3826 OrderState::inactive(Expired::new(order_id, now, reported_fill))
3827 } else {
3828 OrderState::inactive(Cancelled::new(order_id, now, reported_fill))
3829 }
3830 }
3831 OrderStatusKind::Filled => {
3832 // A filled order filled its whole quantity, whether or not IB's figure parsed.
3833 let filled_qty = reported_fill.unwrap_or(ctx.quantity);
3834 OrderState::fully_filled(Filled::new(order_id, Utc::now(), filled_qty, None))
3835 }
3836 // Working states (Submitted/PreSubmitted/PendingSubmit) and the
3837 // transitional ones (ApiPending/PendingCancel/ApiCancelled) are reported
3838 // as active/Open until a confirmed Cancelled/Inactive/Filled arrives.
3839 // ApiCancelled is deliberately NOT treated as terminal here: it precedes
3840 // a confirmed Cancelled, whose event reaps the order-id mapping (or
3841 // clear_stale does), mirroring the account_stream `is_terminal` guard.
3842 // Enumerated explicitly so a future ibapi variant fails to compile rather
3843 // than silently defaulting to Open.
3844 OrderStatusKind::Submitted
3845 | OrderStatusKind::PreSubmitted
3846 | OrderStatusKind::PendingSubmit
3847 | OrderStatusKind::ApiPending
3848 | OrderStatusKind::PendingCancel
3849 | OrderStatusKind::ApiCancelled => OrderState::active(Open::new(
3850 VenueOrderId::Assigned(order_id),
3851 Utc::now(),
3852 filled_qty,
3853 )),
3854 // A status string ibapi does not model. Upstream declines to classify
3855 // it — `is_active()` and `is_terminal()` are both false — and so do we:
3856 // report it active/Open so the order-id mapping is retained and the
3857 // account stream can still resolve the order's real state. The
3858 // alternative, treating it as a rejection, would drop the mapping for
3859 // an order that may well be live in the market and discard its
3860 // executions and commissions. An order that is in fact dead and left
3861 // Open here is reaped by `OrderIdMap::clear_stale`.
3862 OrderStatusKind::Unknown(ref raw) => {
3863 warn!(
3864 ib_id,
3865 status = raw.as_str(),
3866 "unmodelled IBKR order status; treating as live, account \
3867 stream is authoritative"
3868 );
3869 OrderState::active(Open::new(
3870 VenueOrderId::Assigned(order_id),
3871 Utc::now(),
3872 filled_qty,
3873 ))
3874 }
3875 };
3876
3877 Order {
3878 key: OrderKey {
3879 exchange: ExchangeId::Ibkr,
3880 instrument: ctx.instrument.clone(),
3881 strategy: StrategyId::unknown(),
3882 cid: client_id,
3883 },
3884 side: ctx.side,
3885 price: ctx.price,
3886 quantity: ctx.quantity,
3887 kind: ctx.kind,
3888 time_in_force: ctx.time_in_force,
3889 state,
3890 }
3891}
3892
3893pub use execution::parse_ib_timestamp;
3894
3895impl BracketOrderClient for IbkrClient {
3896 async fn open_bracket_order(
3897 &self,
3898 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
3899 ) -> UnifiedBracketOrderResult {
3900 let ibkr_request = BracketOrderRequest {
3901 instrument: request.key.instrument.clone(),
3902 strategy: request.key.strategy.clone(),
3903 parent_cid: request.key.cid.clone(),
3904 side: request.state.side,
3905 quantity: request.state.quantity,
3906 entry_price: request.state.entry_price,
3907 take_profit_price: request.state.take_profit_price,
3908 stop_loss_price: request.state.stop_loss_price,
3909 time_in_force: request.state.time_in_force,
3910 };
3911
3912 let result = self.open_bracket_order(ibkr_request).await;
3913
3914 UnifiedBracketOrderResult::with_all_legs(
3915 result.parent,
3916 result.take_profit,
3917 result.stop_loss,
3918 )
3919 }
3920}
3921
3922/// Looks each order up in IB's listing of the account's completed orders, by the order reference
3923/// that carries the client order id it was placed with, so it finds an order this client placed
3924/// before a restart too. One listing is read per call, and, when an order ended cancelled or
3925/// expired, one read of the last seven days' executions for its fill.
3926///
3927/// A filled order is reported fully filled, without an average price; a cancelled or expired one
3928/// with the fill its executions show, which is nothing when they show none of it and the order
3929/// was placed through this client under five days ago, since the read then covers its whole life,
3930/// and otherwise unknown (`None`); a placement IB accepted and then rejected, or left `Inactive`,
3931/// as [`OpenFailed`](crate::order::state::InactiveOrderState::OpenFailed). A cancelled order is
3932/// reported expired by the rule [`ExecutionClient::account_stream`] applies. An order IB lists
3933/// under no client order id, such as one entered in TWS, cannot be found.
3934///
3935/// IB lists the orders completed before a Gateway restart under API client id 0, so a completed
3936/// order is matched by its client order id whichever of this client's id and 0 it is listed
3937/// under. Client order ids must therefore be unique across the API clients on the account.
3938///
3939/// When IB lists several completed orders under one id, the one that completed last is reported.
3940/// One that completed more than 5 seconds before this client began tracking the order now under
3941/// that id is an earlier order's, under a reused id, and the order is not reported ended, with a
3942/// warning: should IB's clock lag this host's by more, an order that did end is held as live.
3943///
3944/// The account stream runs the same lookup itself after a gap in event delivery; see
3945/// [`ExecutionClient::account_stream`].
3946///
3947/// # Errors
3948///
3949/// [`UnindexedClientError::Internal`] when a listing fails, IB sends nothing for 10 seconds
3950/// before the end of one, or the connection drops during it.
3951impl OrderStatusClient for IbkrClient {
3952 async fn fetch_ended_orders(
3953 &self,
3954 orders: &[crate::order::UnindexedOrderKey],
3955 ) -> Result<Vec<crate::order::UnindexedInactiveOrder>, UnindexedClientError> {
3956 if orders.is_empty() {
3957 return Ok(Vec::new());
3958 }
3959 let orders = orders.to_vec();
3960 let client = self.client.clone();
3961 let listings = self.listings.clone();
3962 let contracts = self.contracts.clone();
3963 let order_ids = self.order_ids.clone();
3964 let pending_cancels = self.pending_cancels.clone();
3965 let earlier_completions = self.earlier_completions.clone();
3966 tokio::task::spawn_blocking(move || {
3967 let reader = ended_orders::EndedOrderReader::new(
3968 &client,
3969 &listings,
3970 &contracts,
3971 &order_ids,
3972 &pending_cancels,
3973 &earlier_completions,
3974 );
3975 // Each lookup is answered from listings read on this thread, so the futures are
3976 // ready at once and nothing awaits the runtime.
3977 futures::executor::block_on(crate::client::order_recovery::fetch_ended_by_key(
3978 &orders,
3979 |key| std::future::ready(reader.lookup(&key)),
3980 ))
3981 })
3982 .await
3983 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
3984 }
3985}
3986
3987#[cfg(test)]
3988#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
3989mod contract_config_tests {
3990 use super::*;
3991 use contract::ContractConfigError as E;
3992
3993 /// A `ContractConfig` with every optional field present, so individual tests
3994 /// can null out exactly the field under test.
3995 fn full_config(security_type: &str) -> ContractConfig {
3996 ContractConfig {
3997 name: "TEST".to_string(),
3998 symbol: "AAPL".to_string(),
3999 security_type: security_type.to_string(),
4000 exchange: "SMART".to_string(),
4001 currency: "USD".to_string(),
4002 last_trade_date: Some("20260116".to_string()),
4003 strike: Some(150.0),
4004 right: Some("C".to_string()),
4005 }
4006 }
4007
4008 #[test]
4009 fn stk_and_cash_ignore_option_fields() {
4010 // STK/CASH never read last_trade_date/strike/right, so absence is fine.
4011 let mut stk = full_config("STK");
4012 stk.last_trade_date = None;
4013 stk.strike = None;
4014 stk.right = None;
4015 assert!(stk.to_contract().is_ok());
4016
4017 let mut cash = full_config("CASH");
4018 cash.last_trade_date = None;
4019 cash.strike = None;
4020 cash.right = None;
4021 assert!(cash.to_contract().is_ok());
4022 }
4023
4024 #[test]
4025 fn valid_fut_and_opt_build() {
4026 assert!(full_config("FUT").to_contract().is_ok());
4027 assert!(full_config("OPT").to_contract().is_ok());
4028 }
4029
4030 #[test]
4031 fn opt_missing_right_is_error_not_silent_call() {
4032 let mut cfg = full_config("OPT");
4033 cfg.right = None;
4034 assert_eq!(cfg.to_contract().unwrap_err(), E::MissingOptionRight);
4035 }
4036
4037 #[test]
4038 fn opt_unrecognized_right_is_error() {
4039 let mut cfg = full_config("OPT");
4040 cfg.right = Some("X".to_string());
4041 assert_eq!(
4042 cfg.to_contract().unwrap_err(),
4043 E::UnrecognizedOptionRight {
4044 right: "X".to_string()
4045 }
4046 );
4047 }
4048
4049 #[test]
4050 fn opt_missing_strike_is_error() {
4051 let mut cfg = full_config("OPT");
4052 cfg.strike = None;
4053 assert_eq!(cfg.to_contract().unwrap_err(), E::MissingStrike);
4054 }
4055
4056 #[test]
4057 fn fut_and_opt_missing_last_trade_date_is_error() {
4058 let mut fut = full_config("FUT");
4059 fut.last_trade_date = None;
4060 assert_eq!(fut.to_contract().unwrap_err(), E::MissingLastTradeDate);
4061
4062 let mut opt = full_config("OPT");
4063 opt.last_trade_date = None;
4064 assert_eq!(opt.to_contract().unwrap_err(), E::MissingLastTradeDate);
4065 }
4066
4067 #[test]
4068 fn unknown_security_type_is_error_not_silent_stk() {
4069 let cfg = full_config("BOND");
4070 assert_eq!(
4071 cfg.to_contract().unwrap_err(),
4072 E::UnrecognizedSecurityType {
4073 security_type: "BOND".to_string()
4074 }
4075 );
4076 }
4077
4078 fn ibkr_config(contracts: Vec<ContractConfig>) -> IbkrConfig {
4079 IbkrConfig::new("127.0.0.1", 4002, 1, "").with_contracts(contracts)
4080 }
4081
4082 fn named(name: &str, security_type: &str) -> ContractConfig {
4083 ContractConfig {
4084 name: name.to_string(),
4085 ..full_config(security_type)
4086 }
4087 }
4088
4089 #[test]
4090 fn validate_config_accepts_each_security_type_its_kind_takes() {
4091 let names = ["STOCK", "FX", "FUTURE", "OPTION"].map(InstrumentNameExchange::new);
4092 let instruments = [
4093 ClientInstrument::new(&names[0], InstrumentKindDiscriminant::Spot),
4094 ClientInstrument::new(&names[1], InstrumentKindDiscriminant::Spot),
4095 ClientInstrument::new(&names[2], InstrumentKindDiscriminant::Future),
4096 ClientInstrument::new(&names[3], InstrumentKindDiscriminant::Option),
4097 ];
4098 let config = ibkr_config(vec![
4099 named("STOCK", "STK"),
4100 named("FX", "CASH"),
4101 named("FUTURE", "FUT"),
4102 named("OPTION", "OPT"),
4103 ]);
4104
4105 assert_eq!(IbkrClient::validate_config(&config, &instruments), Ok(()));
4106 }
4107
4108 /// The disagreement the kind gate cannot see: a stock contract registered for an instrument
4109 /// the engine models as an option would route option orders as stock orders.
4110 #[test]
4111 fn validate_config_rejects_a_security_type_its_instrument_kind_contradicts() {
4112 let name = InstrumentNameExchange::new("AAPL-C150");
4113 let instruments = [ClientInstrument::new(
4114 &name,
4115 InstrumentKindDiscriminant::Option,
4116 )];
4117 let config = ibkr_config(vec![named("AAPL-C150", "STK")]);
4118
4119 let error = IbkrClient::validate_config(&config, &instruments).unwrap_err();
4120 assert!(error.contains("AAPL-C150"), "{error}");
4121 assert!(error.contains("STK"), "{error}");
4122 assert!(error.contains("OPT"), "{error}");
4123 }
4124
4125 /// `connect_sync` refuses an invalid entry only once connected; built through
4126 /// `ExecutionBuilder`, it fails the build before connecting. Every problem is reported, not just the first.
4127 #[test]
4128 fn validate_config_reports_every_invalid_or_contradicting_entry() {
4129 let name = InstrumentNameExchange::new("ES");
4130 let instruments = [ClientInstrument::new(
4131 &name,
4132 InstrumentKindDiscriminant::Future,
4133 )];
4134 let mut missing_date = named("NQ", "FUT");
4135 missing_date.last_trade_date = None;
4136 let config = ibkr_config(vec![named("ES", "STK"), missing_date]);
4137
4138 let error = IbkrClient::validate_config(&config, &instruments).unwrap_err();
4139 assert!(error.contains("\"ES\""), "{error}");
4140 assert!(error.contains("\"NQ\""), "{error}");
4141 }
4142
4143 /// Every (kind, security type) pair: accepted exactly where `security_types_of` lists it, and
4144 /// a kind this client cannot trade rejected whatever the type.
4145 #[test]
4146 fn validate_config_checks_every_kind_against_every_security_type() {
4147 use InstrumentKindDiscriminant as K;
4148 let accepted = [
4149 (K::Spot, "STK"),
4150 (K::Spot, "CASH"),
4151 (K::Future, "FUT"),
4152 (K::Option, "OPT"),
4153 ];
4154 let name = InstrumentNameExchange::new("X");
4155 for kind in [K::Spot, K::Perpetual, K::Future, K::Option, K::Cfd] {
4156 for security_type in ["STK", "CASH", "FUT", "OPT"] {
4157 let instruments = [ClientInstrument::new(&name, kind)];
4158 let config = ibkr_config(vec![named("X", security_type)]);
4159 assert_eq!(
4160 IbkrClient::validate_config(&config, &instruments).is_ok(),
4161 accepted.contains(&(kind, security_type)),
4162 "{kind} with {security_type}"
4163 );
4164 }
4165 }
4166 }
4167
4168 /// `security_types_of` is written separately from `to_contract`'s match, so a type added
4169 /// there must be added here too: an entry naming a type no kind lists fails every check.
4170 #[test]
4171 fn security_types_cover_every_type_to_contract_builds() {
4172 use InstrumentKindDiscriminant as K;
4173 let listed = [K::Spot, K::Perpetual, K::Future, K::Option, K::Cfd]
4174 .into_iter()
4175 .flat_map(security_types_of)
4176 .copied()
4177 .collect::<Vec<_>>();
4178
4179 // Every type listed must be one `to_contract` can build.
4180 for security_type in &listed {
4181 assert!(
4182 full_config(security_type).to_contract().is_ok(),
4183 "{security_type}"
4184 );
4185 }
4186
4187 // Every type `to_contract` builds must be listed. A `match` cannot be enumerated, so this
4188 // names them by hand, and a type added to `to_contract` must be added here as well.
4189 // `BOND` stands for a type it does not build.
4190 for security_type in ["STK", "CASH", "FUT", "OPT", "BOND"] {
4191 assert_eq!(
4192 full_config(security_type).to_contract().is_ok(),
4193 listed.contains(&security_type),
4194 "{security_type}"
4195 );
4196 }
4197 }
4198
4199 /// A kind this client cannot trade is named as such, rather than as a mismatch with no
4200 /// security type to expect.
4201 #[test]
4202 fn validate_config_names_a_kind_this_client_cannot_trade() {
4203 let name = InstrumentNameExchange::new("X");
4204 let instruments = [ClientInstrument::new(
4205 &name,
4206 InstrumentKindDiscriminant::Perpetual,
4207 )];
4208 let config = ibkr_config(vec![named("X", "STK")]);
4209
4210 let error = IbkrClient::validate_config(&config, &instruments).unwrap_err();
4211 assert!(error.contains("perpetual"), "{error}");
4212 assert!(error.contains("cannot trade"), "{error}");
4213 }
4214
4215 /// A contract can also be registered later with `register_contract`, so neither side of an
4216 /// unmatched pair is an error here.
4217 #[test]
4218 fn validate_config_accepts_entries_and_instruments_without_a_counterpart() {
4219 let name = InstrumentNameExchange::new("MSFT");
4220 let instruments = [ClientInstrument::new(
4221 &name,
4222 InstrumentKindDiscriminant::Spot,
4223 )];
4224 let config = ibkr_config(vec![named("SOMETHING-ELSE", "OPT")]);
4225
4226 assert_eq!(IbkrClient::validate_config(&config, &instruments), Ok(()));
4227 }
4228}
4229
4230#[cfg(test)]
4231#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
4232mod order_status_tests {
4233 use super::*;
4234 use crate::order::state::ActiveOrderState;
4235 use ibapi::orders::{OrderStatus, OrderStatusKind, PlaceOrder};
4236
4237 fn status(kind: OrderStatusKind, filled: f64) -> Result<PlaceOrder, ibapi::Error> {
4238 Ok(PlaceOrder::OrderStatus(OrderStatus {
4239 order_id: 42,
4240 status: kind,
4241 filled,
4242 ..OrderStatus::default()
4243 }))
4244 }
4245
4246 fn ctx() -> OrderContext {
4247 OrderContext {
4248 instrument: InstrumentNameExchange::new("AAPL"),
4249 side: Side::Buy,
4250 price: Some(Decimal::from(150)),
4251 quantity: Decimal::from(10),
4252 kind: OrderKind::Limit,
4253 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
4254 }
4255 }
4256
4257 /// ibapi 4.0 preserves an unrecognised TWS status as
4258 /// `OrderStatusKind::Unknown(raw)` instead of failing the subscription with
4259 /// `Error::Parse`. Placement must treat it as live-but-indeterminate: the
4260 /// order exists, so the order-id mapping has to survive for the account
4261 /// stream to resolve it.
4262 #[test]
4263 fn unknown_status_is_held_pending_during_placement() {
4264 let events = vec![status(OrderStatusKind::Unknown("Reclassified".into()), 0.0)];
4265
4266 match await_order_placement(events) {
4267 PlacementOutcome::HeldPending(reason) => {
4268 assert!(
4269 reason.contains("Reclassified"),
4270 "the raw status must reach the caller, got {reason:?}"
4271 );
4272 }
4273 other => panic!("expected HeldPending, got {other:?}"),
4274 }
4275 }
4276
4277 /// Specifically *not* the `ApiPending` treatment. Falling through to the
4278 /// `PLACEMENT_STATUS_TIMEOUT` backstop yields `NoStatus`, which on the
4279 /// bracket path cancels all three legs — so an unrecognised status must
4280 /// produce a decision here rather than deferring to the timeout.
4281 #[test]
4282 fn unknown_status_does_not_fall_through_to_no_status() {
4283 let events = vec![status(OrderStatusKind::Unknown("Reclassified".into()), 0.0)];
4284 assert!(!matches!(
4285 await_order_placement(events),
4286 PlacementOutcome::NoStatus
4287 ));
4288
4289 // ApiPending, by contrast, keeps waiting and exhausts the iterator.
4290 let events = vec![status(OrderStatusKind::ApiPending, 0.0)];
4291 assert!(matches!(
4292 await_order_placement(events),
4293 PlacementOutcome::NoStatus
4294 ));
4295 }
4296
4297 /// ibapi 4.2 fails an in-flight placement with a transport error the moment
4298 /// the socket drops. TWS may already hold the order, so it must stay
4299 /// trackable (`NoStatus` keeps the order-id mapping), not read as rejected.
4300 #[test]
4301 fn transport_loss_while_awaiting_placement_is_no_status() {
4302 for e in [
4303 ibapi::Error::ConnectionReset,
4304 ibapi::Error::ConnectionFailed,
4305 ibapi::Error::Shutdown,
4306 ibapi::Error::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
4307 ] {
4308 let label = e.to_string();
4309 assert!(
4310 matches!(
4311 await_order_placement(vec![Err(e)]),
4312 PlacementOutcome::NoStatus
4313 ),
4314 "{label} must leave the placement unresolved, not rejected"
4315 );
4316 }
4317
4318 // A status that arrived before the drop still decides.
4319 let events = vec![
4320 status(OrderStatusKind::Submitted, 0.0),
4321 Err(ibapi::Error::ConnectionReset),
4322 ];
4323 assert!(matches!(
4324 await_order_placement(events),
4325 PlacementOutcome::Accepted { .. }
4326 ));
4327 }
4328
4329 fn order_notice(code: i32, message: &str) -> Result<PlaceOrder, ibapi::Error> {
4330 Err(ibapi::Error::Notice(ibapi::Notice {
4331 request_id: Some(42),
4332 code,
4333 message: message.to_string(),
4334 error_time: None,
4335 advanced_order_reject_json: String::new(),
4336 }))
4337 }
4338
4339 /// IBKR holds an order on 399 (until the session opens) and on 404 (while it locates shares
4340 /// for a short sale). Either way the order is live, so placement must not report it rejected.
4341 #[test]
4342 fn held_order_notices_are_held_pending_not_rejected() {
4343 // The notices below are built by hand, so pin the ibapi routing they stand for: a 404 is
4344 // a plain error, which ends the placement subscription as `Err(Error::Notice)`. Should a
4345 // later ibapi make it informational, it would be dropped before reaching this code, and
4346 // `INFORMATIONAL_ORDER_CODES`' account of it would need revisiting.
4347 let held_for_locate = ibapi::Notice {
4348 request_id: Some(42),
4349 code: 404,
4350 message: String::new(),
4351 error_time: None,
4352 advanced_order_reject_json: String::new(),
4353 };
4354 assert_eq!(held_for_locate.category(), ibapi::NoticeCategory::Error);
4355
4356 for (code, message) in [
4357 (
4358 399,
4359 "Order Message:\nSELL 100 AAPL NASDAQ.NMS\nYour order will not be placed at the \
4360 exchange until 2026-10-12 09:30:00 US/Eastern.",
4361 ),
4362 (
4363 404,
4364 "Shares for this order are not immediately available for short sale. The order \
4365 will be held while we attempt to locate the shares.",
4366 ),
4367 ] {
4368 match await_order_placement(vec![order_notice(code, message)]) {
4369 PlacementOutcome::HeldPending(reason) => assert!(
4370 reason.starts_with(&format!("[{code}] ")),
4371 "the code and text must reach the caller, got {reason:?}"
4372 ),
4373 other => panic!("expected HeldPending for {code}, got {other:?}"),
4374 }
4375 }
4376 }
4377
4378 /// A notice that is not a known hold, such as a 201 rejection, still rejects the placement.
4379 #[test]
4380 fn other_order_notices_are_rejected() {
4381 let events = vec![order_notice(
4382 201,
4383 "Order rejected - reason:YOUR ORDER IS NOT ACCEPTED.",
4384 )];
4385 assert!(matches!(
4386 await_order_placement(events),
4387 PlacementOutcome::Rejected(reason) if reason.contains("[201]")
4388 ));
4389 }
4390
4391 #[test]
4392 fn non_transport_error_while_awaiting_placement_is_rejected() {
4393 let events = vec![Err(ibapi::Error::Simple("boom".into()))];
4394 assert!(matches!(
4395 await_order_placement(events),
4396 PlacementOutcome::Rejected(reason) if reason.contains("boom")
4397 ));
4398 }
4399
4400 /// A send refused because the transport is down or reconnecting never
4401 /// reached TWS: it is a transient connectivity error, not a venue rejection.
4402 #[test]
4403 fn send_error_separates_transport_loss_from_refusal() {
4404 let refused: OrderError<AssetNameExchange, InstrumentNameExchange> =
4405 send_error(&ibapi::Error::ConnectionReset, "down".into());
4406 assert!(
4407 matches!(&refused, OrderError::Connectivity(ConnectivityError::Socket(m)) if m == "down")
4408 );
4409 assert!(refused.is_transient());
4410
4411 let io: OrderError<AssetNameExchange, InstrumentNameExchange> = send_error(
4412 &ibapi::Error::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
4413 "io".into(),
4414 );
4415 assert!(io.is_transient());
4416 assert!(
4417 io.is_outcome_unknown(),
4418 "a failed write may have sent part of it"
4419 );
4420
4421 // ibapi has given up reconnecting: retrying cannot succeed.
4422 for e in [
4423 ibapi::Error::Shutdown,
4424 ibapi::Error::ConnectionFailed,
4425 ibapi::Error::Simple("no".into()),
4426 ] {
4427 let label = e.to_string();
4428 let rejected: OrderError<AssetNameExchange, InstrumentNameExchange> =
4429 send_error(&e, "no".into());
4430 assert!(
4431 matches!(&rejected, OrderError::Rejected(ApiError::OrderRejected(m)) if m == "no"),
4432 "{label} must be a non-transient refusal"
4433 );
4434 assert!(!rejected.is_transient(), "{label}");
4435 }
4436 }
4437
4438 /// An open ibapi refused with `ConnectionReset` was never written, so the order was not
4439 /// placed. Every other send error is classified as for a cancel, an `Io` one leaving the
4440 /// outcome unknown.
4441 #[test]
4442 fn open_send_error_reports_a_refused_send_as_not_placed() {
4443 let refused: OrderError<AssetNameExchange, InstrumentNameExchange> =
4444 open_send_error(&ibapi::Error::ConnectionReset, "down".into());
4445 assert!(matches!(
4446 &refused,
4447 OrderError::NotPlaced(NotPlacedReason::NotSent(ConnectivityError::Socket(m))) if m == "down"
4448 ));
4449 assert!(!refused.is_outcome_unknown());
4450
4451 let io: OrderError<AssetNameExchange, InstrumentNameExchange> = open_send_error(
4452 &ibapi::Error::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
4453 "io".into(),
4454 );
4455 assert!(io.is_outcome_unknown());
4456 }
4457
4458 /// A failed open forgets its IB order id, unless its outcome is unknown: TWS may hold the
4459 /// order, whose status and fills the mapping still routes.
4460 #[test]
4461 fn a_failed_open_keeps_its_ib_order_id_only_when_its_outcome_is_unknown() {
4462 let cid = ClientOrderId::new("failed-open");
4463 let failures: [(OrderError<AssetNameExchange, InstrumentNameExchange>, bool); 3] = [
4464 (
4465 OrderError::Connectivity(ConnectivityError::Socket("io".into())),
4466 true,
4467 ),
4468 (
4469 OrderError::NotPlaced(NotPlacedReason::NotSent(ConnectivityError::Socket(
4470 "refused".into(),
4471 ))),
4472 false,
4473 ),
4474 (
4475 OrderError::Rejected(ApiError::OrderRejected("no".into())),
4476 false,
4477 ),
4478 ];
4479 for (error, kept) in failures {
4480 let order_ids = OrderIdMap::new();
4481 order_ids.register(cid.clone(), 7, ctx()).unwrap();
4482 release_failed_open(&order_ids, 7, &error);
4483 assert_eq!(order_ids.get_client_id(7).is_some(), kept, "{error:?}");
4484 }
4485 }
4486
4487 /// Regression anchors: the statuses that were already decisive must keep
4488 /// their outcomes across the 4.0 migration.
4489 #[test]
4490 fn known_statuses_keep_their_placement_outcomes() {
4491 assert!(matches!(
4492 await_order_placement(vec![status(OrderStatusKind::Submitted, 0.0)]),
4493 PlacementOutcome::Accepted { .. }
4494 ));
4495 assert!(matches!(
4496 await_order_placement(vec![status(OrderStatusKind::Filled, 10.0)]),
4497 PlacementOutcome::Accepted { filled } if filled == 10.0
4498 ));
4499 assert!(matches!(
4500 await_order_placement(vec![status(OrderStatusKind::Inactive, 0.0)]),
4501 PlacementOutcome::Rejected(_)
4502 ));
4503 assert!(matches!(
4504 await_order_placement(vec![status(OrderStatusKind::Cancelled, 0.0)]),
4505 PlacementOutcome::Rejected(_)
4506 ));
4507 }
4508
4509 /// An unmodelled status must not be reported as terminal. Treating it as a
4510 /// rejection would drop the order-id mapping for an order that may well be
4511 /// live in the market, discarding its executions and commissions.
4512 #[test]
4513 fn unknown_status_maps_to_active_open() {
4514 let raw = OrderStatus {
4515 order_id: 42,
4516 status: OrderStatusKind::Unknown("Reclassified".into()),
4517 filled: 3.0,
4518 ..OrderStatus::default()
4519 };
4520
4521 let order = make_order_from_status(
4522 &raw,
4523 ClientOrderId::new("cid-1"),
4524 &ctx(),
4525 &PendingCancels::new(),
4526 );
4527
4528 assert!(
4529 matches!(order.state, OrderState::Active(_)),
4530 "unmodelled status must stay active, got {:?}",
4531 order.state
4532 );
4533 }
4534
4535 /// A cancelled order carries what IB reports filled, and a fill that is not a number stays
4536 /// unknown rather than reading as zero; a filled order filled its whole quantity regardless.
4537 #[test]
4538 fn an_ended_status_carries_its_fill_or_none() {
4539 let ended = |status: OrderStatusKind, filled: f64| {
4540 let raw = OrderStatus {
4541 order_id: 42,
4542 status,
4543 filled,
4544 ..OrderStatus::default()
4545 };
4546 make_order_from_status(
4547 &raw,
4548 ClientOrderId::new("cid-1"),
4549 &ctx(),
4550 &PendingCancels::new(),
4551 )
4552 .state
4553 };
4554 let cancelled_fill = |state: OrderState<_, _>| match state {
4555 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
4556 cancelled.filled_quantity
4557 }
4558 other => panic!("expected Cancelled, got {other:?}"),
4559 };
4560
4561 assert_eq!(
4562 cancelled_fill(ended(OrderStatusKind::Cancelled, 3.0)),
4563 Some(Decimal::from(3))
4564 );
4565 assert_eq!(
4566 cancelled_fill(ended(OrderStatusKind::Cancelled, f64::NAN)),
4567 None
4568 );
4569 assert!(matches!(
4570 ended(OrderStatusKind::Filled, f64::NAN),
4571 OrderState::Inactive(crate::order::state::InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
4572 if filled_quantity == ctx().quantity
4573 ));
4574 }
4575
4576 /// A good-till-date order cancelled at or past its expiry, with no cancel requested, expired.
4577 /// One cancelled before its expiry was cancelled.
4578 #[test]
4579 fn a_good_till_date_order_cancelled_at_its_expiry_expired() {
4580 let cancelled_at = |expiry: DateTime<Utc>| {
4581 let ctx = OrderContext {
4582 time_in_force: TimeInForce::GoodTillDate { expiry },
4583 ..ctx()
4584 };
4585 let raw = OrderStatus {
4586 order_id: 42,
4587 status: OrderStatusKind::Cancelled,
4588 ..OrderStatus::default()
4589 };
4590 make_order_from_status(
4591 &raw,
4592 ClientOrderId::new("cid-1"),
4593 &ctx,
4594 &PendingCancels::new(),
4595 )
4596 .state
4597 };
4598 use crate::order::state::InactiveOrderState;
4599
4600 assert!(matches!(
4601 cancelled_at(Utc::now() - chrono::Duration::minutes(1)),
4602 OrderState::Inactive(InactiveOrderState::Expired(_))
4603 ));
4604 // IB's clock may run ahead of this host's.
4605 assert!(matches!(
4606 cancelled_at(Utc::now() + chrono::Duration::seconds(2)),
4607 OrderState::Inactive(InactiveOrderState::Expired(_))
4608 ));
4609 assert!(matches!(
4610 cancelled_at(Utc::now() + chrono::Duration::hours(1)),
4611 OrderState::Inactive(InactiveOrderState::Cancelled(_))
4612 ));
4613 }
4614
4615 /// An unmodelled status must not consume a pending cancel: the entry has to
4616 /// survive for the confirmed `Cancelled` that may still follow.
4617 #[test]
4618 fn unknown_status_leaves_pending_cancel_intact() {
4619 let pending = PendingCancels::new();
4620 pending.insert(42);
4621
4622 let raw = OrderStatus {
4623 order_id: 42,
4624 status: OrderStatusKind::Unknown("Reclassified".into()),
4625 ..OrderStatus::default()
4626 };
4627 let _ = make_order_from_status(&raw, ClientOrderId::new("cid-1"), &ctx(), &pending);
4628
4629 assert!(
4630 pending.remove(42),
4631 "the pending cancel must still be there for the confirmed Cancelled"
4632 );
4633 }
4634
4635 /// A rejected leg is settled even when its rollback cancel was refused: its
4636 /// terminal status will not be replayed, so keeping it Open would leave a
4637 /// phantom order.
4638 #[test]
4639 fn rejected_leg_with_failed_cancel_is_settled() {
4640 let accepted: Option<Result<f64, String>> = Some(Ok(0.0));
4641 let rejected: Option<Result<f64, String>> = Some(Err("Cancelled".into()));
4642
4643 assert_eq!(
4644 unresolved_legs([(1, &rejected), (2, &accepted), (3, &accepted)], &[1, 2, 3]),
4645 vec![2, 3]
4646 );
4647 }
4648
4649 /// A later leg's send failure keeps `send_error`'s classification only when
4650 /// every rollback cancel went out; otherwise it is a non-transient
4651 /// rejection naming the cancels that failed.
4652 #[test]
4653 fn leg_send_error_is_transient_only_after_a_clean_rollback() {
4654 let clean = leg_send_error(&ibapi::Error::ConnectionReset, "sl failed".into(), "");
4655 assert!(matches!(clean, OrderError::Connectivity(_)));
4656
4657 let dirty = leg_send_error(
4658 &ibapi::Error::ConnectionReset,
4659 "sl failed".into(),
4660 "; rollback cancel failed for order ids 10 (connection reset), which may be live",
4661 );
4662 match dirty {
4663 OrderError::Rejected(ApiError::OrderRejected(message)) => {
4664 assert!(message.starts_with("sl failed; rollback cancel failed"));
4665 }
4666 other => panic!("expected a non-transient rejection, got {other:?}"),
4667 }
4668 }
4669
4670 /// A leg is unresolved when it reported no status, or was accepted and its
4671 /// rollback cancel was refused; a leg whose cancel went out is settled.
4672 #[test]
4673 fn unresolved_legs_are_no_status_or_failed_cancel() {
4674 let accepted: Option<Result<f64, String>> = Some(Ok(0.0));
4675 let rejected: Option<Result<f64, String>> = Some(Err("201".into()));
4676 let no_status: Option<Result<f64, String>> = None;
4677
4678 assert_eq!(
4679 unresolved_legs([(1, &accepted), (2, &rejected), (3, &no_status)], &[]),
4680 vec![3]
4681 );
4682 assert_eq!(
4683 unresolved_legs([(1, &accepted), (2, &rejected), (3, &accepted)], &[1]),
4684 vec![1]
4685 );
4686 assert_eq!(
4687 unresolved_legs([(1, &no_status), (2, &no_status), (3, &accepted)], &[2]),
4688 vec![1, 2]
4689 );
4690 assert!(unresolved_legs([(1, &accepted), (2, &rejected), (3, &accepted)], &[]).is_empty());
4691 }
4692
4693 fn bracket_request() -> BracketOrderRequest {
4694 BracketOrderRequest {
4695 instrument: InstrumentNameExchange::new("AAPL"),
4696 strategy: StrategyId::new("test"),
4697 parent_cid: ClientOrderId::new("br"),
4698 side: Side::Buy,
4699 quantity: Decimal::from(10),
4700 entry_price: Decimal::from(150),
4701 take_profit_price: Decimal::from(160),
4702 stop_loss_price: Decimal::from(145),
4703 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
4704 }
4705 }
4706
4707 /// An unresolved leg comes back Open under its IB id and keeps its mapping,
4708 /// so the account stream can still resolve it; a settled leg comes back
4709 /// Inactive with the error and its mapping is released.
4710 #[test]
4711 fn failed_bracket_keeps_unresolved_legs_open_and_tracked() {
4712 let request = bracket_request();
4713 let (tp_cid, sl_cid) = derive_child_cids(&request.parent_cid);
4714 let order_ids = OrderIdMap::new();
4715 order_ids
4716 .register(request.parent_cid.clone(), 10, ctx())
4717 .unwrap();
4718 order_ids.register(tp_cid, 11, ctx()).unwrap();
4719 order_ids.register(sl_cid, 12, ctx()).unwrap();
4720
4721 let result = failed_bracket(
4722 &request,
4723 [10, 11, 12],
4724 BracketFailure {
4725 error: OrderError::Rejected(ApiError::OrderRejected("boom".into())),
4726 unresolved: vec![11],
4727 },
4728 &order_ids,
4729 );
4730
4731 assert!(matches!(result.parent.state, OrderState::Inactive(_)));
4732 assert!(matches!(result.stop_loss.state, OrderState::Inactive(_)));
4733 match &result.take_profit.state {
4734 OrderState::Active(ActiveOrderState::Open(open)) => {
4735 assert_eq!(open.id.assigned(), Some(&OrderId::new("11")));
4736 assert_eq!(open.filled_quantity, Decimal::ZERO);
4737 }
4738 other => panic!("unresolved leg must be Open, got {other:?}"),
4739 }
4740
4741 assert!(order_ids.get_client_id(10).is_none());
4742 assert!(order_ids.get_client_id(11).is_some());
4743 assert!(order_ids.get_client_id(12).is_none());
4744 }
4745
4746 /// With no unresolved leg the failure keeps its all-inactive shape and
4747 /// releases every mapping.
4748 #[test]
4749 fn failed_bracket_without_unresolved_legs_is_all_inactive() {
4750 let request = bracket_request();
4751 let (tp_cid, sl_cid) = derive_child_cids(&request.parent_cid);
4752 let order_ids = OrderIdMap::new();
4753 order_ids
4754 .register(request.parent_cid.clone(), 10, ctx())
4755 .unwrap();
4756 order_ids.register(tp_cid, 11, ctx()).unwrap();
4757 order_ids.register(sl_cid, 12, ctx()).unwrap();
4758
4759 let result = failed_bracket(
4760 &request,
4761 [10, 11, 12],
4762 BracketFailure {
4763 error: OrderError::Rejected(ApiError::OrderRejected("boom".into())),
4764 unresolved: Vec::new(),
4765 },
4766 &order_ids,
4767 );
4768
4769 for leg in [&result.parent, &result.take_profit, &result.stop_loss] {
4770 assert!(matches!(leg.state, OrderState::Inactive(_)));
4771 }
4772 for ib_id in [10, 11, 12] {
4773 assert!(order_ids.get_client_id(ib_id).is_none());
4774 }
4775 }
4776}
4777
4778#[cfg(test)]
4779#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
4780mod order_reader_tests {
4781 use super::*;
4782 use crate::client::dedup::new_dedup_cache;
4783 use crate::trade::TradeId;
4784 use ibapi::orders::{OrderStatus, OrderUpdate};
4785 use std::cell::Cell;
4786
4787 /// API client `api_client_id`'s own executions alone.
4788 fn scope(api_client_id: i32) -> ExecutionScope {
4789 ExecutionScope {
4790 api_client_id,
4791 other_clients: false,
4792 }
4793 }
4794
4795 /// Status updates for an order this client did not place, so none is forwarded.
4796 fn unforwarded(n: usize) -> Vec<Result<OrderUpdate, ibapi::Error>> {
4797 (0..n)
4798 .map(|_| {
4799 Ok(OrderUpdate::OrderStatus(OrderStatus {
4800 order_id: 999,
4801 ..OrderStatus::default()
4802 }))
4803 })
4804 .collect()
4805 }
4806
4807 /// Run the reader over `updates` and return how many of them it pulled.
4808 fn run(sink: &recovery::EventSink, updates: Vec<Result<OrderUpdate, ibapi::Error>>) -> usize {
4809 let pulled = Cell::new(0);
4810 forward_order_updates(
4811 updates
4812 .into_iter()
4813 .inspect(|_| pulled.set(pulled.get() + 1)),
4814 sink,
4815 scope(0),
4816 &ContractRegistry::new(),
4817 &OrderIdMap::new(),
4818 &PendingCancels::new(),
4819 &ExecutionBuffer::new(),
4820 );
4821 pulled.get()
4822 }
4823
4824 #[test]
4825 fn stops_on_the_first_update_after_the_stream_ends() {
4826 let (tx, _rx) = mpsc::unbounded_channel();
4827 let sink = recovery::EventSink::new(
4828 tx,
4829 new_dedup_cache(),
4830 KnownLiveOrders::shared(ExchangeId::Ibkr),
4831 );
4832 sink.terminate(StreamTerminationReason::Error("recovery failed".into()));
4833
4834 assert_eq!(run(&sink, unforwarded(3)), 1);
4835 }
4836
4837 #[test]
4838 fn stops_on_the_first_update_after_the_consumer_goes() {
4839 let (tx, rx) = mpsc::unbounded_channel();
4840 let sink = recovery::EventSink::new(
4841 tx,
4842 new_dedup_cache(),
4843 KnownLiveOrders::shared(ExchangeId::Ibkr),
4844 );
4845 drop(rx);
4846
4847 assert_eq!(run(&sink, unforwarded(3)), 1);
4848 }
4849
4850 /// How each terminal status leaves an order's client id and IB-id entry: `Filled` frees the
4851 /// id and keeps the entry for late executions, `Cancelled`/`Inactive` remove both, and a
4852 /// working status keeps both.
4853 #[test]
4854 fn a_terminal_status_frees_the_orders_client_id() {
4855 use ibapi::orders::OrderStatusKind;
4856
4857 for (kind, entry_kept, id_held) in [
4858 (OrderStatusKind::Filled, true, false),
4859 (OrderStatusKind::Cancelled, false, false),
4860 (OrderStatusKind::Inactive, false, false),
4861 (OrderStatusKind::Submitted, true, true),
4862 ] {
4863 let (tx, mut rx) = mpsc::unbounded_channel();
4864 let sink = recovery::EventSink::new(
4865 tx,
4866 new_dedup_cache(),
4867 KnownLiveOrders::shared(ExchangeId::Ibkr),
4868 );
4869 let order_ids = OrderIdMap::new();
4870 let cid = ClientOrderId::new("cid-7");
4871 order_ids
4872 .register(
4873 cid.clone(),
4874 7,
4875 OrderContext {
4876 instrument: InstrumentNameExchange::new("AAPL"),
4877 side: Side::Buy,
4878 price: Some(Decimal::from(100)),
4879 quantity: Decimal::ONE,
4880 kind: OrderKind::Limit,
4881 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
4882 },
4883 )
4884 .unwrap();
4885
4886 forward_order_updates(
4887 [Ok(OrderUpdate::OrderStatus(OrderStatus {
4888 order_id: 7,
4889 status: kind.clone(),
4890 ..OrderStatus::default()
4891 }))],
4892 &sink,
4893 scope(0),
4894 &ContractRegistry::new(),
4895 &order_ids,
4896 &PendingCancels::new(),
4897 &ExecutionBuffer::new(),
4898 );
4899
4900 assert!(rx.try_recv().is_ok(), "{kind:?}: the status is forwarded");
4901 assert_eq!(
4902 order_ids.get_client_id(7).is_some(),
4903 entry_kept,
4904 "{kind:?}: entry"
4905 );
4906 assert_eq!(order_ids.get_ib_id(&cid).is_some(), id_held, "{kind:?}: id");
4907 }
4908 }
4909
4910 /// Another API client's status under a tracked order's IB id is not this client's order's:
4911 /// IB numbers orders per client. It is dropped, and the tracked order keeps its entry.
4912 #[test]
4913 fn another_api_clients_status_does_not_reach_a_tracked_order() {
4914 use ibapi::orders::OrderStatusKind;
4915
4916 let (tx, mut rx) = mpsc::unbounded_channel();
4917 let sink = recovery::EventSink::new(
4918 tx,
4919 new_dedup_cache(),
4920 KnownLiveOrders::shared(ExchangeId::Ibkr),
4921 );
4922 let order_ids = OrderIdMap::new();
4923 let cid = ClientOrderId::new("cid-7");
4924 order_ids
4925 .register(
4926 cid.clone(),
4927 7,
4928 OrderContext {
4929 instrument: InstrumentNameExchange::new("AAPL"),
4930 side: Side::Buy,
4931 price: Some(Decimal::from(100)),
4932 quantity: Decimal::ONE,
4933 kind: OrderKind::Limit,
4934 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
4935 },
4936 )
4937 .unwrap();
4938
4939 forward_order_updates(
4940 [Ok(OrderUpdate::OrderStatus(OrderStatus {
4941 order_id: 7,
4942 client_id: 905,
4943 status: OrderStatusKind::Cancelled,
4944 ..OrderStatus::default()
4945 }))],
4946 &sink,
4947 scope(903),
4948 &ContractRegistry::new(),
4949 &order_ids,
4950 &PendingCancels::new(),
4951 &ExecutionBuffer::new(),
4952 );
4953
4954 assert!(
4955 matches!(
4956 rx.try_recv().map(|event| event.kind),
4957 Ok(AccountEventKind::StreamTerminated(_))
4958 ),
4959 "nothing forwarded before the stream ended"
4960 );
4961 assert_eq!(order_ids.get_ib_id(&cid), Some(7));
4962 assert!(order_ids.contains(7));
4963 }
4964
4965 /// A status IB re-sends for a filled order, after its id names a later order, is dropped
4966 /// rather than read as the later order's, which it would end. The later order's own status is
4967 /// forwarded.
4968 #[test]
4969 fn a_resent_status_for_an_ended_order_does_not_reach_a_reused_id() {
4970 use ibapi::orders::OrderStatusKind;
4971
4972 let (tx, mut rx) = mpsc::unbounded_channel();
4973 let sink = recovery::EventSink::new(
4974 tx,
4975 new_dedup_cache(),
4976 KnownLiveOrders::shared(ExchangeId::Ibkr),
4977 );
4978 let order_ids = OrderIdMap::new();
4979 let cid = ClientOrderId::new("reused");
4980 let ctx = || OrderContext {
4981 instrument: InstrumentNameExchange::new("AAPL"),
4982 side: Side::Buy,
4983 price: Some(Decimal::from(100)),
4984 quantity: Decimal::ONE,
4985 kind: OrderKind::Limit,
4986 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
4987 };
4988 let status = |order_id, status| {
4989 Ok(OrderUpdate::OrderStatus(OrderStatus {
4990 order_id,
4991 status,
4992 ..OrderStatus::default()
4993 }))
4994 };
4995 order_ids.register(cid.clone(), 1, ctx()).unwrap();
4996 // One stream, which ends with the updates: the first order fills, its id is registered
4997 // again for a second order, then IB re-sends the first order's fill.
4998 let updates = [status(1, OrderStatusKind::Filled)]
4999 .into_iter()
5000 .chain(std::iter::once_with(|| {
5001 order_ids.register(cid.clone(), 2, ctx()).unwrap();
5002 status(1, OrderStatusKind::Filled)
5003 }))
5004 .chain([status(2, OrderStatusKind::Submitted)]);
5005 forward_order_updates(
5006 updates,
5007 &sink,
5008 scope(0),
5009 &ContractRegistry::new(),
5010 &order_ids,
5011 &PendingCancels::new(),
5012 &ExecutionBuffer::new(),
5013 );
5014
5015 let Ok(first) = rx.try_recv() else {
5016 panic!("the first order's fill is forwarded");
5017 };
5018 assert!(
5019 matches!(first.kind, AccountEventKind::OrderSnapshot(Snapshot(ref order)) if !matches!(order.state, OrderState::Active(_))),
5020 "{first:?}"
5021 );
5022 let Ok(event) = rx.try_recv() else {
5023 panic!("the later order's status is forwarded");
5024 };
5025 let AccountEventKind::OrderSnapshot(Snapshot(order)) = event.kind else {
5026 panic!("an order snapshot, got {event:?}");
5027 };
5028 assert!(
5029 matches!(order.state, OrderState::Active(_)),
5030 "it is the later, working order's: {order:?}"
5031 );
5032 assert!(
5033 matches!(
5034 rx.try_recv().map(|event| event.kind),
5035 Ok(AccountEventKind::StreamTerminated(_))
5036 ),
5037 "and the re-sent fill was dropped: nothing else before the stream ended"
5038 );
5039 assert_eq!(order_ids.get_ib_id(&cid), Some(2));
5040 }
5041
5042 /// An execution and its correction, each completed by its commission report, reach the
5043 /// stream as a trade and an amendment of it, never as two trades.
5044 #[test]
5045 fn a_corrected_execution_is_reported_as_an_amendment() {
5046 use crate::trade::TradeAmendmentKind;
5047 use ibapi::{
5048 contracts::Contract,
5049 orders::{CommissionReport, Execution, ExecutionData},
5050 };
5051
5052 let contracts = ContractRegistry::new();
5053 contracts
5054 .register(
5055 InstrumentNameExchange::new("AAPL"),
5056 Contract {
5057 contract_id: 265598,
5058 ..Contract::default()
5059 },
5060 )
5061 .unwrap();
5062 let order_ids = OrderIdMap::new();
5063 order_ids
5064 .register(
5065 ClientOrderId::new("cid-7"),
5066 7,
5067 OrderContext {
5068 instrument: InstrumentNameExchange::new("AAPL"),
5069 side: Side::Buy,
5070 price: Some(Decimal::from(100)),
5071 quantity: Decimal::ONE,
5072 kind: OrderKind::Limit,
5073 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
5074 },
5075 )
5076 .unwrap();
5077 let execution = |exec_id: &str, price: f64| {
5078 Ok(OrderUpdate::ExecutionData(ExecutionData {
5079 // TWS tags an execution happening now with -1.
5080 request_id: -1,
5081 contract: Contract {
5082 contract_id: 265598,
5083 ..Contract::default()
5084 },
5085 execution: Execution {
5086 order_id: 7,
5087 execution_id: exec_id.to_string(),
5088 time: "20261005 14:00:00 UTC".to_string(),
5089 shares: 1.0,
5090 price,
5091 cumulative_quantity: 1.0,
5092 ..Execution::default()
5093 },
5094 }))
5095 };
5096 let commission = |exec_id: &str| {
5097 Ok(OrderUpdate::CommissionReport(CommissionReport {
5098 execution_id: exec_id.to_string(),
5099 commission: 1.0,
5100 currency: "USD".to_string(),
5101 ..CommissionReport::default()
5102 }))
5103 };
5104
5105 let (tx, mut rx) = mpsc::unbounded_channel();
5106 let sink = recovery::EventSink::new(
5107 tx,
5108 new_dedup_cache(),
5109 KnownLiveOrders::shared(ExchangeId::Ibkr),
5110 );
5111 forward_order_updates(
5112 [
5113 execution("0000e0d5.5f8b1c2a.01.01", 100.0),
5114 commission("0000e0d5.5f8b1c2a.01.01"),
5115 execution("0000e0d5.5f8b1c2a.01.02", 100.5),
5116 commission("0000e0d5.5f8b1c2a.01.02"),
5117 ],
5118 &sink,
5119 scope(0),
5120 &contracts,
5121 &order_ids,
5122 &PendingCancels::new(),
5123 &ExecutionBuffer::new(),
5124 );
5125
5126 let AccountEventKind::Trade(original) = rx.try_recv().unwrap().kind else {
5127 panic!("expected the original as a trade");
5128 };
5129 assert_eq!(original.id, TradeId::new("0000e0d5.5f8b1c2a.01.01"));
5130 let AccountEventKind::TradeAmended(amendment) = rx.try_recv().unwrap().kind else {
5131 panic!("expected the correction as an amendment");
5132 };
5133 assert_eq!(amendment.original, Some(original.id));
5134 // The IB order id, which the order's `Open` state carries, not the client order id.
5135 assert_eq!(amendment.order_id, OrderId::new("7"));
5136 let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
5137 panic!("expected a resolved correction, got {:?}", amendment.kind);
5138 };
5139 assert_eq!(replacement.id, TradeId::new("0000e0d5.5f8b1c2a.01.02"));
5140 assert_eq!(replacement.price, Decimal::new(1005, 1));
5141 assert_eq!(replacement.fees.fees, Decimal::ONE);
5142 assert!(matches!(
5143 rx.try_recv().unwrap().kind,
5144 AccountEventKind::StreamTerminated(_)
5145 ));
5146 }
5147
5148 #[test]
5149 fn a_shutdown_terminates_the_stream_as_one() {
5150 let (tx, mut rx) = mpsc::unbounded_channel();
5151 let sink = recovery::EventSink::new(
5152 tx,
5153 new_dedup_cache(),
5154 KnownLiveOrders::shared(ExchangeId::Ibkr),
5155 );
5156
5157 let mut updates = unforwarded(1);
5158 updates.push(Err(ibapi::Error::Shutdown));
5159 updates.extend(unforwarded(1));
5160
5161 assert_eq!(run(&sink, updates), 2, "nothing is read past the shutdown");
5162 assert!(matches!(
5163 rx.try_recv().unwrap().kind,
5164 AccountEventKind::StreamTerminated(StreamTerminationReason::Error(ref reason))
5165 if reason == recovery::CLIENT_SHUT_DOWN
5166 ));
5167 assert!(
5168 rx.try_recv().is_err(),
5169 "the stream ends with its termination"
5170 );
5171 }
5172
5173 #[test]
5174 fn open_stream_reads_past_unforwarded_updates() {
5175 let (tx, mut rx) = mpsc::unbounded_channel();
5176 let sink = recovery::EventSink::new(
5177 tx,
5178 new_dedup_cache(),
5179 KnownLiveOrders::shared(ExchangeId::Ibkr),
5180 );
5181
5182 assert_eq!(run(&sink, unforwarded(3)), 3);
5183 // The subscription ended, so the stream says so.
5184 assert!(matches!(
5185 rx.try_recv().unwrap().kind,
5186 AccountEventKind::StreamTerminated(StreamTerminationReason::Error(ref reason))
5187 if reason == "IBKR order-update stream ended"
5188 ));
5189 }
5190
5191 mod listings {
5192 use super::*;
5193 use ibapi::{
5194 contracts::Contract,
5195 orders::{
5196 CommissionReport, Execution, ExecutionData, Executions, OrderData,
5197 OrderState as IbOrderState, OrderStatusKind, Orders,
5198 },
5199 subscriptions::SubscriptionItem,
5200 };
5201 use rust_decimal_macros::dec;
5202 use std::time::Duration;
5203
5204 const API_CLIENT: i32 = 903;
5205 const CON_ID: i32 = 265598;
5206
5207 fn contracts() -> ContractRegistry {
5208 let contracts = ContractRegistry::new();
5209 contracts
5210 .register(
5211 InstrumentNameExchange::new("AAPL"),
5212 Contract {
5213 contract_id: CON_ID,
5214 ..Contract::default()
5215 },
5216 )
5217 .unwrap();
5218 contracts
5219 }
5220
5221 /// A `next_timeout` that hands out `items`, then answers as IB does at the end of a
5222 /// listing, at once; or, with `stalls`, only once the wait has run out.
5223 fn subscription<T>(
5224 items: Vec<Result<SubscriptionItem<T>, ibapi::Error>>,
5225 stalls: bool,
5226 ) -> impl FnMut(Duration) -> Option<Result<SubscriptionItem<T>, ibapi::Error>> {
5227 let mut items = items.into_iter();
5228 move |timeout| {
5229 items.next().or_else(|| {
5230 if stalls {
5231 std::thread::sleep(timeout);
5232 }
5233 None
5234 })
5235 }
5236 }
5237
5238 fn read(
5239 items: Vec<Result<SubscriptionItem<u32>, ibapi::Error>>,
5240 stalls: bool,
5241 stop_at: Option<u32>,
5242 ) -> Result<Vec<u32>, UnindexedClientError> {
5243 read_while(items, stalls, stop_at, true)
5244 }
5245
5246 fn read_while(
5247 items: Vec<Result<SubscriptionItem<u32>, ibapi::Error>>,
5248 stalls: bool,
5249 stop_at: Option<u32>,
5250 connected: bool,
5251 ) -> Result<Vec<u32>, UnindexedClientError> {
5252 let mut read = Vec::new();
5253 read_listing(
5254 "test",
5255 Duration::from_millis(40),
5256 || connected,
5257 subscription(items, stalls),
5258 |item| {
5259 read.push(item);
5260 if Some(item) == stop_at {
5261 ControlFlow::Break(())
5262 } else {
5263 ControlFlow::Continue(())
5264 }
5265 },
5266 )?;
5267 Ok(read)
5268 }
5269
5270 #[test]
5271 fn a_listing_read_ends_at_the_end_of_the_listing() {
5272 let items = || vec![Ok(SubscriptionItem::Data(1)), Ok(SubscriptionItem::Data(2))];
5273 assert_eq!(read(items(), false, None).unwrap(), [1, 2]);
5274 assert_eq!(
5275 read(items(), false, Some(1)).unwrap(),
5276 [1],
5277 "or where it breaks"
5278 );
5279 }
5280
5281 /// What a stalled or failed read holds is not the whole listing, so it is not returned.
5282 #[test]
5283 fn a_stalled_or_failed_listing_read_is_an_error() {
5284 let stalled = read(vec![Ok(SubscriptionItem::Data(1))], true, None);
5285 assert!(
5286 matches!(&stalled, Err(UnindexedClientError::Internal(m)) if m.contains("sent nothing")),
5287 "{stalled:?}"
5288 );
5289 let failed = read(
5290 vec![
5291 Ok(SubscriptionItem::Data(1)),
5292 Err(ibapi::Error::ConnectionReset),
5293 ],
5294 false,
5295 None,
5296 );
5297 assert!(
5298 matches!(failed, Err(UnindexedClientError::Internal(_))),
5299 "{failed:?}"
5300 );
5301 }
5302
5303 /// `ibapi` answers early when it drops a subscription's channel as well as at the end
5304 /// marker, so a listing that stops while the client is disconnected is not its end.
5305 #[test]
5306 fn a_listing_that_stops_while_disconnected_is_an_error() {
5307 let read = read_while(vec![Ok(SubscriptionItem::Data(1))], false, None, false);
5308 assert!(
5309 matches!(&read, Err(UnindexedClientError::Internal(m)) if m.contains("connection dropped")),
5310 "{read:?}"
5311 );
5312 }
5313
5314 fn contract(contract_id: i32) -> Contract {
5315 Contract {
5316 contract_id,
5317 ..Contract::default()
5318 }
5319 }
5320
5321 fn ib_notice(code: i32) -> ListingError {
5322 ListingError::Ibapi(ibapi::Error::Notice(ibapi::Notice {
5323 request_id: Some(9),
5324 code,
5325 message: "notice".to_string(),
5326 error_time: None,
5327 advanced_order_reject_json: String::new(),
5328 }))
5329 }
5330
5331 /// A description resolves only when IB lists exactly one contract for it.
5332 #[test]
5333 fn a_contract_description_resolves_only_to_a_single_match() {
5334 assert_eq!(
5335 classify_contract_details(Ok(()), vec![contract(1)]),
5336 Ok(contract(1))
5337 );
5338 assert_eq!(
5339 classify_contract_details(Ok(()), Vec::new()),
5340 Err(ResolveContractError::NoMatch)
5341 );
5342 assert_eq!(
5343 classify_contract_details(Ok(()), vec![contract(1), contract(2)]),
5344 Err(ResolveContractError::Ambiguous {
5345 matches: vec![contract(1), contract(2)]
5346 })
5347 );
5348 }
5349
5350 /// IB answers a description that matches nothing with error 200, and any other error
5351 /// is a refusal. A failure, even after some matches arrived, fails the resolution: the
5352 /// matches read are not all there are.
5353 #[test]
5354 fn a_failed_contract_details_read_says_whether_a_retry_can_help() {
5355 let classify = |failure| classify_contract_details(Err(failure), vec![contract(1)]);
5356
5357 assert_eq!(
5358 classify(ib_notice(NO_SECURITY_DEFINITION_CODE)),
5359 Err(ResolveContractError::NoMatch)
5360 );
5361 assert_eq!(
5362 classify(ib_notice(321)),
5363 Err(ResolveContractError::Refused {
5364 code: 321,
5365 message: "notice".to_string()
5366 })
5367 );
5368
5369 let transient = [
5370 (
5371 ListingError::Dropped,
5372 ConnectivityError::Socket(ListingError::Dropped.to_string()),
5373 ),
5374 (
5375 ListingError::Stalled(Duration::from_secs(10)),
5376 ConnectivityError::Timeout,
5377 ),
5378 (
5379 ListingError::Ibapi(ibapi::Error::ConnectionReset),
5380 ConnectivityError::Socket(ibapi::Error::ConnectionReset.to_string()),
5381 ),
5382 ];
5383 for (failure, connectivity) in transient {
5384 let classified = classify(failure);
5385 assert_eq!(
5386 classified,
5387 Err(ResolveContractError::Connectivity(connectivity))
5388 );
5389 assert!(classified.unwrap_err().is_transient());
5390 }
5391
5392 // ibapi has given up reconnecting, or the client is shut down: retrying cannot help.
5393 for permanent in [ibapi::Error::ConnectionFailed, ibapi::Error::Shutdown] {
5394 let classified = classify(ListingError::Ibapi(permanent));
5395 assert!(
5396 matches!(&classified, Err(ResolveContractError::Failed(_))),
5397 "{classified:?}"
5398 );
5399 assert!(!classified.unwrap_err().is_transient());
5400 }
5401 assert!(
5402 !ResolveContractError::Refused {
5403 code: 321,
5404 message: String::new()
5405 }
5406 .is_transient()
5407 );
5408 assert!(!ResolveContractError::NoMatch.is_transient());
5409 }
5410
5411 /// IB not answering the request for the login's accounts is a stall a retry can clear;
5412 /// any other failure is not.
5413 #[test]
5414 fn only_an_unanswered_accounts_request_is_worth_retrying() {
5415 let timeout = managed_accounts_error(ibapi::Error::Timeout);
5416 assert_eq!(
5417 timeout,
5418 UnindexedClientError::Connectivity(ConnectivityError::Timeout)
5419 );
5420 assert!(timeout.is_transient());
5421
5422 for e in [ibapi::Error::Shutdown, ibapi::Error::Simple("no".into())] {
5423 let label = e.to_string();
5424 assert_eq!(
5425 managed_accounts_error(e),
5426 UnindexedClientError::Internal(format!("managed accounts: {label}"))
5427 );
5428 }
5429 }
5430
5431 /// The account summary stays live after its listing, so only its `End` item ends the
5432 /// read; one that stops before it is not a whole balance set.
5433 #[test]
5434 fn an_account_summary_read_needs_its_end_item() {
5435 use ibapi::accounts::{AccountSummary, AccountSummaryResult};
5436
5437 let summary = || {
5438 Ok(SubscriptionItem::Data(AccountSummaryResult::Summary(
5439 AccountSummary {
5440 account: "DU1".to_string(),
5441 tag: "TotalCashBalance".to_string(),
5442 value: "100".to_string(),
5443 currency: "USD".to_string(),
5444 },
5445 )))
5446 };
5447 let read = |items| {
5448 read_account_summary(
5449 Duration::from_millis(40),
5450 || true,
5451 subscription(items, false),
5452 )
5453 };
5454
5455 let balances = read(vec![
5456 summary(),
5457 Ok(SubscriptionItem::Data(AccountSummaryResult::End)),
5458 ])
5459 .unwrap()
5460 .to_balances();
5461 assert_eq!(balances.len(), 1, "{balances:?}");
5462 let cut_short = read(vec![summary()]);
5463 assert!(
5464 matches!(cut_short, Err(UnindexedClientError::Internal(_))),
5465 "{cut_short:?}"
5466 );
5467 }
5468
5469 fn listed(api_client_id: i32, order_id: i32, order: ibapi::orders::Order) -> Orders {
5470 Orders::OrderData(OrderData {
5471 order_id,
5472 contract: Contract {
5473 contract_id: CON_ID,
5474 ..Contract::default()
5475 },
5476 order: ibapi::orders::Order {
5477 client_id: api_client_id,
5478 order_id,
5479 ..order
5480 },
5481 order_state: IbOrderState {
5482 status: OrderStatusKind::Submitted,
5483 ..IbOrderState::default()
5484 },
5485 })
5486 }
5487
5488 fn status(
5489 api_client_id: i32,
5490 order_id: i32,
5491 status: OrderStatusKind,
5492 filled: f64,
5493 ) -> Orders {
5494 Orders::OrderStatus(OrderStatus {
5495 order_id,
5496 client_id: api_client_id,
5497 status,
5498 filled,
5499 ..OrderStatus::default()
5500 })
5501 }
5502
5503 fn stop_order() -> ibapi::orders::Order {
5504 ibapi::orders::Order {
5505 action: ibapi::orders::Action::Sell,
5506 order_type: "STP".to_owned(),
5507 total_quantity: 3.0,
5508 aux_price: Some(140.5),
5509 tif: ibapi::orders::TimeInForce::Day,
5510 ..ibapi::orders::Order::default()
5511 }
5512 }
5513
5514 /// This client's open orders, by IB order id: a tracked one as it was placed, under its
5515 /// client order id; another read back from IB's listing, under its IB order id. Each with
5516 /// the fill its status reports.
5517 #[test]
5518 fn open_orders_are_this_api_clients_with_their_fills() {
5519 let order_ids = OrderIdMap::new();
5520 order_ids
5521 .register(
5522 ClientOrderId::new("tracked"),
5523 2,
5524 OrderContext {
5525 instrument: InstrumentNameExchange::new("AAPL"),
5526 side: Side::Buy,
5527 price: Some(dec!(150)),
5528 quantity: dec!(10),
5529 kind: OrderKind::Limit,
5530 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
5531 },
5532 )
5533 .unwrap();
5534
5535 let listed_orders = open_orders_from_listing(
5536 [
5537 listed(API_CLIENT, 5, stop_order()),
5538 status(API_CLIENT, 5, OrderStatusKind::PreSubmitted, 0.0),
5539 // IB lists the tracked order with whatever it holds; what it was placed with
5540 // stands.
5541 listed(API_CLIENT, 2, ibapi::orders::Order::default()),
5542 status(API_CLIENT, 2, OrderStatusKind::Submitted, 4.0),
5543 // Another API client's order under the tracked order's id.
5544 listed(904, 2, stop_order()),
5545 status(904, 2, OrderStatusKind::Submitted, 9.0),
5546 ],
5547 API_CLIENT,
5548 &contracts(),
5549 &order_ids,
5550 );
5551
5552 assert_eq!(listed_orders.len(), 2, "{listed_orders:?}");
5553 // Only the tracked order is named as placed: the other has no order reference.
5554 let exact: Vec<_> = listed_orders
5555 .iter()
5556 .map(ListedOpenOrder::is_exact)
5557 .collect();
5558 assert_eq!(exact, [true, false]);
5559 let orders: Vec<_> = listed_orders
5560 .into_iter()
5561 .map(|listed| listed.order.unwrap())
5562 .collect();
5563 let (tracked, untracked) = (&orders[0], &orders[1]);
5564 assert_eq!(tracked.key.cid, ClientOrderId::new("tracked"));
5565 assert_eq!(tracked.kind, OrderKind::Limit);
5566 assert_eq!(tracked.price, Some(dec!(150)));
5567 assert_eq!(tracked.quantity, dec!(10));
5568 assert_eq!(tracked.state.id, VenueOrderId::Assigned(OrderId::new("2")));
5569 assert_eq!(tracked.state.filled_quantity, dec!(4));
5570
5571 assert_eq!(untracked.key.cid, ClientOrderId::new("5"));
5572 assert_eq!(
5573 untracked.key.instrument,
5574 InstrumentNameExchange::new("AAPL")
5575 );
5576 assert_eq!(untracked.side, Side::Sell);
5577 assert_eq!(untracked.quantity, dec!(3));
5578 assert_eq!(
5579 untracked.kind,
5580 OrderKind::Stop {
5581 trigger_price: dec!(140.5)
5582 }
5583 );
5584 assert_eq!(untracked.time_in_force, TimeInForce::GoodUntilEndOfDay);
5585 assert_eq!(
5586 untracked.state.id,
5587 VenueOrderId::Assigned(OrderId::new("5"))
5588 );
5589 assert_eq!(untracked.state.filled_quantity, Decimal::ZERO);
5590 }
5591
5592 /// An order that has just ended, and one in a contract not registered, are not listed;
5593 /// one whose shape this client never sends is listed without an order, and not exactly.
5594 #[test]
5595 fn open_orders_leave_out_what_cannot_be_reported_open() {
5596 let unregistered = Orders::OrderData(OrderData {
5597 order_id: 3,
5598 contract: Contract {
5599 contract_id: 1,
5600 ..Contract::default()
5601 },
5602 order: ibapi::orders::Order {
5603 client_id: API_CLIENT,
5604 ..stop_order()
5605 },
5606 order_state: IbOrderState::default(),
5607 });
5608 let unreadable = ibapi::orders::Order {
5609 order_type: "REL".to_owned(),
5610 ..stop_order()
5611 };
5612
5613 let orders = open_orders_from_listing(
5614 [
5615 listed(API_CLIENT, 1, stop_order()),
5616 status(API_CLIENT, 1, OrderStatusKind::Cancelled, 0.0),
5617 unregistered,
5618 listed(API_CLIENT, 4, unreadable),
5619 ],
5620 API_CLIENT,
5621 &contracts(),
5622 &OrderIdMap::new(),
5623 );
5624 assert_eq!(orders.len(), 1, "{orders:?}");
5625 assert!(orders[0].order.is_none());
5626 assert!(!orders[0].is_exact());
5627 }
5628
5629 fn referenced(order_ref: &str) -> ibapi::orders::Order {
5630 ibapi::orders::Order {
5631 order_ref: order_ref.to_owned(),
5632 ..stop_order()
5633 }
5634 }
5635
5636 /// An order this client does not track, such as one placed before a restart, is named by
5637 /// the client order id its order reference carries, and tracked from then on.
5638 #[test]
5639 fn an_untracked_order_is_named_by_its_reference_and_adopted() {
5640 let order_ids = OrderIdMap::new();
5641 let listed = open_orders_from_listing(
5642 [
5643 listed(API_CLIENT, 7, referenced("before-restart")),
5644 status(API_CLIENT, 7, OrderStatusKind::Submitted, 1.0),
5645 ],
5646 API_CLIENT,
5647 &contracts(),
5648 &order_ids,
5649 );
5650
5651 assert_eq!(listed.len(), 1, "{listed:?}");
5652 assert!(listed[0].is_exact());
5653 let order = listed[0].order.as_ref().unwrap();
5654 assert_eq!(order.key.cid, ClientOrderId::new("before-restart"));
5655 assert_eq!(order.state.filled_quantity, dec!(1));
5656 assert_eq!(
5657 order_ids.get_ib_id(&ClientOrderId::new("before-restart")),
5658 Some(7)
5659 );
5660 let (_, ctx) = order_ids.get_client_id_and_context(7).unwrap();
5661 assert_eq!(ctx.quantity, dec!(3));
5662 }
5663
5664 /// A tracked order under the listed order's IB order id but another client order id is an
5665 /// earlier order whose id IB reused: the tracked one keeps its mapping, and the listed
5666 /// order, which cannot be tracked under its reference, is listed under its IB order id and
5667 /// not exactly.
5668 #[test]
5669 fn a_reused_ib_order_id_does_not_name_the_listed_order() {
5670 let order_ids = OrderIdMap::new();
5671 order_ids
5672 .register(
5673 ClientOrderId::new("earlier"),
5674 7,
5675 OrderContext {
5676 instrument: InstrumentNameExchange::new("AAPL"),
5677 side: Side::Buy,
5678 price: Some(dec!(150)),
5679 quantity: dec!(10),
5680 kind: OrderKind::Limit,
5681 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
5682 },
5683 )
5684 .unwrap();
5685
5686 let listed = open_orders_from_listing(
5687 [
5688 listed(API_CLIENT, 7, referenced("later")),
5689 status(API_CLIENT, 7, OrderStatusKind::Submitted, 0.0),
5690 ],
5691 API_CLIENT,
5692 &contracts(),
5693 &order_ids,
5694 );
5695
5696 let order = listed[0].order.as_ref().unwrap();
5697 assert_eq!(order.key.cid, ClientOrderId::new("7"));
5698 assert!(!listed[0].is_exact());
5699 assert_eq!(
5700 order.kind,
5701 OrderKind::Stop {
5702 trigger_price: dec!(140.5)
5703 }
5704 );
5705 assert_eq!(order_ids.get_ib_id(&ClientOrderId::new("earlier")), Some(7));
5706 assert_eq!(order_ids.get_ib_id(&ClientOrderId::new("later")), None);
5707 }
5708
5709 /// The order check's listing names each of this client's orders by its reference, or else
5710 /// its tracked id, keeps one that has just ended, and adopts nothing.
5711 #[test]
5712 fn listed_cids_name_this_clients_orders_without_adopting_them() {
5713 let order_ids = OrderIdMap::new();
5714 order_ids
5715 .register(
5716 ClientOrderId::new("tracked"),
5717 2,
5718 OrderContext {
5719 instrument: InstrumentNameExchange::new("AAPL"),
5720 side: Side::Buy,
5721 price: Some(dec!(150)),
5722 quantity: dec!(10),
5723 kind: OrderKind::Limit,
5724 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
5725 },
5726 )
5727 .unwrap();
5728
5729 let cids = listed_cids(
5730 [
5731 listed(API_CLIENT, 1, referenced("ended")),
5732 status(API_CLIENT, 1, OrderStatusKind::Cancelled, 0.0),
5733 listed(API_CLIENT, 2, stop_order()),
5734 listed(API_CLIENT, 3, stop_order()),
5735 listed(904, 4, referenced("other-client")),
5736 ],
5737 API_CLIENT,
5738 &order_ids,
5739 );
5740
5741 assert_eq!(
5742 cids,
5743 fnv::FnvHashSet::from_iter([
5744 ClientOrderId::new("ended"),
5745 ClientOrderId::new("tracked")
5746 ])
5747 );
5748 assert_eq!(order_ids.get_ib_id(&ClientOrderId::new("ended")), None);
5749 }
5750
5751 /// A snapshot's instrument has complete orders only when each was read back exactly, and
5752 /// an instrument without a listed order has none open only if the listing was read.
5753 #[test]
5754 fn snapshot_orders_are_complete_only_when_each_is_exact() {
5755 let msft = InstrumentNameExchange::new("MSFT");
5756 let contracts = contracts();
5757 contracts
5758 .register(
5759 msft.clone(),
5760 Contract {
5761 contract_id: 272093,
5762 ..Contract::default()
5763 },
5764 )
5765 .unwrap();
5766 let in_msft = |order_id, order| {
5767 let Orders::OrderData(mut data) = listed(API_CLIENT, order_id, order) else {
5768 unreachable!()
5769 };
5770 data.contract.contract_id = 272093;
5771 Orders::OrderData(data)
5772 };
5773 let listed_orders = open_orders_from_listing(
5774 [
5775 listed(API_CLIENT, 1, referenced("a")),
5776 status(API_CLIENT, 1, OrderStatusKind::Submitted, 0.0),
5777 // Entered in TWS: no reference, so named by its IB order id.
5778 in_msft(2, stop_order()),
5779 status(API_CLIENT, 2, OrderStatusKind::Submitted, 0.0),
5780 in_msft(3, referenced("b")),
5781 status(API_CLIENT, 3, OrderStatusKind::Submitted, 0.0),
5782 ],
5783 API_CLIENT,
5784 &contracts,
5785 &OrderIdMap::new(),
5786 );
5787
5788 let mut orders = SnapshotOrders::new(Some(listed_orders));
5789 let (aapl_orders, aapl_complete) = orders.take(&InstrumentNameExchange::new("AAPL"));
5790 assert_eq!(aapl_orders.len(), 1);
5791 assert!(aapl_complete);
5792 let (msft_orders, msft_complete) = orders.take(&msft);
5793 assert_eq!(msft_orders.len(), 2);
5794 assert!(!msft_complete);
5795 assert_eq!(
5796 orders.take(&InstrumentNameExchange::new("TSLA")),
5797 (vec![], true)
5798 );
5799
5800 let mut unread = SnapshotOrders::new(None);
5801 assert_eq!(unread.take(&msft), (vec![], false));
5802 }
5803
5804 fn executed(api_client_id: i32, order_id: i32, exec_id: &str, time: &str) -> Executions {
5805 Executions::ExecutionData(ExecutionData {
5806 request_id: 9001,
5807 contract: Contract {
5808 contract_id: CON_ID,
5809 ..Contract::default()
5810 },
5811 execution: Execution {
5812 order_id,
5813 client_id: api_client_id,
5814 execution_id: exec_id.to_string(),
5815 time: time.to_string(),
5816 shares: 1.0,
5817 price: 100.0,
5818 cumulative_quantity: 1.0,
5819 ..Execution::default()
5820 },
5821 })
5822 }
5823
5824 fn commission(exec_id: &str) -> Executions {
5825 Executions::CommissionReport(CommissionReport {
5826 execution_id: exec_id.to_string(),
5827 commission: 1.25,
5828 currency: "USD".to_string(),
5829 ..CommissionReport::default()
5830 })
5831 }
5832
5833 /// The trades of a read: this API client's executions in the span, each under its IB
5834 /// order id, with the fee of its commission report, or an unknown fee without one.
5835 #[test]
5836 fn trades_carry_their_ib_order_id_and_commission() {
5837 let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
5838 let trades = trades_from_executions(
5839 [
5840 executed(API_CLIENT, 7, "paired", "20261007 10:00:02 UTC"),
5841 commission("paired"),
5842 // Earlier than the paired one, and returned first: in time order.
5843 executed(API_CLIENT, 7, "unpaired", "20261007 10:00:01 UTC"),
5844 executed(API_CLIENT, 7, "too-early", "20261007 09:59:59 UTC"),
5845 commission("too-early"),
5846 executed(905, 7, "other-client", "20261007 10:00:03 UTC"),
5847 commission("other-client"),
5848 ],
5849 scope(API_CLIENT),
5850 &contracts(),
5851 at("2026-10-07T10:00:00Z")..=at("2026-10-07T11:00:00Z"),
5852 None,
5853 );
5854
5855 let read: Vec<_> = trades
5856 .iter()
5857 .map(|trade| {
5858 (
5859 trade.id.0.as_str(),
5860 trade.order_id.0.as_str(),
5861 trade.fees.asset.as_ref(),
5862 trade.fees.fees,
5863 )
5864 })
5865 .collect();
5866 assert_eq!(
5867 read,
5868 [
5869 ("unpaired", "7", execution::UNKNOWN_FEE_ASSET, Decimal::ZERO),
5870 ("paired", "7", "USD", dec!(1.25)),
5871 ]
5872 );
5873
5874 let filtered = trades_from_executions(
5875 [
5876 executed(API_CLIENT, 7, "paired", "20261007 10:00:00 UTC"),
5877 commission("paired"),
5878 ],
5879 scope(API_CLIENT),
5880 &contracts(),
5881 at("2026-10-07T10:00:00Z")..=at("2026-10-07T11:00:00Z"),
5882 Some(&HashSet::from([InstrumentNameExchange::new("MSFT")])),
5883 );
5884 assert!(filtered.is_empty(), "{filtered:?}");
5885
5886 // Opted in to other clients' fills, another client's is read too, as external and
5887 // under a name its client's order id cannot share with this client's.
5888 let all_clients = trades_from_executions(
5889 [
5890 executed(API_CLIENT, 7, "own", "20261007 10:00:00 UTC"),
5891 commission("own"),
5892 executed(905, 7, "other-client", "20261007 10:00:01 UTC"),
5893 commission("other-client"),
5894 ],
5895 ExecutionScope {
5896 api_client_id: API_CLIENT,
5897 other_clients: true,
5898 },
5899 &contracts(),
5900 at("2026-10-07T10:00:00Z")..=at("2026-10-07T11:00:00Z"),
5901 None,
5902 );
5903 let read: Vec<_> = all_clients
5904 .iter()
5905 .map(|trade| (trade.order_id.0.as_str(), trade.origin))
5906 .collect();
5907 assert_eq!(
5908 read,
5909 [
5910 ("7", crate::trade::TradeOrigin::Order),
5911 ("client:905:7", crate::trade::TradeOrigin::External)
5912 ]
5913 );
5914 }
5915 }
5916}