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