Skip to main content

rustrade_execution/client/ibkr/
mod.rs

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