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}