Skip to main content

rustrade_execution/client/ibkr/
mod.rs

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