Skip to main content

rustrade_execution/client/ibkr/
mod.rs

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