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