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