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