Skip to main content

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