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