Skip to main content

rustrade_execution/client/ibkr/
mod.rs

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