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