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