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