Skip to main content

rustrade_execution/client/ibkr/
mod.rs

1//! Interactive Brokers ExecutionClient implementation.
2//!
3//! Uses the `ibapi` crate for IB TWS/Gateway connectivity. Supports equities,
4//! futures, options, and forex.
5//!
6//! # Testing Status
7//!
8//! **NOT TESTED in CI.** IBKR has not confirmed permission to use credentials
9//! for CI, and requires IB Gateway/TWS running locally.
10//!
11//! **Tested locally:** All execution tests (connection, orders, account streaming)
12//! run on paper trading accounts — no market data subscriptions required.
13//!
14//! # Connection
15//!
16//! Requires TWS or IB Gateway running locally with API enabled:
17//!
18//! | Application | Live Port | Paper Port |
19//! |-------------|-----------|------------|
20//! | TWS         | 7496      | 7497       |
21//! | IB Gateway  | 4001      | 4002       |
22//!
23//! Enable API in TWS/Gateway: Configure → API → Settings → Enable ActiveX and Socket Clients.
24//! For order placement, uncheck "Read-Only API".
25//!
26//! # Architecture
27//!
28//! - Connection: TCP socket to TWS/Gateway
29//! - Orders: Subscription-based events (OrderStatus, ExecutionData, CommissionReport)
30//! - Account: Subscription-based position and balance updates
31//!
32//! # Limitations
33//!
34//! - **Order types**: Market, Limit, Stop, StopLimit, TrailingStop, TrailingStopLimit,
35//!   and Bracket (entry + take-profit + stop-loss) supported. No Algo orders.
36//! - **TimeInForce**: No `post_only` (IB has no maker-only orders)
37//! - **No auto-reconnect**: Caller responsibility per library philosophy
38//!
39//! # Caller Responsibilities (Reconnection & Recovery)
40//!
41//! Unlike WebSocket-based connectors (Binance, Alpaca), this client does **not**
42//! auto-reconnect. The caller must handle:
43//!
44//! 1. **Disconnect detection**: Monitor [`ExecutionClient::account_stream`] for EOF or errors
45//! 2. **Reconnection**: Call [`IbkrClient::connect_sync`] with a new client ID
46//! 3. **Fill recovery**: After reconnect, call [`ExecutionClient::fetch_trades`] to
47//!    query executions since disconnect, then deduplicate against known fills
48//! 4. **Order reconciliation**: Call [`ExecutionClient::fetch_open_orders`] to
49//!    reconcile open-order state (order lifecycle events during disconnect are lost)
50//! 5. **Stale state cleanup**: Periodically call [`IbkrClient::clear_stale_executions`],
51//!    [`IbkrClient::clear_stale_order_ids`], and [`IbkrClient::clear_stale_pending_cancels`]
52//!
53//! **Rationale**: IBKR uses TCP to local TWS/Gateway, not cloud WebSocket. Reconnection
54//! requires IB Gateway availability and client ID coordination — decisions that belong
55//! in the caller's wrapper, not the library.
56//!
57//! # See Also
58//!
59//! - [IB API Documentation](https://www.interactivebrokers.com/campus/ibkr-api-page/trader-workstation-api/)
60//! - `rustrade_data::exchange::ibkr` for market data
61
62pub mod account;
63pub mod contract;
64pub mod execution;
65pub mod order;
66
67use crate::{
68    AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, Snapshot, UnindexedAccountEvent,
69    UnindexedAccountSnapshot,
70    balance::AssetBalance,
71    client::{BracketOrderClient, ExecutionClient},
72    emit_stream_terminated,
73    error::{
74        ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
75    },
76    order::{
77        Order, OrderKey, OrderKind, TimeInForce,
78        bracket::{
79            BracketOrderRequest as UnifiedBracketOrderRequest,
80            BracketOrderResult as UnifiedBracketOrderResult,
81        },
82        id::{ClientOrderId, OrderId, StrategyId},
83        request::{
84            OrderRequestCancel, OrderRequestOpen, OrderResponseCancel, UnindexedOrderResponseCancel,
85        },
86        state::{Cancelled, Expired, Filled, Open, OrderState, UnindexedOrderState},
87    },
88    trade::{AssetFees, Trade, TradeId},
89};
90use account::BalanceAggregator;
91use chrono::{DateTime, Utc};
92use execution::{ExecutionBuffer, parse_decimal_or_warn};
93use futures::stream::BoxStream;
94use ibapi::{
95    accounts::{AccountSummaryResult, types::AccountGroup},
96    client::blocking::Client,
97};
98pub use order::{BracketOrderRequest, BracketOrderResult};
99use order::{
100    OrderContext, OrderIdMap, PendingCancels, build_ib_bracket_with_oca, build_ib_order,
101    side_to_action, time_in_force_to_ib,
102};
103use parking_lot::Mutex;
104use rust_decimal::Decimal;
105use rustrade_instrument::{
106    Side,
107    asset::name::AssetNameExchange,
108    exchange::ExchangeId,
109    ibkr::ContractRegistry,
110    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
111};
112use serde::{Deserialize, Serialize};
113use smol_str::format_smolstr;
114use std::{
115    collections::HashSet,
116    panic::{AssertUnwindSafe, catch_unwind},
117    sync::Arc,
118};
119use tokio::sync::mpsc;
120use tracing::{debug, error, info, trace, warn};
121
122/// Configuration for the IBKR execution client.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct IbkrConfig {
125    /// TWS/Gateway host (e.g., "127.0.0.1")
126    pub host: String,
127    /// TWS/Gateway port (7496=TWS live, 7497=TWS paper, 4001=GW live, 4002=GW paper)
128    pub port: u16,
129    /// Client ID (must be unique per connection)
130    pub client_id: i32,
131    /// Account ID (e.g., "DU123456" for paper).
132    ///
133    /// Currently unused — balance/position queries use "All" group.
134    /// Reserved for future multi-account routing (advisor accounts).
135    pub account: String,
136    /// Pre-configured contracts to register on startup
137    #[serde(default)]
138    pub contracts: Vec<ContractConfig>,
139}
140
141/// Pre-configured contract for startup registration.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ContractConfig {
144    pub name: String,
145    pub symbol: String,
146    pub security_type: String,
147    pub exchange: String,
148    pub currency: String,
149    #[serde(default)]
150    pub last_trade_date: Option<String>,
151    /// Strike price for options. Uses f64 because `ibapi::Contract.strike` is f64.
152    #[serde(default)]
153    pub strike: Option<f64>,
154    #[serde(default)]
155    pub right: Option<String>,
156}
157
158impl ContractConfig {
159    /// Convert this config into an [`ibapi::contracts::Contract`].
160    ///
161    /// # Errors
162    ///
163    /// Returns a [`ContractConfigError`](contract::ContractConfigError) instead of
164    /// silently fabricating a wrong contract when the config is incomplete or
165    /// unsupported:
166    /// - [`UnrecognizedSecurityType`](contract::ContractConfigError::UnrecognizedSecurityType)
167    ///   — `security_type` is not one of `STK`/`FUT`/`OPT`/`CASH`.
168    /// - [`MissingLastTradeDate`](contract::ContractConfigError::MissingLastTradeDate)
169    ///   — `last_trade_date` is absent on a `FUT` or `OPT` contract.
170    /// - [`MissingStrike`](contract::ContractConfigError::MissingStrike) — `strike`
171    ///   is absent on an `OPT` contract.
172    /// - [`MissingOptionRight`](contract::ContractConfigError::MissingOptionRight) —
173    ///   `right` is absent on an `OPT` contract.
174    /// - [`UnrecognizedOptionRight`](contract::ContractConfigError::UnrecognizedOptionRight)
175    ///   — `right` is present but not one of `C`/`CALL`/`P`/`PUT` (case-insensitive).
176    fn to_contract(&self) -> Result<ibapi::contracts::Contract, contract::ContractConfigError> {
177        use contract::ContractConfigError as E;
178        Ok(match self.security_type.as_str() {
179            "STK" => contract::stock_contract(&self.symbol, &self.exchange, &self.currency),
180            "FUT" => contract::futures_contract(
181                &self.symbol,
182                self.last_trade_date
183                    .as_deref()
184                    .ok_or(E::MissingLastTradeDate)?,
185                &self.exchange,
186                &self.currency,
187            ),
188            "OPT" => contract::option_contract(
189                &self.symbol,
190                self.last_trade_date
191                    .as_deref()
192                    .ok_or(E::MissingLastTradeDate)?,
193                self.strike.ok_or(E::MissingStrike)?,
194                self.right.as_deref().ok_or(E::MissingOptionRight)?,
195                &self.exchange,
196                &self.currency,
197            )?,
198            "CASH" => contract::forex_contract(&self.symbol, &self.currency),
199            other => {
200                return Err(E::UnrecognizedSecurityType {
201                    security_type: other.to_string(),
202                });
203            }
204        })
205    }
206}
207
208/// Account group for "All" accounts (used by reqAccountSummary).
209static ACCOUNT_GROUP_ALL: std::sync::LazyLock<AccountGroup> =
210    std::sync::LazyLock::new(|| AccountGroup("All".to_string()));
211
212/// Timeout for position stream iteration.
213/// Workaround for ibapi bug where `PositionEnd` isn't routed to subscription.
214const POSITION_STREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
215
216/// Maximum time to await an initial `OrderStatus` on an order-placement
217/// subscription before giving up. Bounds the placement loops so a silent TWS
218/// (e.g. a half-open socket) cannot hang them indefinitely. A timeout yields
219/// [`PlacementOutcome::NoStatus`]; the order may still have been submitted, so
220/// callers resolve the final state via the order-update/account stream.
221const PLACEMENT_STATUS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
222
223/// IBKR "Order Message" codes that are informational rather than rejections.
224///
225/// ibapi classifies the whole `200..=399` range as order rejections
226/// (`ORDER_REJECTION_CODE_RANGE`), but IBKR uses code 399 as a generic order
227/// message — e.g. *"Your order will not be placed at the exchange until
228/// 09:30:00 US/Eastern"* — for an order that is in fact **accepted and held**
229/// (it proceeds to `PreSubmitted`). We therefore report the order as
230/// live-but-pending ([`PlacementOutcome::HeldPending`]) rather than rejected
231/// when one of these codes arrives.
232///
233/// # ibapi 4.0 splits code 399 by message text
234///
235/// `is_warning_message` now classifies a 399 whose text carries a `Warning:`
236/// line as a warning, and `classify_error` routes a warning owned by a
237/// request-bound subscription as a non-terminal `RoutedItem::Notice` rather
238/// than a stream-ending `RoutedItem::Error`. `timeout_iter_data` drops notices,
239/// so the two forms now reach [`await_order_placement`] differently:
240///
241/// - **399 carrying a `Warning:` line** — dropped, and the subscription stays
242///   open. The eventual `OrderStatus(PreSubmitted)` therefore arrives on the
243///   placement subscription itself and yields
244///   [`PlacementOutcome::Accepted`] without this list being consulted. Under
245///   ibapi 3.x that status was reachable only via the order-update/account
246///   stream, because the notice had already closed the subscription.
247/// - **399 without one** — still `Err(Error::Notice)`, still closes the
248///   subscription, and is still matched against this list.
249///
250/// The list stays load-bearing for the second form. It documents the known gap
251/// between ibapi's range heuristic and IBKR's actual protocol semantics; if
252/// IBKR adds further informational codes in the 200-399 range, an out-of-RTH
253/// placement test will surface them and they can be added here.
254const INFORMATIONAL_ORDER_CODES: &[i32] = &[399];
255
256/// Outcome of awaiting the initial status on an order-placement subscription.
257///
258/// The authoritative acceptance/rejection signal in the IBKR protocol is the
259/// `OrderStatus` event, not an error notice — see [`await_order_placement`].
260///
261/// Every variant carries a distinct order-placement outcome that callers must
262/// dispatch on; dropping a value would silently discard the order's status.
263#[must_use]
264#[derive(Debug)]
265enum PlacementOutcome {
266    /// The order was acknowledged by IB and its order-id mapping must be
267    /// retained so the account stream can resolve its terminal/fill state. This
268    /// covers the working statuses (`Submitted`/`PreSubmitted`/`PendingSubmit`),
269    /// an immediate `Filled`, and the transitional cancel states
270    /// (`ApiCancelled`/`PendingCancel`) that precede a confirmed terminal status.
271    /// Carries the filled quantity reported with the status.
272    Accepted { filled: f64 },
273    /// A terminal rejection: a `Cancelled`/`Inactive` `OrderStatus`, a genuine
274    /// non-informational TWS notice, or a transport error. Carries the
275    /// human-readable reason.
276    Rejected(String),
277    /// The order exists, but the placement subscription could not classify its
278    /// state: either an informational notice (see
279    /// [`INFORMATIONAL_ORDER_CODES`]) closed the subscription, or TWS reported
280    /// a status ibapi does not model (`OrderStatusKind::Unknown`). Either way
281    /// the order is live/held and its authoritative status will arrive via the
282    /// order-update/account stream. Carries the notice or status text.
283    HeldPending(String),
284    /// The subscription ended — or [`PLACEMENT_STATUS_TIMEOUT`] elapsed —
285    /// without any terminal status or informational notice. The order may or
286    /// may not have been accepted; resolve via the order-update/account stream.
287    NoStatus,
288}
289
290/// Drive an order-placement subscription to its initial [`PlacementOutcome`].
291///
292/// Shared by the single-order and bracket-leg placement paths. Consumes events
293/// from a `place_order` subscription (typically `sub.timeout_iter_data(..)`)
294/// until a decisive outcome is reached.
295///
296/// # Why notices are not blanket rejections
297///
298/// ibapi delivers a TWS error frame that it does not classify as a warning as
299/// `Err(Error::Notice)`, and then closes the subscription. IB emits
300/// informational order messages (e.g. code 399, order held until RTH) the same
301/// way, so treating every `Err` as a rejection would falsely reject orders that
302/// are actually live. We instead key off the notice code: known informational
303/// codes yield [`PlacementOutcome::HeldPending`]; all other notices and
304/// transport errors yield [`PlacementOutcome::Rejected`]. The `OrderStatus`
305/// event — when one is delivered before the closing notice — remains
306/// authoritative.
307///
308/// ibapi 4.0 widened that warning classification beyond the `2100..=2169` range
309/// to include code 0 and the `Warning:` form of code 399, and now routes a
310/// warning owned by a request-bound subscription as a non-terminal notice that
311/// `timeout_iter_data` drops rather than an `Err` that ends the stream. See
312/// [`INFORMATIONAL_ORDER_CODES`] for what that changes here.
313fn await_order_placement<I>(events: I) -> PlacementOutcome
314where
315    I: IntoIterator<Item = Result<ibapi::orders::PlaceOrder, ibapi::Error>>,
316{
317    use ibapi::orders::PlaceOrder;
318
319    for event in events {
320        let event = match event {
321            Ok(event) => event,
322            // Informational order message (e.g. 399 held-until-RTH): the order
323            // is accepted; ibapi has closed the subscription, so stop here and
324            // let the update stream carry the real status.
325            Err(ibapi::Error::Notice(n)) if INFORMATIONAL_ORDER_CODES.contains(&n.code) => {
326                return PlacementOutcome::HeldPending(format!("[{}] {}", n.code, n.message));
327            }
328            // Genuine notice (e.g. 201 reject) or transport error.
329            Err(e) => return PlacementOutcome::Rejected(e.to_string()),
330        };
331
332        if let PlaceOrder::OrderStatus(status) = event {
333            use ibapi::orders::OrderStatusKind;
334            match status.status {
335                OrderStatusKind::Submitted
336                | OrderStatusKind::PreSubmitted
337                | OrderStatusKind::PendingSubmit
338                // A marketable order can fill before any working status is
339                // delivered on the placement subscription — ibapi sends
340                // `OrderStatus(Filled)` directly in that case. The order is
341                // live, not rejected; its authoritative terminal/fill state
342                // arrives via the account stream. Report it accepted so the
343                // order-id mapping is retained and the stream's
344                // executions/commissions are captured (treating it as a
345                // rejection would remove the mapping and drop those events).
346                | OrderStatusKind::Filled => {
347                    return PlacementOutcome::Accepted {
348                        filled: status.filled,
349                    };
350                }
351                OrderStatusKind::Cancelled | OrderStatusKind::Inactive => {
352                    return PlacementOutcome::Rejected(status.status.to_string());
353                }
354                // Transitional cancel states: a cancellation was already requested
355                // (e.g. a fast cancel racing the placement ack), but the order was
356                // submitted — we have a placement subscription — and is still live.
357                // `ApiCancelled` always precedes a confirmed `Cancelled`; both it
358                // and `PendingCancel` resolve to a terminal status on the account
359                // stream, which is authoritative. Report accepted so the order-id
360                // mapping is retained and the stream's terminal/execution/commission
361                // events are captured (treating these as a rejection would remove
362                // the mapping and drop those events).
363                OrderStatusKind::ApiCancelled | OrderStatusKind::PendingCancel => {
364                    return PlacementOutcome::Accepted {
365                        filled: status.filled,
366                    };
367                }
368                // Pre-transmission state: ibapi has not yet sent the order to IB
369                // (e.g. awaiting a security-definition lookup). This is not a
370                // placement decision — keep waiting for a definitive status. The
371                // `PLACEMENT_STATUS_TIMEOUT` backstop yields `NoStatus` if none
372                // arrives.
373                OrderStatusKind::ApiPending => {}
374                // TWS reported a status ibapi does not model. The order exists
375                // — TWS is reporting on it — but neither we nor ibapi can
376                // classify it: `is_active()` and `is_terminal()` are both false
377                // for this variant. Report it live-but-indeterminate, which
378                // retains the order-id mapping so the account stream can
379                // resolve the real state.
380                //
381                // Deliberately NOT falling through to the `PLACEMENT_STATUS_TIMEOUT`
382                // backstop the way `ApiPending` does: that yields `NoStatus`,
383                // and on the bracket path `NoStatus` cancels all three legs —
384                // a destructive response to a status we merely failed to
385                // recognise.
386                OrderStatusKind::Unknown(ref raw) => {
387                    warn!(
388                        ib_order_id = status.order_id,
389                        status = raw.as_str(),
390                        "unmodelled IBKR order status during placement; \
391                         treating as live, account stream is authoritative"
392                    );
393                    return PlacementOutcome::HeldPending(format!(
394                        "unmodelled order status: {raw}"
395                    ));
396                }
397            }
398        }
399        // Non-status events (OpenOrder, ExecutionData, CommissionReport): keep
400        // waiting for the OrderStatus.
401    }
402
403    PlacementOutcome::NoStatus
404}
405
406/// Interactive Brokers execution client.
407///
408/// # Clone Behavior
409///
410/// Cloning creates a shallow copy with shared `Arc` references to the underlying
411/// IB connection, contract registry, order ID map, and execution buffer. All clones
412/// share the same TWS/Gateway connection and state.
413#[derive(Clone)]
414pub struct IbkrClient {
415    config: Arc<IbkrConfig>,
416    client: Arc<Client>,
417    contracts: ContractRegistry,
418    order_ids: OrderIdMap,
419    pending_cancels: PendingCancels,
420    execution_buffer: ExecutionBuffer,
421    next_order_id: Arc<Mutex<i32>>,
422}
423
424impl std::fmt::Debug for IbkrClient {
425    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        f.debug_struct("IbkrClient")
427            .field("config", &self.config)
428            .field("contracts_count", &self.contracts.len())
429            .field("pending_orders", &self.order_ids.len())
430            .finish()
431    }
432}
433
434impl IbkrClient {
435    /// Connect to TWS/Gateway and initialize the client (sync, blocking).
436    ///
437    /// # Errors
438    ///
439    /// Returns error if connection fails or contract resolution fails.
440    pub fn connect_sync(config: IbkrConfig) -> Result<Self, UnindexedClientError> {
441        let url = format!("{}:{}", config.host, config.port);
442        info!(url = %url, client_id = config.client_id, "Connecting to IB");
443
444        let client = Client::connect(&url, config.client_id).map_err(|e| {
445            UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
446        })?;
447
448        let next_id = client.next_order_id();
449
450        let contracts = ContractRegistry::new();
451
452        for contract_config in &config.contracts {
453            let contract = match contract_config.to_contract() {
454                Ok(contract) => contract,
455                Err(e) => {
456                    warn!(name = %contract_config.name, error = %e, "Invalid contract config, skipping");
457                    continue;
458                }
459            };
460            let name = InstrumentNameExchange::from(contract_config.name.as_str());
461
462            match client.contract_details(&contract) {
463                Ok(details) => {
464                    if let Some(detail) = details.into_iter().next() {
465                        contracts.register(name.clone(), detail.contract.clone());
466                        debug!(name = %name, con_id = detail.contract.contract_id, "Registered contract");
467                    }
468                }
469                Err(e) => {
470                    warn!(name = %name, error = %e, "Failed to resolve contract");
471                }
472            }
473        }
474
475        info!(
476            contracts = contracts.len(),
477            next_order_id = next_id,
478            "Connected to IB"
479        );
480
481        Ok(Self {
482            config: Arc::new(config),
483            client: Arc::new(client),
484            contracts,
485            order_ids: OrderIdMap::new(),
486            pending_cancels: PendingCancels::new(),
487            execution_buffer: ExecutionBuffer::new(),
488            next_order_id: Arc::new(Mutex::new(next_id)),
489        })
490    }
491
492    /// Get the next order ID and increment the counter.
493    fn allocate_order_id(&self) -> i32 {
494        self.allocate_order_id_range(1)
495    }
496
497    /// Allocate a contiguous range of order IDs atomically.
498    ///
499    /// Returns the first ID in the range. Caller uses `base`, `base+1`, ..., `base+(count-1)`.
500    ///
501    /// This is essential for bracket orders which require consecutive IDs (parent=N,
502    /// take_profit=N+1, stop_loss=N+2). A single lock acquisition ensures no other
503    /// concurrent `open_order` call can grab an ID in the middle of the range.
504    ///
505    /// # Panics
506    ///
507    /// Panics if `count` exceeds `i32::MAX`, or if the resulting range would overflow `i32::MAX`.
508    #[allow(clippy::expect_used)] // Panic is correct: i32::MAX orders means system is broken
509    fn allocate_order_id_range(&self, count: u32) -> i32 {
510        let count_i32: i32 = count.try_into().expect("count exceeds i32::MAX");
511        let mut id = self.next_order_id.lock();
512        let base = *id;
513        *id = id
514            .checked_add(count_i32)
515            .expect("order ID overflow: i32::MAX exceeded");
516        base
517    }
518
519    /// Register a contract for an instrument.
520    pub fn register_contract(
521        &self,
522        name: InstrumentNameExchange,
523        contract: ibapi::contracts::Contract,
524    ) {
525        self.contracts.register(name, contract);
526    }
527
528    /// Get the contract registry.
529    pub fn contract_registry(&self) -> &ContractRegistry {
530        &self.contracts
531    }
532
533    /// Get the number of pending executions awaiting commission reports.
534    ///
535    /// Useful for monitoring whether commission reports are being received.
536    /// A growing count may indicate IB connection issues or delayed reports.
537    pub fn pending_execution_count(&self) -> usize {
538        self.execution_buffer.pending_count()
539    }
540
541    /// Clear stale executions older than the given duration.
542    ///
543    /// Returns the number of cleared entries.
544    ///
545    /// Call this periodically to prevent unbounded memory growth if commission
546    /// reports are delayed or lost. A reasonable interval is 5-10 minutes with
547    /// a max_age of 1 hour.
548    pub fn clear_stale_executions(&self, max_age: std::time::Duration) -> usize {
549        self.execution_buffer.clear_stale(max_age)
550    }
551
552    /// Clear order ID mappings older than the given duration.
553    ///
554    /// Returns the number of cleared entries.
555    ///
556    /// # Why This Is Needed
557    ///
558    /// IB does not guarantee event ordering between `OrderStatus("Filled")` and
559    /// `ExecutionData`/`CommissionReport`. For fast-filling orders (especially
560    /// market orders), execution data may arrive AFTER the filled status — or
561    /// the filled status may not arrive at all. Removing mappings on terminal
562    /// status would cause data loss.
563    ///
564    /// Call this periodically alongside `clear_stale_executions()`. A reasonable
565    /// interval is 5-10 minutes with a max_age of 1 hour.
566    pub fn clear_stale_order_ids(&self, max_age: std::time::Duration) -> usize {
567        self.order_ids.clear_stale(max_age)
568    }
569
570    /// Clear pending cancel entries older than the given duration.
571    ///
572    /// Returns the number of cleared entries.
573    ///
574    /// Pending cancels are tracked to differentiate user-initiated cancellation
575    /// from time-based expiration. If a cancel request is submitted but the order
576    /// never receives terminal status (e.g., network disconnect), the entry would
577    /// remain indefinitely. Call this alongside other stale cleanup methods.
578    pub fn clear_stale_pending_cancels(&self, max_age: std::time::Duration) -> usize {
579        self.pending_cancels.clear_stale(max_age)
580    }
581
582    /// Disconnect from IB Gateway.
583    ///
584    /// Signals the ibapi client to shut down and releases the client ID for reuse.
585    ///
586    /// [`IbkrClient`] implements [`Clone`] and has no `Drop` impl: the underlying
587    /// connection is released automatically when the last `Arc<Client>` reference
588    /// is dropped. Calling `disconnect()` explicitly terminates the connection
589    /// **immediately for all clones** sharing this client.
590    ///
591    /// Any active `account_stream()` iterators will receive errors on their
592    /// next iteration attempt.
593    ///
594    /// This is idempotent — calling it multiple times is safe.
595    pub fn disconnect(&self) {
596        debug!("Disconnecting IbkrClient");
597        self.client.disconnect();
598    }
599
600    /// Place a bracket order (entry + take-profit + stop-loss) with OCA linking.
601    ///
602    /// A bracket order consists of three linked orders:
603    /// 1. **Entry**: Limit order to enter the position
604    /// 2. **Take Profit**: Limit order to exit at profit target (OCA-linked to SL)
605    /// 3. **Stop Loss**: Stop order to exit at loss limit (OCA-linked to TP)
606    ///
607    /// # OCA (One-Cancels-All) Behavior
608    ///
609    /// When either exit order fills, IB automatically cancels the other. This
610    /// prevents the dangerous scenario where take-profit fills but stop-loss
611    /// remains open, potentially opening an unintended opposing position.
612    ///
613    /// # All-or-Nothing Semantics
614    ///
615    /// If any leg is rejected by IB (e.g., insufficient margin, invalid price),
616    /// this method cancels all other legs and returns all three as `Inactive`.
617    /// You will never receive a mix of active and inactive legs.
618    ///
619    /// The same all-legs cancellation applies if any leg fails to report a
620    /// terminal status within the placement timeout (an unknown/no-status
621    /// outcome): leaving part of a bracket working while the rest is in an
622    /// unknown state is unsafe, so all three legs are cancelled and an error is
623    /// returned. This is intentionally stricter than single-order placement
624    /// (`open_order`), where a no-status outcome retains the order and defers
625    /// resolution to the account stream.
626    ///
627    /// # Cancellation Safety
628    ///
629    /// This future registers order ID mappings before submitting to IB. If
630    /// cancelled mid-flight, orders may still be submitted and mappings will
631    /// leak. Avoid cancelling this future; use IB's native order timeout via
632    /// `TimeInForce` if timeout behavior is needed.
633    ///
634    /// # Example
635    ///
636    /// ```ignore
637    /// let request = BracketOrderRequest {
638    ///     instrument: "AAPL".into(),
639    ///     strategy: StrategyId::new("my-strategy"),
640    ///     parent_cid: ClientOrderId::new("bracket-001"),
641    ///     side: Side::Buy,
642    ///     quantity: dec!(100),
643    ///     entry_price: dec!(150.00),
644    ///     take_profit_price: dec!(160.00),  // +$10 profit target
645    ///     stop_loss_price: dec!(145.00),    // -$5 stop loss
646    ///     time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
647    /// };
648    /// let result = client.open_bracket_order(request).await;
649    /// ```
650    pub async fn open_bracket_order(&self, request: BracketOrderRequest) -> BracketOrderResult {
651        let instrument = request.instrument.clone();
652
653        // Look up contract
654        let contract = match self.contracts.get_contract(&instrument) {
655            Some(c) => c,
656            None => {
657                return make_all_inactive_bracket(
658                    &request,
659                    OrderError::Rejected(ApiError::InstrumentInvalid(
660                        instrument,
661                        "contract not registered".to_string(),
662                    )),
663                );
664            }
665        };
666
667        // Convert quantity to f64
668        let quantity: f64 = match request.quantity.try_into() {
669            Ok(q) => q,
670            Err(_) => {
671                return make_all_inactive_bracket(
672                    &request,
673                    OrderError::Rejected(ApiError::OrderRejected(format!(
674                        "quantity {} exceeds f64 range",
675                        request.quantity
676                    ))),
677                );
678            }
679        };
680
681        // Convert prices to f64
682        let entry_price: f64 = match request.entry_price.try_into() {
683            Ok(p) => p,
684            Err(_) => {
685                return make_all_inactive_bracket(
686                    &request,
687                    OrderError::Rejected(ApiError::OrderRejected(format!(
688                        "entry_price {} exceeds f64 range",
689                        request.entry_price
690                    ))),
691                );
692            }
693        };
694        let tp_price: f64 = match request.take_profit_price.try_into() {
695            Ok(p) => p,
696            Err(_) => {
697                return make_all_inactive_bracket(
698                    &request,
699                    OrderError::Rejected(ApiError::OrderRejected(format!(
700                        "take_profit_price {} exceeds f64 range",
701                        request.take_profit_price
702                    ))),
703                );
704            }
705        };
706        let sl_price: f64 = match request.stop_loss_price.try_into() {
707            Ok(p) => p,
708            Err(_) => {
709                return make_all_inactive_bracket(
710                    &request,
711                    OrderError::Rejected(ApiError::OrderRejected(format!(
712                        "stop_loss_price {} exceeds f64 range",
713                        request.stop_loss_price
714                    ))),
715                );
716            }
717        };
718
719        // Validate time_in_force and convert to IB wire format
720        let ib_tif = match time_in_force_to_ib(&request.time_in_force) {
721            Ok(tif) => tif,
722            Err(e) => {
723                return make_all_inactive_bracket(
724                    &request,
725                    OrderError::Rejected(ApiError::OrderRejected(format!(
726                        "TIF not supported by IB: {e}"
727                    ))),
728                );
729            }
730        };
731
732        // Allocate 3 consecutive order IDs atomically
733        let parent_ib_id = self.allocate_order_id_range(3);
734        let tp_ib_id = parent_ib_id + 1;
735        let sl_ib_id = parent_ib_id + 2;
736
737        // Build bracket orders with OCA linking
738        let action = side_to_action(request.side);
739        let ib_orders = build_ib_bracket_with_oca(
740            parent_ib_id,
741            action,
742            quantity,
743            entry_price,
744            tp_price,
745            sl_price,
746            ib_tif,
747        );
748
749        // Generate client order IDs for children
750        let parent_cid = request.parent_cid.clone();
751        let (tp_cid, sl_cid) = derive_child_cids(&parent_cid);
752
753        // Determine opposite side for exit orders
754        let exit_side = match request.side {
755            Side::Buy => Side::Sell,
756            Side::Sell => Side::Buy,
757        };
758
759        // Register order contexts for all three legs
760        let parent_ctx = OrderContext {
761            instrument: instrument.clone(),
762            side: request.side,
763            price: Some(request.entry_price),
764            quantity: request.quantity,
765            kind: OrderKind::Limit,
766            time_in_force: request.time_in_force,
767        };
768        let tp_ctx = OrderContext {
769            instrument: instrument.clone(),
770            side: exit_side,
771            price: Some(request.take_profit_price),
772            quantity: request.quantity,
773            kind: OrderKind::Limit,
774            time_in_force: request.time_in_force,
775        };
776        let sl_ctx = OrderContext {
777            instrument: instrument.clone(),
778            side: exit_side,
779            price: None, // Stop orders don't have a limit price
780            quantity: request.quantity,
781            kind: OrderKind::Stop {
782                trigger_price: request.stop_loss_price,
783            },
784            time_in_force: request.time_in_force,
785        };
786
787        self.order_ids
788            .register(parent_cid.clone(), parent_ib_id, parent_ctx);
789        self.order_ids.register(tp_cid.clone(), tp_ib_id, tp_ctx);
790        self.order_ids.register(sl_cid.clone(), sl_ib_id, sl_ctx);
791
792        // Place all three orders in spawn_blocking
793        let client = self.client.clone();
794        let result = tokio::task::spawn_blocking(move || {
795            // Place parent (transmit=false, held until SL is sent)
796            let parent_sub = match client.place_order(parent_ib_id, &contract, &ib_orders[0]) {
797                Ok(s) => s,
798                Err(e) => return Err(format!("parent order failed: {e}")),
799            };
800
801            // Place take-profit (transmit=false)
802            let tp_sub = match client.place_order(tp_ib_id, &contract, &ib_orders[1]) {
803                Ok(s) => s,
804                Err(e) => {
805                    // Cancel parent before returning
806                    let _ = client.cancel_order(parent_ib_id, "");
807                    return Err(format!("take_profit order failed: {e}"));
808                }
809            };
810
811            // Place stop-loss (transmit=true, triggers all)
812            let sl_sub = match client.place_order(sl_ib_id, &contract, &ib_orders[2]) {
813                Ok(s) => s,
814                Err(e) => {
815                    // Cancel parent and TP before returning
816                    let _ = client.cancel_order(parent_ib_id, "");
817                    let _ = client.cancel_order(tp_ib_id, "");
818                    return Err(format!("stop_loss order failed: {e}"));
819                }
820            };
821
822            // Await the first status from each subscription. A leg held until
823            // RTH (informational notice, e.g. 399) is treated as live with an
824            // unknown fill (0.0) — the order is working, not rejected. `None`
825            // means the subscription closed/timed out without a terminal status.
826            let leg_status = |outcome: PlacementOutcome, leg: &str| match outcome {
827                PlacementOutcome::Accepted { filled } => Some(Ok(filled)),
828                PlacementOutcome::HeldPending(notice) => {
829                    warn!(leg, %notice, "bracket leg held/pending; tracking via stream");
830                    Some(Ok(0.0))
831                }
832                PlacementOutcome::Rejected(reason) => Some(Err(reason)),
833                PlacementOutcome::NoStatus => None,
834            };
835
836            let parent_status = leg_status(
837                await_order_placement(parent_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
838                "parent",
839            );
840            let tp_status = leg_status(
841                await_order_placement(tp_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
842                "take_profit",
843            );
844            let sl_status = leg_status(
845                await_order_placement(sl_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
846                "stop_loss",
847            );
848
849            // Verify all three legs reported a successful status. A `None` here
850            // means the subscription closed without producing a recognised event
851            // — surface that as an error rather than silently treating it as
852            // success. Any error or missing status cancels all legs.
853            match (parent_status, tp_status, sl_status) {
854                (Some(Ok(parent)), Some(Ok(tp)), Some(Ok(sl))) => Ok((parent, tp, sl)),
855                (parent, tp, sl) => {
856                    let _ = client.cancel_order(parent_ib_id, "");
857                    let _ = client.cancel_order(tp_ib_id, "");
858                    let _ = client.cancel_order(sl_ib_id, "");
859                    Err(format!(
860                        "bracket order rejected: parent={parent:?}, tp={tp:?}, sl={sl:?}"
861                    ))
862                }
863            }
864        })
865        .await;
866
867        match result {
868            Ok(Ok((parent_filled, tp_filled, sl_filled))) => {
869                let now = Utc::now();
870                let parent_filled_dec = parse_decimal_or_warn(parent_filled, "parent.filled");
871                let tp_filled_dec = parse_decimal_or_warn(tp_filled, "tp.filled");
872                let sl_filled_dec = parse_decimal_or_warn(sl_filled, "sl.filled");
873
874                BracketOrderResult {
875                    parent: Order {
876                        key: OrderKey {
877                            exchange: ExchangeId::Ibkr,
878                            instrument: instrument.clone(),
879                            strategy: request.strategy.clone(),
880                            cid: parent_cid,
881                        },
882                        side: request.side,
883                        price: Some(request.entry_price),
884                        quantity: request.quantity,
885                        kind: OrderKind::Limit,
886                        time_in_force: request.time_in_force,
887                        state: OrderState::active(Open::new(
888                            OrderId::new(format_smolstr!("{}", parent_ib_id)),
889                            now,
890                            parent_filled_dec,
891                        )),
892                    },
893                    take_profit: Order {
894                        key: OrderKey {
895                            exchange: ExchangeId::Ibkr,
896                            instrument: instrument.clone(),
897                            strategy: request.strategy.clone(),
898                            cid: tp_cid,
899                        },
900                        side: exit_side,
901                        price: Some(request.take_profit_price),
902                        quantity: request.quantity,
903                        kind: OrderKind::Limit,
904                        time_in_force: request.time_in_force,
905                        state: OrderState::active(Open::new(
906                            OrderId::new(format_smolstr!("{}", tp_ib_id)),
907                            now,
908                            tp_filled_dec,
909                        )),
910                    },
911                    stop_loss: Order {
912                        key: OrderKey {
913                            exchange: ExchangeId::Ibkr,
914                            instrument: instrument.clone(),
915                            strategy: request.strategy.clone(),
916                            cid: sl_cid,
917                        },
918                        side: exit_side,
919                        price: None, // Stop orders don't have a limit price
920                        quantity: request.quantity,
921                        kind: OrderKind::Stop {
922                            trigger_price: request.stop_loss_price,
923                        },
924                        time_in_force: request.time_in_force,
925                        state: OrderState::active(Open::new(
926                            OrderId::new(format_smolstr!("{}", sl_ib_id)),
927                            now,
928                            sl_filled_dec,
929                        )),
930                    },
931                }
932            }
933            Ok(Err(err_msg)) => {
934                // Clean up order ID mappings
935                self.order_ids.remove_by_ib_id(parent_ib_id);
936                self.order_ids.remove_by_ib_id(tp_ib_id);
937                self.order_ids.remove_by_ib_id(sl_ib_id);
938
939                make_all_inactive_bracket(
940                    &request,
941                    OrderError::Rejected(ApiError::OrderRejected(err_msg)),
942                )
943            }
944            Err(join_err) => {
945                // Clean up order ID mappings
946                self.order_ids.remove_by_ib_id(parent_ib_id);
947                self.order_ids.remove_by_ib_id(tp_ib_id);
948                self.order_ids.remove_by_ib_id(sl_ib_id);
949
950                make_all_inactive_bracket(
951                    &request,
952                    OrderError::Rejected(ApiError::OrderRejected(format!(
953                        "task join error: {join_err}"
954                    ))),
955                )
956            }
957        }
958    }
959}
960
961/// Derive bracket child CIDs from the parent CID.
962///
963/// Convention: `{parent}_tp` for take-profit, `{parent}_sl` for stop-loss.
964/// All bracket call sites must use this helper to keep the naming consistent.
965fn derive_child_cids(parent_cid: &ClientOrderId) -> (ClientOrderId, ClientOrderId) {
966    (
967        ClientOrderId::new(format_smolstr!("{}_tp", parent_cid.0)),
968        ClientOrderId::new(format_smolstr!("{}_sl", parent_cid.0)),
969    )
970}
971
972/// Helper to create a BracketOrderResult with all legs inactive (same error).
973fn make_all_inactive_bracket(
974    request: &BracketOrderRequest,
975    error: OrderError<AssetNameExchange, InstrumentNameExchange>,
976) -> BracketOrderResult {
977    let exit_side = match request.side {
978        Side::Buy => Side::Sell,
979        Side::Sell => Side::Buy,
980    };
981
982    let parent_cid = request.parent_cid.clone();
983    let (tp_cid, sl_cid) = derive_child_cids(&parent_cid);
984
985    BracketOrderResult {
986        parent: Order {
987            key: OrderKey {
988                exchange: ExchangeId::Ibkr,
989                instrument: request.instrument.clone(),
990                strategy: request.strategy.clone(),
991                cid: parent_cid,
992            },
993            side: request.side,
994            price: Some(request.entry_price),
995            quantity: request.quantity,
996            kind: OrderKind::Limit,
997            time_in_force: request.time_in_force,
998            state: OrderState::inactive(error.clone()),
999        },
1000        take_profit: Order {
1001            key: OrderKey {
1002                exchange: ExchangeId::Ibkr,
1003                instrument: request.instrument.clone(),
1004                strategy: request.strategy.clone(),
1005                cid: tp_cid,
1006            },
1007            side: exit_side,
1008            price: Some(request.take_profit_price),
1009            quantity: request.quantity,
1010            kind: OrderKind::Limit,
1011            time_in_force: request.time_in_force,
1012            state: OrderState::inactive(error.clone()),
1013        },
1014        stop_loss: Order {
1015            key: OrderKey {
1016                exchange: ExchangeId::Ibkr,
1017                instrument: request.instrument.clone(),
1018                strategy: request.strategy.clone(),
1019                cid: sl_cid,
1020            },
1021            side: exit_side,
1022            price: None, // Stop orders don't have a limit price
1023            quantity: request.quantity,
1024            kind: OrderKind::Stop {
1025                trigger_price: request.stop_loss_price,
1026            },
1027            time_in_force: request.time_in_force,
1028            state: OrderState::inactive(error),
1029        },
1030    }
1031}
1032
1033impl ExecutionClient for IbkrClient {
1034    const EXCHANGE: ExchangeId = ExchangeId::Ibkr;
1035
1036    // Mirrors the security types `ContractConfig::to_contract` can build: `STK` and `CASH` (forex)
1037    // are both outright holdings and so map to `Spot`, `FUT` to `Future`, `OPT` to `Option`.
1038    //
1039    // `Cfd` is absent deliberately. IBKR does offer CFDs, but this client builds no `SecurityType`
1040    // for them, so a CFD instrument would fail `to_contract` with `UnrecognizedSecurityType` at
1041    // registration rather than trade.
1042    //
1043    // Scope: this declares which `InstrumentKind`s the client can represent AT ALL, and is checked
1044    // against `Instrument::kind`. It is NOT a check that a registered `ContractConfig` describes
1045    // the instrument it is keyed to -- `security_type` is a caller-populated `String` registered
1046    // independently into `ContractRegistry` and never compared against `Instrument::kind`. A
1047    // registry entry naming `STK` for an instrument modelled as `Option` still builds a stock
1048    // contract; this gate does not catch it.
1049    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1050        InstrumentKindDiscriminant::Spot,
1051        InstrumentKindDiscriminant::Future,
1052        InstrumentKindDiscriminant::Option,
1053    ];
1054
1055    type Config = IbkrConfig;
1056    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1057
1058    /// Create a new IBKR client by connecting to TWS/Gateway.
1059    ///
1060    /// # Blocking
1061    ///
1062    /// This method performs blocking TCP I/O and IB API handshake. If called
1063    /// from an async context, wrap in `tokio::task::spawn_blocking` or call
1064    /// from a dedicated thread.
1065    ///
1066    /// # Panics
1067    ///
1068    /// Panics if connection fails. The `ExecutionClient` trait doesn't allow
1069    /// `new()` to return `Result`. Use `IbkrClient::connect_sync()` directly
1070    /// for fallible construction.
1071    #[track_caller]
1072    fn new(config: Self::Config) -> Self {
1073        #[allow(clippy::expect_used)] // Trait signature doesn't allow Result
1074        Self::connect_sync(config).expect("failed to connect to IB")
1075    }
1076
1077    /// Fetch account snapshot (balances and positions).
1078    ///
1079    /// # Limitations
1080    ///
1081    /// - The `orders` field in each `InstrumentAccountSnapshot` is always empty.
1082    ///   IB's positions endpoint returns position data only, not open orders.
1083    ///   Use `fetch_open_orders()` or `account_stream()` for order state.
1084    ///
1085    /// - Position quantity and average cost from IB are not carried in the
1086    ///   returned `InstrumentAccountSnapshot`. The struct only indicates which
1087    ///   instruments have positions, not the position sizes. This is a
1088    ///   limitation of the `InstrumentAccountSnapshot` type, not the IB API.
1089    ///
1090    /// # Known Issue: ibapi Decode Errors
1091    ///
1092    /// You may see log errors like `"error decoding message: error occurred:
1093    /// unexpected message: Error"`. This is an upstream ibapi limitation where
1094    /// IB Error messages (type 4) on subscription channels aren't properly
1095    /// routed — they're logged but don't affect functionality.
1096    ///
1097    /// # Timeout
1098    ///
1099    /// Uses 5-second timeout between position updates. If IB stalls mid-stream
1100    /// (e.g., due to the ibapi `PositionEnd` routing bug), returns partial results
1101    /// after 5s of inactivity rather than blocking indefinitely.
1102    ///
1103    /// **For accounts with no positions, this method waits the full 5-second
1104    /// timeout before returning an empty position list.**
1105    async fn account_snapshot(
1106        &self,
1107        assets: &[AssetNameExchange],
1108        instruments: &[InstrumentNameExchange],
1109    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1110        // H-2 fix: Run balances and positions concurrently (independent IB requests)
1111        let client = self.client.clone();
1112        let contracts = self.contracts.clone();
1113        let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
1114            None
1115        } else {
1116            Some(instruments.iter().cloned().collect())
1117        };
1118
1119        let balances_future = self.fetch_balances(assets);
1120        let positions_future = tokio::task::spawn_blocking(move || {
1121            use ibapi::accounts::PositionUpdate;
1122
1123            // ibapi::Error is unstructured — we cannot distinguish connection failures
1124            // (transient, should retry) from API errors (e.g., invalid request).
1125            // Mapped to Internal (non-transient) conservatively; a caller needing reconnect
1126            // logic should drive it from connection state, not from the error type.
1127            let positions_sub = client
1128                .positions()
1129                .map_err(|e| UnindexedClientError::Internal(format!("positions: {e}")))?;
1130
1131            let mut snapshots = Vec::new();
1132            let mut seen = HashSet::new();
1133
1134            // Use the timeout-bounded data iterator instead of the plain blocking
1135            // iterator to avoid hang. ibapi bug: PositionEnd has no request_id so it
1136            // is not routed to the subscription, causing iteration to block forever;
1137            // the per-item timeout yields `None` to end the loop instead.
1138            for pos_update in positions_sub.timeout_iter_data(POSITION_STREAM_TIMEOUT) {
1139                // Surface subscription errors rather than returning partial positions
1140                // (a truncated snapshot could be misread as positions having closed).
1141                let pos_update = match pos_update {
1142                    Ok(p) => p,
1143                    Err(e) => {
1144                        return Err(UnindexedClientError::Internal(format!(
1145                            "positions subscription: {e}"
1146                        )));
1147                    }
1148                };
1149                let PositionUpdate::Position(pos) = pos_update else {
1150                    trace!(?pos_update, "Ignoring non-Position variant");
1151                    continue;
1152                };
1153                let Some(instrument) = contracts.get_name_by_con_id(pos.contract.contract_id)
1154                else {
1155                    continue;
1156                };
1157                if instruments_filter
1158                    .as_ref()
1159                    .is_some_and(|f| !f.contains(&instrument))
1160                {
1161                    continue;
1162                }
1163                if seen.contains(&instrument) {
1164                    debug!(
1165                        instrument = %instrument,
1166                        "Duplicate position for instrument (multi-account?), skipping"
1167                    );
1168                    continue;
1169                }
1170                seen.insert(instrument.clone());
1171                snapshots.push(InstrumentAccountSnapshot {
1172                    instrument,
1173                    orders: Vec::new(),
1174                    position: None,
1175                    isolated: None,
1176                });
1177            }
1178            Ok::<_, UnindexedClientError>(snapshots)
1179        });
1180
1181        // ibapi routes responses by request_id via thread-safe channels (RwLock<HashMap>),
1182        // so concurrent requests on the same client are safe.
1183        let (balances_result, positions_result) = tokio::join!(balances_future, positions_future);
1184        let balances = balances_result?;
1185        let instrument_snapshots = positions_result
1186            .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))??;
1187
1188        Ok(AccountSnapshot {
1189            exchange: ExchangeId::Ibkr,
1190            balances,
1191            instruments: instrument_snapshots,
1192        })
1193    }
1194
1195    /// Stream account events (order updates, fills, commissions).
1196    ///
1197    /// # Thread Lifecycle
1198    ///
1199    /// Spawns a background thread to read from the blocking IB subscription.
1200    /// The thread terminates when:
1201    /// - The returned `BoxStream` is dropped (channel closes)
1202    /// - The IB subscription ends (disconnect)
1203    ///
1204    /// **Important:** If IB is stalled (no events flowing), the thread blocks on
1205    /// the iterator. Dropping the stream signals termination, but the thread won't
1206    /// observe it until the next IB event arrives. For graceful shutdown during
1207    /// stalls, the caller should disconnect the IB connection.
1208    ///
1209    /// # Duplicate Events
1210    ///
1211    /// Orders submitted via `open_order()` will emit `OrderSnapshot` events on
1212    /// this stream in addition to the response from `open_order()` itself. This
1213    /// is inherent to IB's API — both the per-order subscription and the global
1214    /// order update stream receive the same `OrderStatus` events. Callers should
1215    /// deduplicate if needed.
1216    ///
1217    /// # Filter Parameters
1218    ///
1219    /// The `assets` and `instruments` parameters are currently ignored. IB's
1220    /// `order_update_stream()` returns events for all orders on the account.
1221    /// Callers subscribing for a specific instrument will receive events for
1222    /// all instruments.
1223    async fn account_stream(
1224        &self,
1225        _assets: &[AssetNameExchange],
1226        _instruments: &[InstrumentNameExchange],
1227    ) -> Result<Self::AccountStream, UnindexedClientError> {
1228        let client = self.client.clone();
1229
1230        // M-8 fix: Wrap blocking subscription call in spawn_blocking
1231        // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
1232        let order_sub = tokio::task::spawn_blocking(move || client.order_update_stream())
1233            .await
1234            .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
1235            .map_err(|e| UnindexedClientError::Internal(format!("order updates: {e}")))?;
1236
1237        let contracts_clone = self.contracts.clone();
1238        let order_ids_clone = self.order_ids.clone();
1239        let pending_cancels_clone = self.pending_cancels.clone();
1240        let exec_buffer_clone = self.execution_buffer.clone();
1241
1242        let (tx, rx) = mpsc::unbounded_channel();
1243
1244        std::thread::Builder::new()
1245            .name("ibkr-order-stream".to_string())
1246            .spawn(move || {
1247                // Panic safety: parking_lot mutexes do not poison on panic, so shared state
1248                // (ContractRegistry, OrderIdMap, etc.) remains usable. catch_unwind unwinds only
1249                // the inner closure — `tx` lives in this outer thread closure and is still open
1250                // afterward, so the panic handler below emits a terminal StreamTerminated before
1251                // `tx` drops (a panic is a terminal stream death like any other).
1252                let result = catch_unwind(AssertUnwindSafe(|| {
1253                    use ibapi::orders::{OrderStatusKind, OrderUpdate};
1254
1255                    for update in order_sub.iter_data() {
1256                        let update = match update {
1257                            Ok(u) => u,
1258                            Err(e) => {
1259                                // IBKR has no library-managed reconnection: a subscription
1260                                // error ends the stream. Surface it in-band as a terminal
1261                                // StreamTerminated(Error) before tx drops so the caller gets a
1262                                // programmatic signal rather than inferring EOF. (best-effort
1263                                // send — if the consumer already dropped rx it is a no-op.)
1264                                error!(error = %e, "Order stream subscription error");
1265                                emit_stream_terminated(
1266                                    &tx,
1267                                    ExchangeId::Ibkr,
1268                                    StreamTerminationReason::Error(e.to_string()),
1269                                );
1270                                return;
1271                            }
1272                        };
1273                        let event = match update {
1274                            OrderUpdate::OrderStatus(status) => {
1275                                let ib_id = status.order_id;
1276                                // Use single-lock method for terminal status to avoid read+write.
1277                                // Only `Cancelled`/`Inactive` remove the mapping here: a `Filled`
1278                                // order's mapping is intentionally retained so late-arriving
1279                                // ExecutionData/CommissionReport events still resolve it (reaped
1280                                // later by `OrderIdMap::clear_stale`), so this is deliberately
1281                                // narrower than `OrderStatusKind::is_terminal()`.
1282                                let is_terminal = matches!(
1283                                    status.status,
1284                                    OrderStatusKind::Cancelled | OrderStatusKind::Inactive
1285                                );
1286
1287                                let lookup_result = if is_terminal {
1288                                    order_ids_clone.remove_and_get_context(ib_id)
1289                                } else {
1290                                    order_ids_clone.get_client_id_and_context(ib_id)
1291                                };
1292
1293                                if let Some((client_id, ctx)) = lookup_result {
1294                                    let order = make_order_from_status(
1295                                        &status,
1296                                        client_id,
1297                                        &ctx,
1298                                        &pending_cancels_clone,
1299                                    );
1300                                    Some(UnindexedAccountEvent {
1301                                        exchange: ExchangeId::Ibkr,
1302                                        kind: AccountEventKind::OrderSnapshot(Snapshot::new(order)),
1303                                    })
1304                                } else {
1305                                    debug!(ib_order_id = ib_id, "OrderStatus for unknown order ID");
1306                                    None
1307                                }
1308                            }
1309                            OrderUpdate::ExecutionData(exec) => {
1310                                let order_id = exec.execution.order_id;
1311                                let con_id = exec.contract.contract_id;
1312
1313                                // Fail-fast: skip second lookup if first fails
1314                                let Some(client_id) = order_ids_clone.get_client_id(order_id)
1315                                else {
1316                                    debug!(
1317                                        ib_order_id = order_id,
1318                                        con_id, "ExecutionData for unknown order ID, dropping"
1319                                    );
1320                                    continue;
1321                                };
1322                                let Some(instrument) = contracts_clone.get_name_by_con_id(con_id)
1323                                else {
1324                                    debug!(
1325                                        ib_order_id = order_id,
1326                                        con_id, "ExecutionData for unknown contract ID, dropping"
1327                                    );
1328                                    continue;
1329                                };
1330
1331                                exec_buffer_clone.add_execution(exec, instrument, client_id);
1332                                None
1333                            }
1334                            OrderUpdate::CommissionReport(report) => exec_buffer_clone
1335                                .complete_with_commission(&report)
1336                                .map(|trade| UnindexedAccountEvent {
1337                                    exchange: ExchangeId::Ibkr,
1338                                    kind: AccountEventKind::Trade(trade),
1339                                }),
1340                            _ => None,
1341                        };
1342
1343                        if let Some(e) = event
1344                            && tx.send(e).is_err()
1345                        {
1346                            // Consumer dropped rx: no point emitting StreamTerminated
1347                            // (the channel is already closed — it would be a no-op).
1348                            return;
1349                        }
1350                    }
1351
1352                    // The subscription iterator ended without an error (e.g. clean
1353                    // disconnect/unsubscribe). Still a terminal stream death — surface it
1354                    // in-band so the consumer doesn't have to infer it from channel EOF.
1355                    emit_stream_terminated(
1356                        &tx,
1357                        ExchangeId::Ibkr,
1358                        StreamTerminationReason::Error(
1359                            "IBKR order-update stream ended".to_string(),
1360                        ),
1361                    );
1362                }));
1363
1364                if let Err(panic_info) = result {
1365                    let msg = panic_info
1366                        .downcast_ref::<&str>()
1367                        .map(|s| s.to_string())
1368                        .or_else(|| panic_info.downcast_ref::<String>().cloned())
1369                        .unwrap_or_else(|| "unknown panic".to_string());
1370                    error!("Order stream worker panicked: {msg}");
1371                    // A panic is a terminal stream death — surface it in-band before tx drops so
1372                    // the consumer gets a programmatic signal rather than inferring EOF.
1373                    // Best-effort: a no-op if the consumer already dropped rx.
1374                    emit_stream_terminated(
1375                        &tx,
1376                        ExchangeId::Ibkr,
1377                        StreamTerminationReason::Error(format!(
1378                            "IBKR order-update worker panicked: {msg}"
1379                        )),
1380                    );
1381                }
1382            })
1383            .map_err(|e| UnindexedClientError::TaskFailed(format!("thread spawn: {e}")))?;
1384
1385        Ok(Box::pin(
1386            tokio_stream::wrappers::UnboundedReceiverStream::new(rx),
1387        ))
1388    }
1389
1390    /// Cancel an order.
1391    ///
1392    /// # Async Cancel Semantics
1393    ///
1394    /// Returns `Ok(Cancelled)` when the cancel request is **submitted**, not when
1395    /// the order is confirmed cancelled. The actual cancellation confirmation comes
1396    /// via `account_stream` as an `OrderStatus::Cancelled` event.
1397    ///
1398    /// The order ID mapping is retained until terminal status is received via
1399    /// `account_stream`, ensuring fill events arriving between cancel request and
1400    /// confirmation are not lost.
1401    async fn cancel_order(
1402        &self,
1403        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1404    ) -> Option<UnindexedOrderResponseCancel> {
1405        let key = OrderKey {
1406            exchange: request.key.exchange,
1407            instrument: request.key.instrument.clone(),
1408            strategy: request.key.strategy.clone(),
1409            cid: request.key.cid.clone(),
1410        };
1411
1412        let ib_order_id = match self.order_ids.get_ib_id(&request.key.cid) {
1413            Some(id) => id,
1414            None => {
1415                return Some(OrderResponseCancel {
1416                    key,
1417                    state: Err(crate::error::OrderError::Rejected(ApiError::OrderRejected(
1418                        "order ID not found in map".to_string(),
1419                    ))),
1420                });
1421            }
1422        };
1423
1424        let client = self.client.clone();
1425
1426        let result =
1427            tokio::task::spawn_blocking(move || client.cancel_order(ib_order_id, "")).await;
1428
1429        match result {
1430            Ok(Ok(_sub)) => {
1431                // Track user-initiated cancel for Cancelled vs Expired differentiation.
1432                // Insert only after cancel request succeeded — avoids stale entries if
1433                // the future is dropped before reaching this branch.
1434                self.pending_cancels.insert(ib_order_id);
1435
1436                // H-3 fix: Do NOT remove order_ids here. The mapping is needed to
1437                // correlate any fill events that arrive between now and when IB
1438                // confirms the cancel. Removal happens in account_stream when
1439                // OrderStatus::Cancelled is received.
1440                // IBKR cancel_order returns no filled qty; use ZERO and let
1441                // subsequent OrderStatus events provide accurate fill info.
1442                Some(OrderResponseCancel {
1443                    key,
1444                    state: Ok(Cancelled::new(
1445                        OrderId::new(format_smolstr!("{}", ib_order_id)),
1446                        Utc::now(),
1447                        Decimal::ZERO,
1448                    )),
1449                })
1450            }
1451            Ok(Err(e)) => {
1452                error!(order_id = ib_order_id, error = %e, "Failed to cancel order");
1453                Some(OrderResponseCancel {
1454                    key,
1455                    state: Err(crate::error::OrderError::Rejected(ApiError::OrderRejected(
1456                        e.to_string(),
1457                    ))),
1458                })
1459            }
1460            Err(e) => {
1461                error!(order_id = ib_order_id, error = %e, "Task join error");
1462                Some(OrderResponseCancel {
1463                    key,
1464                    state: Err(crate::error::OrderError::Rejected(ApiError::OrderRejected(
1465                        e.to_string(),
1466                    ))),
1467                })
1468            }
1469        }
1470    }
1471
1472    /// Submit an order to IB.
1473    ///
1474    /// # Cancellation Safety
1475    ///
1476    /// This future registers the order ID mapping before submitting to IB.
1477    /// If the future is cancelled (e.g., via `tokio::select!` timeout) after
1478    /// registration but before completion:
1479    /// - The order may still be submitted to IB
1480    /// - The order ID mapping will leak (not cleaned up)
1481    /// - Subsequent fills will be processed via `account_stream`
1482    ///
1483    /// Callers should avoid cancelling this future mid-flight. If timeout
1484    /// behavior is needed, prefer setting IB's native order timeout via
1485    /// `TimeInForce` instead.
1486    async fn open_order(
1487        &self,
1488        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1489    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1490        let key = OrderKey {
1491            exchange: ExchangeId::Ibkr,
1492            instrument: request.key.instrument.clone(),
1493            strategy: request.key.strategy.clone(),
1494            cid: request.key.cid.clone(),
1495        };
1496
1497        let contract = match self.contracts.get_contract(request.key.instrument) {
1498            Some(c) => c,
1499            None => {
1500                return Some(Order {
1501                    key,
1502                    side: request.state.side,
1503                    price: request.state.price,
1504                    quantity: request.state.quantity,
1505                    kind: request.state.kind,
1506                    time_in_force: request.state.time_in_force,
1507                    state: OrderState::inactive(OrderError::Rejected(ApiError::InstrumentInvalid(
1508                        request.key.instrument.clone(),
1509                        "contract not registered".to_string(),
1510                    ))),
1511                });
1512            }
1513        };
1514
1515        let quantity: f64 = match request.state.quantity.try_into() {
1516            Ok(q) => q,
1517            Err(_) => {
1518                return Some(Order {
1519                    key,
1520                    side: request.state.side,
1521                    price: request.state.price,
1522                    quantity: request.state.quantity,
1523                    kind: request.state.kind,
1524                    time_in_force: request.state.time_in_force,
1525                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1526                        format!("quantity {} exceeds f64 range", request.state.quantity),
1527                    ))),
1528                });
1529            }
1530        };
1531
1532        let ib_order = match build_ib_order(
1533            request.state.side,
1534            quantity,
1535            &request.state.kind,
1536            request.state.price,
1537            &request.state.time_in_force,
1538        ) {
1539            Ok(o) => o,
1540            Err(e) => {
1541                return Some(Order {
1542                    key,
1543                    side: request.state.side,
1544                    price: request.state.price,
1545                    quantity: request.state.quantity,
1546                    kind: request.state.kind,
1547                    time_in_force: request.state.time_in_force,
1548                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1549                        e.to_string(),
1550                    ))),
1551                });
1552            }
1553        };
1554
1555        let ib_order_id = self.allocate_order_id();
1556
1557        // Store order context for reconstructing Order from OrderStatus callbacks
1558        let context = OrderContext {
1559            instrument: request.key.instrument.clone(),
1560            side: request.state.side,
1561            price: request.state.price,
1562            quantity: request.state.quantity,
1563            kind: request.state.kind,
1564            time_in_force: request.state.time_in_force,
1565        };
1566        self.order_ids
1567            .register(request.key.cid.clone(), ib_order_id, context);
1568
1569        let client = self.client.clone();
1570        let side = request.state.side;
1571        let price = request.state.price;
1572        let req_quantity = request.state.quantity;
1573        let kind = request.state.kind;
1574        let tif = request.state.time_in_force;
1575
1576        // Move both place_order AND subscription iteration into spawn_blocking
1577        // to avoid blocking Tokio worker threads on IB's synchronous iterator.
1578        let result = tokio::task::spawn_blocking(move || {
1579            let sub = match client.place_order(ib_order_id, &contract, &ib_order) {
1580                Ok(s) => s,
1581                Err(e) => return Err(e.to_string()),
1582            };
1583
1584            match await_order_placement(sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)) {
1585                PlacementOutcome::Accepted { filled } => {
1586                    let filled = parse_decimal_or_warn(filled, "status.filled");
1587                    Ok(Some((ib_order_id, filled)))
1588                }
1589                // Cancelled/Inactive/unexpected status or a genuine notice/error.
1590                // Don't remove the mapping here — the outer match centralizes all
1591                // error-path removals.
1592                PlacementOutcome::Rejected(reason) => Err(reason),
1593                // Held until RTH (informational notice): the order is live; its
1594                // authoritative status arrives via account_stream. Surface as
1595                // "no terminal status yet" (Open with zero fill), same as below.
1596                PlacementOutcome::HeldPending(notice) => {
1597                    warn!(ib_order_id, %notice, "order held/pending; tracking via stream");
1598                    Ok(None)
1599                }
1600                // Subscription exhausted/timed out without a terminal status.
1601                // Do NOT remove the mapping — ExecutionData/CommissionReport may
1602                // still arrive via account_stream; clear_stale_order_ids() handles
1603                // stale mappings.
1604                PlacementOutcome::NoStatus => Ok(None),
1605            }
1606        })
1607        .await;
1608
1609        match result {
1610            Ok(Ok(Some((order_id, filled)))) => {
1611                // IB always returns order status via subscription - never immediate fills.
1612                // The filled quantity here is from the OrderStatus event, not a complete fill.
1613                Some(Order {
1614                    key,
1615                    side,
1616                    price,
1617                    quantity: req_quantity,
1618                    kind,
1619                    time_in_force: tif,
1620                    state: OrderState::active(Open::new(
1621                        OrderId::new(format_smolstr!("{}", order_id)),
1622                        Utc::now(),
1623                        filled,
1624                    )),
1625                })
1626            }
1627            Ok(Ok(None)) => {
1628                // Subscription exhausted without terminal status. The order WAS submitted
1629                // (we have an IB order ID), but we lost tracking. Return Open with zero
1630                // filled — caller can query via fetch_open_orders or wait for account_stream.
1631                warn!(
1632                    ib_order_id,
1633                    "Order subscription ended without terminal status, returning Open"
1634                );
1635                Some(Order {
1636                    key,
1637                    side,
1638                    price,
1639                    quantity: req_quantity,
1640                    kind,
1641                    time_in_force: tif,
1642                    state: OrderState::active(Open::new(
1643                        OrderId::new(format_smolstr!("{}", ib_order_id)),
1644                        Utc::now(),
1645                        Decimal::ZERO,
1646                    )),
1647                })
1648            }
1649            Ok(Err(status)) => {
1650                // Cleanup order_ids for rejection (place_order error or Cancelled/Inactive)
1651                self.order_ids.remove_by_ib_id(ib_order_id);
1652                Some(Order {
1653                    key,
1654                    side,
1655                    price,
1656                    quantity: req_quantity,
1657                    kind,
1658                    time_in_force: tif,
1659                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1660                        status,
1661                    ))),
1662                })
1663            }
1664            Err(e) => {
1665                self.order_ids.remove_by_ib_id(ib_order_id);
1666                Some(Order {
1667                    key,
1668                    side,
1669                    price,
1670                    quantity: req_quantity,
1671                    kind,
1672                    time_in_force: tif,
1673                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1674                        e.to_string(),
1675                    ))),
1676                })
1677            }
1678        }
1679    }
1680
1681    /// Fetch account balances.
1682    ///
1683    /// # Limitations
1684    ///
1685    /// The `time_exchange` field in returned balances uses `Utc::now()`, not
1686    /// the actual IB server timestamp. IB's account summary endpoint does not
1687    /// provide timestamps per balance update.
1688    async fn fetch_balances(
1689        &self,
1690        assets: &[AssetNameExchange],
1691    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1692        let client = self.client.clone();
1693        let assets_filter: Option<HashSet<AssetNameExchange>> = if assets.is_empty() {
1694            None
1695        } else {
1696            Some(assets.iter().cloned().collect())
1697        };
1698
1699        tokio::task::spawn_blocking(move || {
1700            // IB's reqAccountSummary expects a group name ("All" for all linked accounts),
1701            // not an account ID. Using account ID causes error 321 "Unified group name is invalid".
1702            // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
1703            let sub = client
1704                .account_summary(&ACCOUNT_GROUP_ALL, &["TotalCashValue", "AvailableFunds"])
1705                .map_err(|e| UnindexedClientError::Internal(format!("account_summary: {e}")))?;
1706
1707            let mut aggregator = BalanceAggregator::new();
1708            for summary in sub.iter_data() {
1709                // Surface errors rather than returning a partial balance set.
1710                let summary = summary
1711                    .map_err(|e| UnindexedClientError::Internal(format!("account_summary: {e}")))?;
1712                match summary {
1713                    AccountSummaryResult::Summary(s) => aggregator.process(&s),
1714                    AccountSummaryResult::End => break,
1715                }
1716            }
1717
1718            let mut balances = aggregator.to_balances();
1719
1720            if let Some(ref filter) = assets_filter {
1721                balances.retain(|b| filter.contains(&b.asset));
1722            }
1723
1724            Ok(balances)
1725        })
1726        .await
1727        .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
1728    }
1729
1730    /// Fetch open orders.
1731    ///
1732    /// # Limitations
1733    ///
1734    /// - The `filled_qty` in returned orders is set to zero. IB's open orders endpoint
1735    ///   returns order definitions, not fill status. For accurate filled quantities,
1736    ///   use `account_stream` which provides `OrderStatus` events with fill progress.
1737    /// - The `time_in_force` defaults to `GoodUntilCancelled`. IB's open orders endpoint
1738    ///   does not return the original TIF setting.
1739    /// - This method blocks on IB's subscription until IB sends an end-of-data marker.
1740    ///   If IB is stalled, this will block indefinitely.
1741    async fn fetch_open_orders(
1742        &self,
1743        instruments: &[InstrumentNameExchange],
1744    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1745        let client = self.client.clone();
1746        let contracts = self.contracts.clone();
1747        let order_ids = self.order_ids.clone();
1748        let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
1749            None
1750        } else {
1751            Some(instruments.iter().cloned().collect())
1752        };
1753
1754        tokio::task::spawn_blocking(move || {
1755            use ibapi::orders::Orders;
1756
1757            // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
1758            let sub = client
1759                .all_open_orders()
1760                .map_err(|e| UnindexedClientError::Internal(format!("open_orders: {e}")))?;
1761
1762            let mut orders = Vec::new();
1763            for order_item in sub.iter_data() {
1764                // Surface errors rather than returning a partial open-order set,
1765                // which the caller could misread during order reconciliation.
1766                let order_item = order_item
1767                    .map_err(|e| UnindexedClientError::Internal(format!("open_orders: {e}")))?;
1768                let order_data = match order_item {
1769                    Orders::OrderData(data) => data,
1770                    _ => continue,
1771                };
1772
1773                let instrument = match contracts.get_name_by_con_id(order_data.contract.contract_id)
1774                {
1775                    Some(i) => i,
1776                    None => continue,
1777                };
1778
1779                if instruments_filter
1780                    .as_ref()
1781                    .is_some_and(|f| !f.contains(&instrument))
1782                {
1783                    continue;
1784                }
1785
1786                let client_id = order_ids
1787                    .get_client_id(order_data.order_id)
1788                    .unwrap_or_else(|| {
1789                        ClientOrderId::new(format_smolstr!("{}", order_data.order_id))
1790                    });
1791
1792                let side = match order_data.order.action {
1793                    ibapi::orders::Action::Buy => Side::Buy,
1794                    ibapi::orders::Action::Sell
1795                    | ibapi::orders::Action::SellShort
1796                    | ibapi::orders::Action::SellLong => Side::Sell,
1797                };
1798
1799                let price = order_data
1800                    .order
1801                    .limit_price
1802                    .map(|p| parse_decimal_or_warn(p, "limit_price"));
1803                let kind = if order_data.order.order_type == "LMT" {
1804                    OrderKind::Limit
1805                } else {
1806                    OrderKind::Market
1807                };
1808
1809                orders.push(Order {
1810                    key: OrderKey {
1811                        exchange: ExchangeId::Ibkr,
1812                        instrument,
1813                        strategy: StrategyId::unknown(),
1814                        cid: client_id,
1815                    },
1816                    side,
1817                    price,
1818                    quantity: parse_decimal_or_warn(
1819                        order_data.order.total_quantity,
1820                        "total_quantity",
1821                    ),
1822                    kind,
1823                    // IB's open orders endpoint doesn't return TIF; default to GTC
1824                    time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1825                    state: Open::new(
1826                        OrderId::new(format_smolstr!("{}", order_data.order_id)),
1827                        Utc::now(),
1828                        Decimal::ZERO, // M-5: filled_qty unavailable from open orders endpoint
1829                    ),
1830                });
1831            }
1832
1833            Ok(orders)
1834        })
1835        .await
1836        .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
1837    }
1838
1839    /// Fetch historical trades (executions).
1840    ///
1841    /// # Limitations
1842    ///
1843    /// - IB only returns executions from the current trading day. The `time_since`
1844    ///   parameter is applied client-side to filter within that day. For historical
1845    ///   executions beyond today, use IB's Flex Query or Activity Statements.
1846    /// - **Fees are always zero.** IB's executions endpoint doesn't include commission
1847    ///   data. For trades with accurate fees, use `account_stream()` which pairs
1848    ///   `ExecutionData` with `CommissionReport` events.
1849    /// - This method blocks on IB's executions subscription until IB sends an
1850    ///   end-of-data marker. If IB is stalled, this will block indefinitely.
1851    async fn fetch_trades(
1852        &self,
1853        time_since: DateTime<Utc>,
1854        instruments: &[InstrumentNameExchange],
1855    ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1856        let client = self.client.clone();
1857        let contracts = self.contracts.clone();
1858        let order_ids = self.order_ids.clone();
1859        let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
1860            None
1861        } else {
1862            Some(instruments.iter().cloned().collect())
1863        };
1864
1865        tokio::task::spawn_blocking(move || {
1866            use ibapi::orders::{ExecutionSide, Executions};
1867
1868            let exec_filter = ibapi::orders::ExecutionFilter::default();
1869            // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
1870            let sub = client
1871                .executions(exec_filter)
1872                .map_err(|e| UnindexedClientError::Internal(format!("executions: {e}")))?;
1873
1874            let mut trades = Vec::new();
1875            for exec_item in sub.iter_data() {
1876                // Surface errors rather than returning a partial fill set, which the
1877                // caller could misread during fill recovery.
1878                let exec_item = exec_item
1879                    .map_err(|e| UnindexedClientError::Internal(format!("executions: {e}")))?;
1880                let exec_data = match exec_item {
1881                    Executions::ExecutionData(data) => data,
1882                    _ => continue,
1883                };
1884
1885                let instrument = match contracts.get_name_by_con_id(exec_data.contract.contract_id)
1886                {
1887                    Some(i) => i,
1888                    None => continue,
1889                };
1890
1891                if instruments_filter
1892                    .as_ref()
1893                    .is_some_and(|f| !f.contains(&instrument))
1894                {
1895                    continue;
1896                }
1897
1898                let exec = &exec_data.execution;
1899                let exec_time = match execution::parse_ib_timestamp(&exec.time) {
1900                    Some(t) => t,
1901                    None => {
1902                        warn!(
1903                            exec_id = %exec.execution_id,
1904                            time = %exec.time,
1905                            "Unparseable timestamp in execution, skipping"
1906                        );
1907                        continue;
1908                    }
1909                };
1910
1911                if exec_time < time_since {
1912                    continue;
1913                }
1914
1915                // `ExecutionSide` is a closed two-variant enum in ibapi 3.x
1916                // (the decoder rejects unknown wire values), so this is total.
1917                let side = match exec.side {
1918                    ExecutionSide::Bought => Side::Buy,
1919                    ExecutionSide::Sold => Side::Sell,
1920                };
1921
1922                let client_id = order_ids
1923                    .get_client_id(exec.order_id)
1924                    .unwrap_or_else(|| ClientOrderId::new(format_smolstr!("{}", exec.order_id)));
1925
1926                trades.push(Trade {
1927                    id: TradeId::new(&exec.execution_id),
1928                    order_id: OrderId::new(&client_id.0),
1929                    instrument,
1930                    strategy: StrategyId::unknown(),
1931                    time_exchange: exec_time,
1932                    side,
1933                    price: parse_decimal_or_warn(exec.price, "exec.price"),
1934                    quantity: parse_decimal_or_warn(exec.shares, "exec.shares"),
1935                    order_filled_quantity: Some(parse_decimal_or_warn(
1936                        exec.cumulative_quantity,
1937                        "exec.cumulative_quantity",
1938                    )),
1939                    // IBKR executions API lacks commission data (available via CommissionReport callback).
1940                    // "UNKNOWN" placeholder will fail indexing - use unindexed or correlate with WS.
1941                    fees: AssetFees::new(AssetNameExchange::from("UNKNOWN"), Decimal::ZERO, None),
1942                });
1943            }
1944
1945            Ok(trades)
1946        })
1947        .await
1948        .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
1949    }
1950}
1951
1952/// Build an Order from IB OrderStatus using stored OrderContext.
1953///
1954/// # IBKR Status Mapping
1955///
1956/// | `OrderStatusKind`                           | → OrderState         | Rationale                                   |
1957/// |---------------------------------------------|----------------------|---------------------------------------------|
1958/// | `Inactive`                                  | OpenFailed(Rejected) | Order blocked by validation/margin/exchange |
1959/// | `Cancelled`                                 | Cancelled or Expired | See differentiation logic below             |
1960/// | `Filled`                                    | FullyFilled          | Order fully executed                        |
1961/// | `Submitted`/`PreSubmitted`/`PendingSubmit`  | Active(Open)         | Order working on exchange                   |
1962/// | `ApiPending`/`PendingCancel`/`ApiCancelled` | Active(Open)         | Transitional; await a confirmed terminal    |
1963/// | `Unknown(raw)`                              | Active(Open) + warn  | Unmodelled status; stream is authoritative  |
1964///
1965/// The match is exhaustive over `OrderStatusKind` (no wildcard), so a new ibapi
1966/// *variant* becomes a compile error rather than a silent `Active(Open)`.
1967///
1968/// Since ibapi 4.0 a new TWS *status string* no longer reaches that guard: it
1969/// decodes to `OrderStatusKind::Unknown(raw)` instead of failing the whole
1970/// subscription with `Error::Parse`. This function handles that variant
1971/// explicitly and logs it at `warn!`, so an unrecognised status is observable
1972/// rather than silent — but it is no longer a compile error.
1973///
1974/// # Cancelled vs Expired Differentiation
1975///
1976/// IBKR sends `"Cancelled"` status for both user-initiated cancellation and
1977/// time-based expiration. We differentiate using:
1978///
1979/// 1. If order ID is in `pending_cancels` (set by `cancel_order`) → `Cancelled`
1980/// 2. Else if `time_in_force == GoodUntilEndOfDay` → `Expired` (DAY order expired)
1981/// 3. Else → `Cancelled` (broker-initiated or external cancellation)
1982///
1983/// # Known Limitation
1984///
1985/// If the broker cancels a DAY order before market close (e.g., insufficient margin),
1986/// it will be misclassified as `Expired`. This is rare and acceptable given the
1987/// alternative (forking ibapi to preserve order_id in error callbacks).
1988fn make_order_from_status(
1989    status: &ibapi::orders::OrderStatus,
1990    client_id: ClientOrderId,
1991    ctx: &OrderContext,
1992    pending_cancels: &PendingCancels,
1993) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
1994{
1995    use ibapi::orders::OrderStatusKind;
1996
1997    let ib_id = status.order_id;
1998    let order_id = OrderId::new(format_smolstr!("{}", ib_id));
1999
2000    let filled_qty = parse_decimal_or_warn(status.filled, "status.filled");
2001    let state = match status.status {
2002        OrderStatusKind::Inactive => {
2003            // "Inactive" means the order was accepted by IB but is not working:
2004            // validation failure, margin issue, exchange closed, share location hold.
2005            // This is a placement failure, not a cancellation.
2006            OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2007                "IB status: Inactive (order blocked by validation/margin/exchange)".into(),
2008            )))
2009        }
2010        OrderStatusKind::Cancelled => {
2011            // Differentiate user-cancel from time-expiration
2012            let was_user_cancel = pending_cancels.remove(ib_id);
2013
2014            if was_user_cancel {
2015                // User called cancel_order() — definitely a cancellation
2016                OrderState::inactive(Cancelled::new(order_id, Utc::now(), filled_qty))
2017            } else if matches!(ctx.time_in_force, TimeInForce::GoodUntilEndOfDay) {
2018                // DAY order without pending cancel — expired at market close
2019                OrderState::inactive(Expired::new(order_id, Utc::now(), filled_qty))
2020            } else {
2021                // GTC/IOC/FOK without pending cancel — broker or exchange cancelled
2022                OrderState::inactive(Cancelled::new(order_id, Utc::now(), filled_qty))
2023            }
2024        }
2025        OrderStatusKind::Filled => {
2026            OrderState::fully_filled(Filled::new(order_id, Utc::now(), filled_qty, None))
2027        }
2028        // Working states (Submitted/PreSubmitted/PendingSubmit) and the
2029        // transitional ones (ApiPending/PendingCancel/ApiCancelled) are reported
2030        // as active/Open until a confirmed Cancelled/Inactive/Filled arrives.
2031        // ApiCancelled is deliberately NOT treated as terminal here: it precedes
2032        // a confirmed Cancelled, whose event reaps the order-id mapping (or
2033        // clear_stale does), mirroring the account_stream `is_terminal` guard.
2034        // Enumerated explicitly so a future ibapi variant fails to compile rather
2035        // than silently defaulting to Open.
2036        OrderStatusKind::Submitted
2037        | OrderStatusKind::PreSubmitted
2038        | OrderStatusKind::PendingSubmit
2039        | OrderStatusKind::ApiPending
2040        | OrderStatusKind::PendingCancel
2041        | OrderStatusKind::ApiCancelled => {
2042            OrderState::active(Open::new(order_id, Utc::now(), filled_qty))
2043        }
2044        // A status string ibapi does not model. Upstream declines to classify
2045        // it — `is_active()` and `is_terminal()` are both false — and so do we:
2046        // report it active/Open so the order-id mapping is retained and the
2047        // account stream can still resolve the order's real state. The
2048        // alternative, treating it as a rejection, would drop the mapping for
2049        // an order that may well be live in the market and discard its
2050        // executions and commissions. An order that is in fact dead and left
2051        // Open here is reaped by `OrderIdMap::clear_stale`.
2052        OrderStatusKind::Unknown(ref raw) => {
2053            warn!(
2054                ib_id,
2055                status = raw.as_str(),
2056                "unmodelled IBKR order status; treating as live, account \
2057                 stream is authoritative"
2058            );
2059            OrderState::active(Open::new(order_id, Utc::now(), filled_qty))
2060        }
2061    };
2062
2063    Order {
2064        key: OrderKey {
2065            exchange: ExchangeId::Ibkr,
2066            instrument: ctx.instrument.clone(),
2067            strategy: StrategyId::unknown(),
2068            cid: client_id,
2069        },
2070        side: ctx.side,
2071        price: ctx.price,
2072        quantity: ctx.quantity,
2073        kind: ctx.kind,
2074        time_in_force: ctx.time_in_force,
2075        state,
2076    }
2077}
2078
2079pub use execution::parse_ib_timestamp;
2080
2081impl BracketOrderClient for IbkrClient {
2082    async fn open_bracket_order(
2083        &self,
2084        request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
2085    ) -> UnifiedBracketOrderResult {
2086        let ibkr_request = BracketOrderRequest {
2087            instrument: request.key.instrument.clone(),
2088            strategy: request.key.strategy.clone(),
2089            parent_cid: request.key.cid.clone(),
2090            side: request.state.side,
2091            quantity: request.state.quantity,
2092            entry_price: request.state.entry_price,
2093            take_profit_price: request.state.take_profit_price,
2094            stop_loss_price: request.state.stop_loss_price,
2095            time_in_force: request.state.time_in_force,
2096        };
2097
2098        let result = self.open_bracket_order(ibkr_request).await;
2099
2100        UnifiedBracketOrderResult::with_all_legs(
2101            result.parent,
2102            result.take_profit,
2103            result.stop_loss,
2104        )
2105    }
2106}
2107
2108#[cfg(test)]
2109#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
2110mod contract_config_tests {
2111    use super::*;
2112    use contract::ContractConfigError as E;
2113
2114    /// A `ContractConfig` with every optional field present, so individual tests
2115    /// can null out exactly the field under test.
2116    fn full_config(security_type: &str) -> ContractConfig {
2117        ContractConfig {
2118            name: "TEST".to_string(),
2119            symbol: "AAPL".to_string(),
2120            security_type: security_type.to_string(),
2121            exchange: "SMART".to_string(),
2122            currency: "USD".to_string(),
2123            last_trade_date: Some("20260116".to_string()),
2124            strike: Some(150.0),
2125            right: Some("C".to_string()),
2126        }
2127    }
2128
2129    #[test]
2130    fn stk_and_cash_ignore_option_fields() {
2131        // STK/CASH never read last_trade_date/strike/right, so absence is fine.
2132        let mut stk = full_config("STK");
2133        stk.last_trade_date = None;
2134        stk.strike = None;
2135        stk.right = None;
2136        assert!(stk.to_contract().is_ok());
2137
2138        let mut cash = full_config("CASH");
2139        cash.last_trade_date = None;
2140        cash.strike = None;
2141        cash.right = None;
2142        assert!(cash.to_contract().is_ok());
2143    }
2144
2145    #[test]
2146    fn valid_fut_and_opt_build() {
2147        assert!(full_config("FUT").to_contract().is_ok());
2148        assert!(full_config("OPT").to_contract().is_ok());
2149    }
2150
2151    #[test]
2152    fn opt_missing_right_is_error_not_silent_call() {
2153        let mut cfg = full_config("OPT");
2154        cfg.right = None;
2155        assert_eq!(cfg.to_contract().unwrap_err(), E::MissingOptionRight);
2156    }
2157
2158    #[test]
2159    fn opt_unrecognized_right_is_error() {
2160        let mut cfg = full_config("OPT");
2161        cfg.right = Some("X".to_string());
2162        assert_eq!(
2163            cfg.to_contract().unwrap_err(),
2164            E::UnrecognizedOptionRight {
2165                right: "X".to_string()
2166            }
2167        );
2168    }
2169
2170    #[test]
2171    fn opt_missing_strike_is_error() {
2172        let mut cfg = full_config("OPT");
2173        cfg.strike = None;
2174        assert_eq!(cfg.to_contract().unwrap_err(), E::MissingStrike);
2175    }
2176
2177    #[test]
2178    fn fut_and_opt_missing_last_trade_date_is_error() {
2179        let mut fut = full_config("FUT");
2180        fut.last_trade_date = None;
2181        assert_eq!(fut.to_contract().unwrap_err(), E::MissingLastTradeDate);
2182
2183        let mut opt = full_config("OPT");
2184        opt.last_trade_date = None;
2185        assert_eq!(opt.to_contract().unwrap_err(), E::MissingLastTradeDate);
2186    }
2187
2188    #[test]
2189    fn unknown_security_type_is_error_not_silent_stk() {
2190        let cfg = full_config("BOND");
2191        assert_eq!(
2192            cfg.to_contract().unwrap_err(),
2193            E::UnrecognizedSecurityType {
2194                security_type: "BOND".to_string()
2195            }
2196        );
2197    }
2198}
2199
2200#[cfg(test)]
2201#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
2202mod order_status_tests {
2203    use super::*;
2204    use ibapi::orders::{OrderStatus, OrderStatusKind, PlaceOrder};
2205
2206    fn status(kind: OrderStatusKind, filled: f64) -> Result<PlaceOrder, ibapi::Error> {
2207        Ok(PlaceOrder::OrderStatus(OrderStatus {
2208            order_id: 42,
2209            status: kind,
2210            filled,
2211            ..OrderStatus::default()
2212        }))
2213    }
2214
2215    fn ctx() -> OrderContext {
2216        OrderContext {
2217            instrument: InstrumentNameExchange::new("AAPL"),
2218            side: Side::Buy,
2219            price: Some(Decimal::from(150)),
2220            quantity: Decimal::from(10),
2221            kind: OrderKind::Limit,
2222            time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
2223        }
2224    }
2225
2226    /// ibapi 4.0 preserves an unrecognised TWS status as
2227    /// `OrderStatusKind::Unknown(raw)` instead of failing the subscription with
2228    /// `Error::Parse`. Placement must treat it as live-but-indeterminate: the
2229    /// order exists, so the order-id mapping has to survive for the account
2230    /// stream to resolve it.
2231    #[test]
2232    fn unknown_status_is_held_pending_during_placement() {
2233        let events = vec![status(OrderStatusKind::Unknown("Reclassified".into()), 0.0)];
2234
2235        match await_order_placement(events) {
2236            PlacementOutcome::HeldPending(reason) => {
2237                assert!(
2238                    reason.contains("Reclassified"),
2239                    "the raw status must reach the caller, got {reason:?}"
2240                );
2241            }
2242            other => panic!("expected HeldPending, got {other:?}"),
2243        }
2244    }
2245
2246    /// Specifically *not* the `ApiPending` treatment. Falling through to the
2247    /// `PLACEMENT_STATUS_TIMEOUT` backstop yields `NoStatus`, which on the
2248    /// bracket path cancels all three legs — so an unrecognised status must
2249    /// produce a decision here rather than deferring to the timeout.
2250    #[test]
2251    fn unknown_status_does_not_fall_through_to_no_status() {
2252        let events = vec![status(OrderStatusKind::Unknown("Reclassified".into()), 0.0)];
2253        assert!(!matches!(
2254            await_order_placement(events),
2255            PlacementOutcome::NoStatus
2256        ));
2257
2258        // ApiPending, by contrast, keeps waiting and exhausts the iterator.
2259        let events = vec![status(OrderStatusKind::ApiPending, 0.0)];
2260        assert!(matches!(
2261            await_order_placement(events),
2262            PlacementOutcome::NoStatus
2263        ));
2264    }
2265
2266    /// Regression anchors: the statuses that were already decisive must keep
2267    /// their outcomes across the 4.0 migration.
2268    #[test]
2269    fn known_statuses_keep_their_placement_outcomes() {
2270        assert!(matches!(
2271            await_order_placement(vec![status(OrderStatusKind::Submitted, 0.0)]),
2272            PlacementOutcome::Accepted { .. }
2273        ));
2274        assert!(matches!(
2275            await_order_placement(vec![status(OrderStatusKind::Filled, 10.0)]),
2276            PlacementOutcome::Accepted { filled } if filled == 10.0
2277        ));
2278        assert!(matches!(
2279            await_order_placement(vec![status(OrderStatusKind::Inactive, 0.0)]),
2280            PlacementOutcome::Rejected(_)
2281        ));
2282        assert!(matches!(
2283            await_order_placement(vec![status(OrderStatusKind::Cancelled, 0.0)]),
2284            PlacementOutcome::Rejected(_)
2285        ));
2286    }
2287
2288    /// An unmodelled status must not be reported as terminal. Treating it as a
2289    /// rejection would drop the order-id mapping for an order that may well be
2290    /// live in the market, discarding its executions and commissions.
2291    #[test]
2292    fn unknown_status_maps_to_active_open() {
2293        let raw = OrderStatus {
2294            order_id: 42,
2295            status: OrderStatusKind::Unknown("Reclassified".into()),
2296            filled: 3.0,
2297            ..OrderStatus::default()
2298        };
2299
2300        let order = make_order_from_status(
2301            &raw,
2302            ClientOrderId::new("cid-1"),
2303            &ctx(),
2304            &PendingCancels::new(),
2305        );
2306
2307        assert!(
2308            matches!(order.state, OrderState::Active(_)),
2309            "unmodelled status must stay active, got {:?}",
2310            order.state
2311        );
2312    }
2313
2314    /// An unmodelled status must not consume a pending cancel: the entry has to
2315    /// survive for the confirmed `Cancelled` that may still follow.
2316    #[test]
2317    fn unknown_status_leaves_pending_cancel_intact() {
2318        let pending = PendingCancels::new();
2319        pending.insert(42);
2320
2321        let raw = OrderStatus {
2322            order_id: 42,
2323            status: OrderStatusKind::Unknown("Reclassified".into()),
2324            ..OrderStatus::default()
2325        };
2326        let _ = make_order_from_status(&raw, ClientOrderId::new("cid-1"), &ctx(), &pending);
2327
2328        assert!(
2329            pending.remove(42),
2330            "the pending cancel must still be there for the confirmed Cancelled"
2331        );
2332    }
2333}