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//! - **Order lifecycle events missed during a gap are not recovered** (see below)
38//!
39//! # Reconnection & Recovery
40//!
41//! `ibapi` reconnects its socket to TWS/Gateway by itself, with the same client ID.
42//! In-flight requests fail with `ConnectionReset` at the drop, and new sends are refused
43//! until the reconnect completes. Order sends classify that as a transient
44//! [`OrderError::Connectivity`]. If the reconnect fails, `ibapi` shuts the client down.
45//!
46//! [`ExecutionClient::account_stream`] stays open across a successful reconnect. It
47//! detects the gap and, once delivery is restored, recovers the fills TWS sent while
48//! the socket was down, emitting each as a `Trade` exactly once. The same applies when
49//! TWS loses its own link to IB's servers (notice 1100) and later restores it (1101/1102).
50//! See that method for the details.
51//!
52//! # Caller Responsibilities
53//!
54//! 1. **Order reconciliation**: order lifecycle events sent during a gap are lost. An
55//! order cancelled, expired or rejected in that window is not reported. After a
56//! reconnect, call [`ExecutionClient::fetch_open_orders`] to reconcile open-order
57//! state. The IBKR account snapshot carries no open orders (#371). On `ibapi` 4.2.0
58//! that call is unreliable, and fails for a while after a reconnect: see
59//! [Known Issues](#known-issues-ibapi-420-shared-queues).
60//! 2. **Permanent disconnect**: when `ibapi` gives up reconnecting, or fill recovery
61//! fails repeatedly, `account_stream` ends with `StreamTerminated` within about a
62//! second. Its reader thread then stays blocked until TWS sends another event, and
63//! another `account_stream` call on the client fails until the thread exits. After
64//! recovery fails, the client is still connected, so the reader exits on the next
65//! TWS event and `account_stream` works again. After a shutdown, no event follows,
66//! so replace the client. Shutdown is detected only while a stream is open, so
67//! `account_stream` on a client that has already shut down returns a stream that
68//! never ends; see [`ExecutionClient::account_stream`]. Replacing the client, by
69//! reconnecting with [`IbkrClient::connect_sync`] and choosing the client ID, is the
70//! caller's decision. A new `IbkrClient` does not know the orders the old one
71//! placed, so their later events are dropped.
72//! 3. **Stale state cleanup**: Periodically call [`IbkrClient::clear_stale_executions`],
73//! [`IbkrClient::clear_stale_order_ids`], and [`IbkrClient::clear_stale_pending_cancels`]
74//!
75//! **Rationale**: IBKR uses TCP to local TWS/Gateway, not cloud WebSocket. `ibapi`
76//! owns the transient reconnect. Replacing a client that is gone for good requires IB
77//! Gateway availability and client ID coordination, decisions that belong in the
78//! caller's wrapper, not the library.
79//!
80//! # Known Issues: `ibapi` 4.2.0 Shared Queues
81//!
82//! `ibapi` 4.2.0 answers the open-orders and positions requests from one queue per
83//! request type. Every call on the client reads from the same queue, and nothing clears
84//! it between calls. Besides each call's own reply, two things land in it:
85//!
86//! - A connection drop queues one `ConnectionReset` per response type: three on the
87//! open-orders queue, two on the positions queue.
88//! - Every `OpenOrder` and `OrderStatus` message for an order with no live placement
89//! subscription is copied into `ibapi`'s three open-orders queues. That covers every
90//! update for an order this client placed, once its placement call has returned, and
91//! every row of an open-orders reply.
92//!
93//! As a result:
94//!
95//! - [`ExecutionClient::fetch_open_orders`] can report orders that have since filled or
96//! been cancelled as open. Each connection drop makes three calls fail and adds three
97//! calls of lag, which never clears. See that method.
98//! - [`ExecutionClient::account_snapshot`] fails twice after each connection drop, then
99//! recovers.
100//! - This client never reads two of the three open-orders queues, and reads the third
101//! only when `fetch_open_orders` is called. They keep a copy of every order update
102//! until read, so the two unread ones grow for the life of the client: `ibapi`'s own
103//! reconnect does not clear them. `ibapi` logs a warning each time one of them passes a
104//! multiple of 10,000 queued messages.
105//!
106//! [Account order events](ExecutionClient::account_stream) come through a separate
107//! channel and are unaffected.
108//!
109//! Fixed upstream after 4.2.0 by
110//! [rust-ibapi#836](https://github.com/wboayue/rust-ibapi/pull/836), which gives each
111//! call its own queue and drops messages no call asked for.
112//!
113//! # See Also
114//!
115//! - [IB API Documentation](https://www.interactivebrokers.com/campus/ibkr-api-page/trader-workstation-api/)
116//! - `rustrade_data::exchange::ibkr` for market data
117
118pub mod account;
119pub mod contract;
120pub mod execution;
121pub mod order;
122mod recovery;
123
124use crate::{
125 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, Snapshot, UnindexedAccountEvent,
126 UnindexedAccountSnapshot,
127 balance::AssetBalance,
128 client::{BracketOrderClient, ExecutionClient, dedup::new_dedup_cache},
129 error::{
130 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
131 },
132 order::{
133 Order, OrderKey, OrderKind, TimeInForce,
134 bracket::{
135 BracketOrderRequest as UnifiedBracketOrderRequest,
136 BracketOrderResult as UnifiedBracketOrderResult,
137 },
138 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
139 request::{
140 OrderRequestCancel, OrderRequestOpen, OrderResponseCancel, UnindexedOrderResponseCancel,
141 },
142 state::{Cancelled, Expired, Filled, Open, OrderState, UnindexedOrderState},
143 },
144 trade::{AssetFees, Trade, TradeId},
145};
146use account::BalanceAggregator;
147use chrono::{DateTime, Utc};
148use execution::{ExecutionBuffer, parse_decimal_or_warn};
149use futures::stream::BoxStream;
150use ibapi::{
151 accounts::{AccountSummaryResult, types::AccountGroup},
152 client::blocking::Client,
153};
154pub use order::{BracketOrderRequest, BracketOrderResult};
155use order::{
156 OrderContext, OrderIdMap, PendingCancels, build_ib_bracket_with_oca, build_ib_order,
157 side_to_action, time_in_force_to_ib,
158};
159use parking_lot::Mutex;
160use rust_decimal::Decimal;
161use rustrade_instrument::{
162 Side,
163 asset::name::AssetNameExchange,
164 exchange::ExchangeId,
165 ibkr::ContractRegistry,
166 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
167};
168use serde::{Deserialize, Serialize};
169use smol_str::format_smolstr;
170use std::{
171 collections::HashSet,
172 panic::{AssertUnwindSafe, catch_unwind},
173 sync::Arc,
174};
175use tokio::sync::mpsc;
176use tracing::{debug, error, info, trace, warn};
177
178/// Configuration for the IBKR execution client.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct IbkrConfig {
181 /// TWS/Gateway host (e.g., "127.0.0.1")
182 pub host: String,
183 /// TWS/Gateway port (7496=TWS live, 7497=TWS paper, 4001=GW live, 4002=GW paper)
184 pub port: u16,
185 /// Client ID (must be unique per connection)
186 pub client_id: i32,
187 /// Account ID (e.g., "DU123456" for paper).
188 ///
189 /// Currently unused — balance/position queries use "All" group.
190 /// Reserved for future multi-account routing (advisor accounts).
191 pub account: String,
192 /// Pre-configured contracts to register on startup
193 #[serde(default)]
194 pub contracts: Vec<ContractConfig>,
195}
196
197/// Pre-configured contract for startup registration.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ContractConfig {
200 pub name: String,
201 pub symbol: String,
202 pub security_type: String,
203 pub exchange: String,
204 pub currency: String,
205 #[serde(default)]
206 pub last_trade_date: Option<String>,
207 /// Strike price for options. Uses f64 because `ibapi::Contract.strike` is f64.
208 #[serde(default)]
209 pub strike: Option<f64>,
210 #[serde(default)]
211 pub right: Option<String>,
212}
213
214impl ContractConfig {
215 /// Convert this config into an [`ibapi::contracts::Contract`].
216 ///
217 /// # Errors
218 ///
219 /// Returns a [`ContractConfigError`](contract::ContractConfigError) instead of
220 /// silently fabricating a wrong contract when the config is incomplete or
221 /// unsupported:
222 /// - [`UnrecognizedSecurityType`](contract::ContractConfigError::UnrecognizedSecurityType)
223 /// — `security_type` is not one of `STK`/`FUT`/`OPT`/`CASH`.
224 /// - [`MissingLastTradeDate`](contract::ContractConfigError::MissingLastTradeDate)
225 /// — `last_trade_date` is absent on a `FUT` or `OPT` contract.
226 /// - [`MissingStrike`](contract::ContractConfigError::MissingStrike) — `strike`
227 /// is absent on an `OPT` contract.
228 /// - [`MissingOptionRight`](contract::ContractConfigError::MissingOptionRight) —
229 /// `right` is absent on an `OPT` contract.
230 /// - [`UnrecognizedOptionRight`](contract::ContractConfigError::UnrecognizedOptionRight)
231 /// — `right` is present but not one of `C`/`CALL`/`P`/`PUT` (case-insensitive).
232 fn to_contract(&self) -> Result<ibapi::contracts::Contract, contract::ContractConfigError> {
233 use contract::ContractConfigError as E;
234 Ok(match self.security_type.as_str() {
235 "STK" => contract::stock_contract(&self.symbol, &self.exchange, &self.currency),
236 "FUT" => contract::futures_contract(
237 &self.symbol,
238 self.last_trade_date
239 .as_deref()
240 .ok_or(E::MissingLastTradeDate)?,
241 &self.exchange,
242 &self.currency,
243 ),
244 "OPT" => contract::option_contract(
245 &self.symbol,
246 self.last_trade_date
247 .as_deref()
248 .ok_or(E::MissingLastTradeDate)?,
249 self.strike.ok_or(E::MissingStrike)?,
250 self.right.as_deref().ok_or(E::MissingOptionRight)?,
251 &self.exchange,
252 &self.currency,
253 )?,
254 "CASH" => contract::forex_contract(&self.symbol, &self.currency),
255 other => {
256 return Err(E::UnrecognizedSecurityType {
257 security_type: other.to_string(),
258 });
259 }
260 })
261 }
262}
263
264/// Account group for "All" accounts (used by reqAccountSummary).
265static ACCOUNT_GROUP_ALL: std::sync::LazyLock<AccountGroup> =
266 std::sync::LazyLock::new(|| AccountGroup("All".to_string()));
267
268/// Quiet period that ends the positions read in `account_snapshot`.
269///
270/// IB's positions request is a live subscription that keeps streaming after its
271/// `PositionEnd` marker, so the read stops once this long passes without an
272/// update. It does not stop at `PositionEnd`: on `ibapi` 4.2.0 the positions
273/// queue is shared across calls (see the module's Known Issues), so after a
274/// connection drop it may hold the replies to the calls that failed. Stopping at
275/// the first `PositionEnd` would return one of those replies and leave the rest
276/// for the next call, which would lag behind for good. Reading until quiet
277/// drains them.
278const POSITION_STREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
279
280/// Maximum time to await an initial `OrderStatus` on an order-placement
281/// subscription before giving up. Bounds the placement loops so a silent TWS
282/// (e.g. a half-open socket) cannot hang them indefinitely. A timeout yields
283/// [`PlacementOutcome::NoStatus`]; the order may still have been submitted, so
284/// callers resolve the final state via the order-update/account stream.
285const PLACEMENT_STATUS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
286
287/// IBKR "Order Message" codes that are informational rather than rejections.
288///
289/// ibapi classifies the `200..=399` range as order rejections
290/// (`ORDER_REJECTION_CODE_RANGE`) — bar 202 and, since 4.1.0, 317, both of
291/// which are resolved ahead of the range — but IBKR uses code 399 as a generic order
292/// message — e.g. *"Your order will not be placed at the exchange until
293/// 09:30:00 US/Eastern"* — for an order that is in fact **accepted and held**
294/// (it proceeds to `PreSubmitted`). We therefore report the order as
295/// live-but-pending ([`PlacementOutcome::HeldPending`]) rather than rejected
296/// when one of these codes arrives.
297///
298/// # ibapi 4.0 splits code 399 by message text
299///
300/// `is_warning_message` now classifies a 399 whose text carries a `Warning:`
301/// line as a warning, and `classify_error` routes a warning owned by a
302/// request-bound subscription as a non-terminal `RoutedItem::Notice` rather
303/// than a stream-ending `RoutedItem::Error`. `timeout_iter_data` drops notices,
304/// so the two forms now reach [`await_order_placement`] differently:
305///
306/// - **399 carrying a `Warning:` line** — dropped, and the subscription stays
307/// open. The eventual `OrderStatus(PreSubmitted)` therefore arrives on the
308/// placement subscription itself and yields
309/// [`PlacementOutcome::Accepted`] without this list being consulted. Under
310/// ibapi 3.x that status was reachable only via the order-update/account
311/// stream, because the notice had already closed the subscription.
312/// - **399 without one** — still `Err(Error::Notice)`, still closes the
313/// subscription, and is still matched against this list.
314///
315/// Re-verified against ibapi 4.1.0, which reworked this classification: the
316/// warning band widened to `2100..=2199`, `classify_error`'s predicate became
317/// `is_informational_code`, and 317 joined the data advisories. The change is
318/// strictly additive and 399 is in neither the advisory nor the system list, so
319/// both forms above route exactly as described.
320///
321/// The list stays load-bearing for the second form. It documents the known gap
322/// between ibapi's range heuristic and IBKR's actual protocol semantics; if
323/// IBKR adds further informational codes in the 200-399 range, an out-of-RTH
324/// placement test will surface them and they can be added here.
325const INFORMATIONAL_ORDER_CODES: &[i32] = &[399];
326
327/// Outcome of awaiting the initial status on an order-placement subscription.
328///
329/// The authoritative acceptance/rejection signal in the IBKR protocol is the
330/// `OrderStatus` event, not an error notice — see [`await_order_placement`].
331///
332/// Every variant carries a distinct order-placement outcome that callers must
333/// dispatch on; dropping a value would silently discard the order's status.
334#[must_use]
335#[derive(Debug)]
336enum PlacementOutcome {
337 /// The order was acknowledged by IB and its order-id mapping must be
338 /// retained so the account stream can resolve its terminal/fill state. This
339 /// covers the working statuses (`Submitted`/`PreSubmitted`/`PendingSubmit`),
340 /// an immediate `Filled`, and the transitional cancel states
341 /// (`ApiCancelled`/`PendingCancel`) that precede a confirmed terminal status.
342 /// Carries the filled quantity reported with the status.
343 Accepted { filled: f64 },
344 /// A terminal rejection: a `Cancelled`/`Inactive` `OrderStatus`, a genuine
345 /// non-informational TWS notice, or an ibapi error other than transport
346 /// loss (see [`is_transport_loss`]). Carries the human-readable reason.
347 Rejected(String),
348 /// The order exists, but the placement subscription could not classify its
349 /// state: either an informational notice (see
350 /// [`INFORMATIONAL_ORDER_CODES`]) closed the subscription, or TWS reported
351 /// a status ibapi does not model (`OrderStatusKind::Unknown`). Either way
352 /// the order is live/held and its authoritative status will arrive via the
353 /// order-update/account stream. Carries the notice or status text.
354 HeldPending(String),
355 /// The subscription ended — or [`PLACEMENT_STATUS_TIMEOUT`] elapsed, or the
356 /// transport was lost — without any terminal status or informational
357 /// notice. The order may or may not have been accepted; resolve via the
358 /// order-update/account stream.
359 NoStatus,
360}
361
362/// Whether `e` means the TWS transport was lost, as opposed to TWS or ibapi
363/// answering the request.
364///
365/// Since ibapi 4.2 the sync transport fails in-flight requests with one of these
366/// the moment the socket drops, and refuses new sends until the reconnect has
367/// completed. None of them says what TWS did with an order already written.
368fn is_transport_loss(e: &ibapi::Error) -> bool {
369 matches!(
370 e,
371 ibapi::Error::ConnectionReset
372 | ibapi::Error::ConnectionFailed
373 | ibapi::Error::Shutdown
374 | ibapi::Error::Io(_)
375 )
376}
377
378/// The instrument and client order id `execution` belongs to, or `None` when this
379/// client does not track its order or its contract.
380fn resolve_execution(
381 execution: &ibapi::orders::ExecutionData,
382 contracts: &ContractRegistry,
383 order_ids: &OrderIdMap,
384) -> Option<(InstrumentNameExchange, ClientOrderId)> {
385 let order_id = execution.execution.order_id;
386 let con_id = execution.contract.contract_id;
387
388 // Fail-fast: skip second lookup if first fails
389 let Some(client_id) = order_ids.get_client_id(order_id) else {
390 debug!(
391 ib_order_id = order_id,
392 con_id, "ExecutionData for unknown order ID, dropping"
393 );
394 return None;
395 };
396 let Some(instrument) = contracts.get_name_by_con_id(con_id) else {
397 debug!(
398 ib_order_id = order_id,
399 con_id, "ExecutionData for unknown contract ID, dropping"
400 );
401 return None;
402 };
403 Some((instrument, client_id))
404}
405
406/// Forward `updates`, from `ibapi`'s order-update subscription, to `sink` as account events,
407/// until the subscription ends or the account stream does.
408///
409/// A closed stream is noticed on the next update, whether or not that update would be
410/// forwarded, so the thread running this releases `ibapi`'s single order-update slot as soon as
411/// TWS sends anything.
412fn forward_order_updates(
413 updates: impl IntoIterator<Item = Result<ibapi::orders::OrderUpdate, ibapi::Error>>,
414 sink: &recovery::EventSink,
415 contracts: &ContractRegistry,
416 order_ids: &OrderIdMap,
417 pending_cancels: &PendingCancels,
418 exec_buffer: &ExecutionBuffer,
419) {
420 use ibapi::orders::{OrderStatusKind, OrderUpdate};
421
422 for update in updates {
423 // Recovery ended the stream, or the consumer dropped it. Stop on this update rather than
424 // the next one that would be forwarded, which may never come.
425 if !sink.is_open() {
426 return;
427 }
428 let update = match update {
429 Ok(u) => u,
430 Err(e) => {
431 // ibapi's own reconnect does not surface here (see the
432 // recovery module), so an error on the subscription is terminal.
433 // Surface it in-band as StreamTerminated(Error) so the caller
434 // gets a programmatic signal rather than inferring EOF.
435 // (best-effort — a no-op if the consumer already dropped rx.)
436 error!(error = %e, "Order stream subscription error");
437 sink.terminate(StreamTerminationReason::Error(e.to_string()));
438 return;
439 }
440 };
441 let event = match update {
442 OrderUpdate::OrderStatus(status) => {
443 let ib_id = status.order_id;
444 // Use single-lock method for terminal status to avoid read+write.
445 // Only `Cancelled`/`Inactive` remove the mapping here: a `Filled`
446 // order's mapping is intentionally retained so late-arriving
447 // ExecutionData/CommissionReport events still resolve it (reaped
448 // later by `OrderIdMap::clear_stale`), so this is deliberately
449 // narrower than `OrderStatusKind::is_terminal()`.
450 let is_terminal = matches!(
451 status.status,
452 OrderStatusKind::Cancelled | OrderStatusKind::Inactive
453 );
454
455 let lookup_result = if is_terminal {
456 order_ids.remove_and_get_context(ib_id)
457 } else {
458 order_ids.get_client_id_and_context(ib_id)
459 };
460
461 if let Some((client_id, ctx)) = lookup_result {
462 let order = make_order_from_status(&status, client_id, &ctx, pending_cancels);
463 Some(UnindexedAccountEvent {
464 exchange: ExchangeId::Ibkr,
465 kind: AccountEventKind::OrderSnapshot(Snapshot::new(order)),
466 })
467 } else {
468 debug!(ib_order_id = ib_id, "OrderStatus for unknown order ID");
469 None
470 }
471 }
472 OrderUpdate::ExecutionData(exec) => {
473 // ibapi copies an executions request's answers here too; they
474 // are not fills happening now, and recovery emits its own.
475 if recovery::is_replayed_execution(&exec) {
476 trace!(
477 exec_id = %exec.execution.execution_id,
478 request_id = exec.request_id,
479 "ExecutionData answering an executions request, skipping"
480 );
481 continue;
482 }
483 let Some((instrument, client_id)) = resolve_execution(&exec, contracts, order_ids)
484 else {
485 continue;
486 };
487
488 exec_buffer.add_execution(exec, instrument, client_id);
489 None
490 }
491 OrderUpdate::CommissionReport(report) => {
492 if let Some(trade) = exec_buffer.complete_with_commission(&report)
493 && !sink.send_trade(trade)
494 {
495 return;
496 }
497 None
498 }
499 _ => None,
500 };
501
502 if let Some(e) = event
503 && !sink.send(e)
504 {
505 // The consumer dropped rx, or recovery ended the stream: either
506 // way nothing more may be sent.
507 return;
508 }
509 }
510
511 // The subscription iterator ended without an error (e.g. clean
512 // disconnect/unsubscribe, or ibapi giving up reconnecting). Still a
513 // terminal stream death — surface it in-band so the consumer doesn't have
514 // to infer it from channel EOF.
515 sink.terminate(StreamTerminationReason::Error(
516 "IBKR order-update stream ended".to_string(),
517 ));
518}
519
520/// The message a worker thread panicked with.
521fn panic_message(panic_info: &(dyn std::any::Any + Send)) -> String {
522 panic_info
523 .downcast_ref::<&str>()
524 .map(|s| s.to_string())
525 .or_else(|| panic_info.downcast_ref::<String>().cloned())
526 .unwrap_or_else(|| "unknown panic".to_string())
527}
528
529/// Classify the error from a send (`place_order` / `cancel_order`) that failed
530/// before TWS acknowledged anything, carrying `message` as the reason.
531///
532/// A dropped or reconnecting transport (`ConnectionReset`, `Io`) is a transient
533/// [`OrderError::Connectivity`]: the request did not reach TWS and may be retried
534/// once the connection is back. `Shutdown` and `ConnectionFailed` mean ibapi has
535/// given up reconnecting, so they are non-transient, like any other refusal
536/// ([`ApiError::OrderRejected`]).
537///
538/// # Known limitation
539///
540/// ibapi refuses a send with `ConnectionReset` whenever the session is not
541/// connected, including after it has shut down for good, so a send refused on a
542/// dead client still reads as transient here. The client's streams ending is
543/// the signal that it will not come back.
544fn send_error<AssetKey, InstrumentKey>(
545 e: &ibapi::Error,
546 message: String,
547) -> OrderError<AssetKey, InstrumentKey> {
548 match e {
549 ibapi::Error::ConnectionReset | ibapi::Error::Io(_) => {
550 OrderError::Connectivity(ConnectivityError::Socket(message))
551 }
552 _ => OrderError::Rejected(ApiError::OrderRejected(message)),
553 }
554}
555
556/// Drive an order-placement subscription to its initial [`PlacementOutcome`].
557///
558/// Shared by the single-order and bracket-leg placement paths. Consumes events
559/// from a `place_order` subscription (typically `sub.timeout_iter_data(..)`)
560/// until a decisive outcome is reached.
561///
562/// # Why notices are not blanket rejections
563///
564/// ibapi delivers a TWS error frame that it does not classify as a warning as
565/// `Err(Error::Notice)`, and then closes the subscription. IB emits
566/// informational order messages (e.g. code 399, order held until RTH) the same
567/// way, so treating every `Err` as a rejection would falsely reject orders that
568/// are actually live. We instead key off the notice code: known informational
569/// codes yield [`PlacementOutcome::HeldPending`]; transport loss yields
570/// [`PlacementOutcome::NoStatus`], because the order may already be live; all
571/// other notices and errors yield [`PlacementOutcome::Rejected`]. The `OrderStatus`
572/// event — when one is delivered before the closing notice — remains
573/// authoritative.
574///
575/// ibapi 4.0 widened that warning classification beyond the `2100..=2169` range
576/// to include code 0 and the `Warning:` form of code 399, and now routes a
577/// warning owned by a request-bound subscription as a non-terminal notice that
578/// `timeout_iter_data` drops rather than an `Err` that ends the stream. See
579/// [`INFORMATIONAL_ORDER_CODES`] for what that changes here.
580fn await_order_placement<I>(events: I) -> PlacementOutcome
581where
582 I: IntoIterator<Item = Result<ibapi::orders::PlaceOrder, ibapi::Error>>,
583{
584 use ibapi::orders::PlaceOrder;
585
586 for event in events {
587 let event = match event {
588 Ok(event) => event,
589 // Informational order message (e.g. 399 held-until-RTH): the order
590 // is accepted; ibapi has closed the subscription, so stop here and
591 // let the update stream carry the real status.
592 Err(ibapi::Error::Notice(n)) if INFORMATIONAL_ORDER_CODES.contains(&n.code) => {
593 return PlacementOutcome::HeldPending(format!("[{}] {}", n.code, n.message));
594 }
595 // The socket dropped before TWS reported a status. ibapi 4.2 fails
596 // in-flight requests at the drop rather than after the reconnect, so
597 // this can arrive after TWS received the order: its fate is unknown,
598 // not rejected.
599 Err(e) if is_transport_loss(&e) => {
600 warn!(error = %e, "transport lost while awaiting order placement; status unknown");
601 return PlacementOutcome::NoStatus;
602 }
603 // Genuine notice (e.g. 201 reject) or another ibapi error.
604 Err(e) => return PlacementOutcome::Rejected(e.to_string()),
605 };
606
607 if let PlaceOrder::OrderStatus(status) = event {
608 use ibapi::orders::OrderStatusKind;
609 match status.status {
610 OrderStatusKind::Submitted
611 | OrderStatusKind::PreSubmitted
612 | OrderStatusKind::PendingSubmit
613 // A marketable order can fill before any working status is
614 // delivered on the placement subscription — ibapi sends
615 // `OrderStatus(Filled)` directly in that case. The order is
616 // live, not rejected; its authoritative terminal/fill state
617 // arrives via the account stream. Report it accepted so the
618 // order-id mapping is retained and the stream's
619 // executions/commissions are captured (treating it as a
620 // rejection would remove the mapping and drop those events).
621 | OrderStatusKind::Filled => {
622 return PlacementOutcome::Accepted {
623 filled: status.filled,
624 };
625 }
626 OrderStatusKind::Cancelled | OrderStatusKind::Inactive => {
627 return PlacementOutcome::Rejected(status.status.to_string());
628 }
629 // Transitional cancel states: a cancellation was already requested
630 // (e.g. a fast cancel racing the placement ack), but the order was
631 // submitted — we have a placement subscription — and is still live.
632 // `ApiCancelled` always precedes a confirmed `Cancelled`; both it
633 // and `PendingCancel` resolve to a terminal status on the account
634 // stream, which is authoritative. Report accepted so the order-id
635 // mapping is retained and the stream's terminal/execution/commission
636 // events are captured (treating these as a rejection would remove
637 // the mapping and drop those events).
638 OrderStatusKind::ApiCancelled | OrderStatusKind::PendingCancel => {
639 return PlacementOutcome::Accepted {
640 filled: status.filled,
641 };
642 }
643 // Pre-transmission state: ibapi has not yet sent the order to IB
644 // (e.g. awaiting a security-definition lookup). This is not a
645 // placement decision — keep waiting for a definitive status. The
646 // `PLACEMENT_STATUS_TIMEOUT` backstop yields `NoStatus` if none
647 // arrives.
648 OrderStatusKind::ApiPending => {}
649 // TWS reported a status ibapi does not model. The order exists
650 // — TWS is reporting on it — but neither we nor ibapi can
651 // classify it: `is_active()` and `is_terminal()` are both false
652 // for this variant. Report it live-but-indeterminate, which
653 // retains the order-id mapping so the account stream can
654 // resolve the real state.
655 //
656 // Deliberately NOT falling through to the `PLACEMENT_STATUS_TIMEOUT`
657 // backstop the way `ApiPending` does: that yields `NoStatus`,
658 // and on the bracket path `NoStatus` cancels all three legs —
659 // a destructive response to a status we merely failed to
660 // recognise.
661 OrderStatusKind::Unknown(ref raw) => {
662 warn!(
663 ib_order_id = status.order_id,
664 status = raw.as_str(),
665 "unmodelled IBKR order status during placement; \
666 treating as live, account stream is authoritative"
667 );
668 return PlacementOutcome::HeldPending(format!(
669 "unmodelled order status: {raw}"
670 ));
671 }
672 }
673 }
674 // Non-status events (OpenOrder, ExecutionData, CommissionReport): keep
675 // waiting for the OrderStatus.
676 }
677
678 PlacementOutcome::NoStatus
679}
680
681/// Interactive Brokers execution client.
682///
683/// # Clone Behavior
684///
685/// Cloning creates a shallow copy with shared `Arc` references to the underlying
686/// IB connection, contract registry, order ID map, and execution buffer. All clones
687/// share the same TWS/Gateway connection and state.
688#[derive(Clone)]
689pub struct IbkrClient {
690 config: Arc<IbkrConfig>,
691 client: Arc<Client>,
692 contracts: ContractRegistry,
693 order_ids: OrderIdMap,
694 pending_cancels: PendingCancels,
695 execution_buffer: ExecutionBuffer,
696 next_order_id: Arc<Mutex<i32>>,
697}
698
699impl std::fmt::Debug for IbkrClient {
700 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
701 f.debug_struct("IbkrClient")
702 .field("config", &self.config)
703 .field("contracts_count", &self.contracts.len())
704 .field("pending_orders", &self.order_ids.len())
705 .finish()
706 }
707}
708
709impl IbkrClient {
710 /// Connect to TWS/Gateway and initialize the client (sync, blocking).
711 ///
712 /// # Errors
713 ///
714 /// Returns error if connection fails or contract resolution fails.
715 pub fn connect_sync(config: IbkrConfig) -> Result<Self, UnindexedClientError> {
716 let url = format!("{}:{}", config.host, config.port);
717 info!(url = %url, client_id = config.client_id, "Connecting to IB");
718
719 let client = Client::connect(&url, config.client_id).map_err(|e| {
720 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
721 })?;
722
723 let next_id = client.next_order_id();
724
725 let contracts = ContractRegistry::new();
726
727 for contract_config in &config.contracts {
728 let contract = match contract_config.to_contract() {
729 Ok(contract) => contract,
730 Err(e) => {
731 warn!(name = %contract_config.name, error = %e, "Invalid contract config, skipping");
732 continue;
733 }
734 };
735 let name = InstrumentNameExchange::from(contract_config.name.as_str());
736
737 match client.contract_details(&contract) {
738 Ok(details) => {
739 if let Some(detail) = details.into_iter().next() {
740 contracts.register(name.clone(), detail.contract.clone());
741 debug!(name = %name, con_id = detail.contract.contract_id, "Registered contract");
742 }
743 }
744 Err(e) => {
745 warn!(name = %name, error = %e, "Failed to resolve contract");
746 }
747 }
748 }
749
750 info!(
751 contracts = contracts.len(),
752 next_order_id = next_id,
753 "Connected to IB"
754 );
755
756 Ok(Self {
757 config: Arc::new(config),
758 client: Arc::new(client),
759 contracts,
760 order_ids: OrderIdMap::new(),
761 pending_cancels: PendingCancels::new(),
762 execution_buffer: ExecutionBuffer::new(),
763 next_order_id: Arc::new(Mutex::new(next_id)),
764 })
765 }
766
767 /// Get the next order ID and increment the counter.
768 fn allocate_order_id(&self) -> i32 {
769 self.allocate_order_id_range(1)
770 }
771
772 /// Allocate a contiguous range of order IDs atomically.
773 ///
774 /// Returns the first ID in the range. Caller uses `base`, `base+1`, ..., `base+(count-1)`.
775 ///
776 /// This is essential for bracket orders which require consecutive IDs (parent=N,
777 /// take_profit=N+1, stop_loss=N+2). A single lock acquisition ensures no other
778 /// concurrent `open_order` call can grab an ID in the middle of the range.
779 ///
780 /// # Panics
781 ///
782 /// Panics if `count` exceeds `i32::MAX`, or if the resulting range would overflow `i32::MAX`.
783 #[allow(clippy::expect_used)] // Panic is correct: i32::MAX orders means system is broken
784 fn allocate_order_id_range(&self, count: u32) -> i32 {
785 let count_i32: i32 = count.try_into().expect("count exceeds i32::MAX");
786 let mut id = self.next_order_id.lock();
787 let base = *id;
788 *id = id
789 .checked_add(count_i32)
790 .expect("order ID overflow: i32::MAX exceeded");
791 base
792 }
793
794 /// Register a contract for an instrument.
795 pub fn register_contract(
796 &self,
797 name: InstrumentNameExchange,
798 contract: ibapi::contracts::Contract,
799 ) {
800 self.contracts.register(name, contract);
801 }
802
803 /// Get the contract registry.
804 pub fn contract_registry(&self) -> &ContractRegistry {
805 &self.contracts
806 }
807
808 /// Get the number of pending executions awaiting commission reports.
809 ///
810 /// Useful for monitoring whether commission reports are being received.
811 /// A growing count may indicate IB connection issues or delayed reports.
812 pub fn pending_execution_count(&self) -> usize {
813 self.execution_buffer.pending_count()
814 }
815
816 /// Clear stale executions older than the given duration.
817 ///
818 /// Returns the number of cleared entries.
819 ///
820 /// Call this periodically to prevent unbounded memory growth if commission
821 /// reports are delayed or lost. A reasonable interval is 5-10 minutes with
822 /// a max_age of 1 hour.
823 pub fn clear_stale_executions(&self, max_age: std::time::Duration) -> usize {
824 self.execution_buffer.clear_stale(max_age)
825 }
826
827 /// Clear order ID mappings older than the given duration.
828 ///
829 /// Returns the number of cleared entries.
830 ///
831 /// # Why This Is Needed
832 ///
833 /// IB does not guarantee event ordering between `OrderStatus("Filled")` and
834 /// `ExecutionData`/`CommissionReport`. For fast-filling orders (especially
835 /// market orders), execution data may arrive AFTER the filled status — or
836 /// the filled status may not arrive at all. Removing mappings on terminal
837 /// status would cause data loss.
838 ///
839 /// Call this periodically alongside `clear_stale_executions()`. A reasonable
840 /// interval is 5-10 minutes with a max_age of 1 hour.
841 pub fn clear_stale_order_ids(&self, max_age: std::time::Duration) -> usize {
842 self.order_ids.clear_stale(max_age)
843 }
844
845 /// Clear pending cancel entries older than the given duration.
846 ///
847 /// Returns the number of cleared entries.
848 ///
849 /// Pending cancels are tracked to differentiate user-initiated cancellation
850 /// from time-based expiration. If a cancel request is submitted but the order
851 /// never receives terminal status (e.g., network disconnect), the entry would
852 /// remain indefinitely. Call this alongside other stale cleanup methods.
853 pub fn clear_stale_pending_cancels(&self, max_age: std::time::Duration) -> usize {
854 self.pending_cancels.clear_stale(max_age)
855 }
856
857 /// Disconnect from IB Gateway.
858 ///
859 /// Signals the ibapi client to shut down and releases the client ID for reuse.
860 ///
861 /// [`IbkrClient`] implements [`Clone`] and has no `Drop` impl: the underlying
862 /// connection is released automatically when the last `Arc<Client>` reference
863 /// is dropped. Calling `disconnect()` explicitly terminates the connection
864 /// **immediately for all clones** sharing this client.
865 ///
866 /// Any active `account_stream()` ends with `StreamTerminated` within about a
867 /// second.
868 ///
869 /// This is idempotent — calling it multiple times is safe.
870 pub fn disconnect(&self) {
871 debug!("Disconnecting IbkrClient");
872 self.client.disconnect();
873 }
874
875 /// Place a bracket order (entry + take-profit + stop-loss) with OCA linking.
876 ///
877 /// A bracket order consists of three linked orders:
878 /// 1. **Entry**: Limit order to enter the position
879 /// 2. **Take Profit**: Limit order to exit at profit target (OCA-linked to SL)
880 /// 3. **Stop Loss**: Stop order to exit at loss limit (OCA-linked to TP)
881 ///
882 /// # OCA (One-Cancels-All) Behavior
883 ///
884 /// When either exit order fills, IB automatically cancels the other. This
885 /// prevents the dangerous scenario where take-profit fills but stop-loss
886 /// remains open, potentially opening an unintended opposing position.
887 ///
888 /// # Failure Semantics
889 ///
890 /// If any leg is rejected by IB (e.g., insufficient margin, invalid price),
891 /// or cannot be sent, this method cancels every leg already sent and
892 /// returns the legs as `Inactive`, except for legs of unknown fate (below).
893 ///
894 /// The same all-legs cancellation applies if any leg fails to report a
895 /// status within the placement timeout, or the transport drops while
896 /// waiting (an unknown/no-status outcome): leaving part of a bracket
897 /// working while the rest is in an unknown state is unsafe, so every leg is
898 /// cancelled and an error is returned.
899 ///
900 /// # Legs of Unknown Fate
901 ///
902 /// Cancelling does not settle a leg whose fate is unknown: a leg that
903 /// reported no status, or whose rollback cancel could not be sent (ibapi
904 /// refuses sends while its transport is down), may still be live or held
905 /// at TWS. Such a leg comes back `Open` with zero fill and keeps its
906 /// order-id mapping, as a no-status single order does, so the account
907 /// stream can report how it ends. The other legs come back `Inactive` with
908 /// the error. A leg IB rejected is settled even if its cancel was refused.
909 /// So a failed bracket can return a mix of `Open` and `Inactive` legs, and
910 /// every leg can be `Open` when none reported a status: an all-`Open`
911 /// result is then indistinguishable from success, and the failure shows
912 /// only in the log and on the account stream. Treat an `Open` leg as
913 /// working until the account stream or
914 /// [`ExecutionClient::fetch_open_orders`] says otherwise.
915 ///
916 /// # Cancellation Safety
917 ///
918 /// This future registers order ID mappings before submitting to IB. If
919 /// cancelled mid-flight, orders may still be submitted and mappings will
920 /// leak. Avoid cancelling this future; use IB's native order timeout via
921 /// `TimeInForce` if timeout behavior is needed.
922 ///
923 /// # Example
924 ///
925 /// ```ignore
926 /// let request = BracketOrderRequest {
927 /// instrument: "AAPL".into(),
928 /// strategy: StrategyId::new("my-strategy"),
929 /// parent_cid: ClientOrderId::new("bracket-001"),
930 /// side: Side::Buy,
931 /// quantity: dec!(100),
932 /// entry_price: dec!(150.00),
933 /// take_profit_price: dec!(160.00), // +$10 profit target
934 /// stop_loss_price: dec!(145.00), // -$5 stop loss
935 /// time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
936 /// };
937 /// let result = client.open_bracket_order(request).await;
938 /// ```
939 pub async fn open_bracket_order(&self, request: BracketOrderRequest) -> BracketOrderResult {
940 let instrument = request.instrument.clone();
941
942 // Look up contract
943 let contract = match self.contracts.get_contract(&instrument) {
944 Some(c) => c,
945 None => {
946 return make_all_inactive_bracket(
947 &request,
948 OrderError::Rejected(ApiError::InstrumentInvalid(
949 instrument,
950 "contract not registered".to_string(),
951 )),
952 );
953 }
954 };
955
956 // Convert quantity to f64
957 let quantity: f64 = match request.quantity.try_into() {
958 Ok(q) => q,
959 Err(_) => {
960 return make_all_inactive_bracket(
961 &request,
962 OrderError::Rejected(ApiError::OrderRejected(format!(
963 "quantity {} exceeds f64 range",
964 request.quantity
965 ))),
966 );
967 }
968 };
969
970 // Convert prices to f64
971 let entry_price: f64 = match request.entry_price.try_into() {
972 Ok(p) => p,
973 Err(_) => {
974 return make_all_inactive_bracket(
975 &request,
976 OrderError::Rejected(ApiError::OrderRejected(format!(
977 "entry_price {} exceeds f64 range",
978 request.entry_price
979 ))),
980 );
981 }
982 };
983 let tp_price: f64 = match request.take_profit_price.try_into() {
984 Ok(p) => p,
985 Err(_) => {
986 return make_all_inactive_bracket(
987 &request,
988 OrderError::Rejected(ApiError::OrderRejected(format!(
989 "take_profit_price {} exceeds f64 range",
990 request.take_profit_price
991 ))),
992 );
993 }
994 };
995 let sl_price: f64 = match request.stop_loss_price.try_into() {
996 Ok(p) => p,
997 Err(_) => {
998 return make_all_inactive_bracket(
999 &request,
1000 OrderError::Rejected(ApiError::OrderRejected(format!(
1001 "stop_loss_price {} exceeds f64 range",
1002 request.stop_loss_price
1003 ))),
1004 );
1005 }
1006 };
1007
1008 // Validate time_in_force and convert to IB wire format
1009 let ib_tif = match time_in_force_to_ib(&request.time_in_force) {
1010 Ok(tif) => tif,
1011 Err(e) => {
1012 return make_all_inactive_bracket(
1013 &request,
1014 OrderError::Rejected(ApiError::OrderRejected(format!(
1015 "TIF not supported by IB: {e}"
1016 ))),
1017 );
1018 }
1019 };
1020
1021 // Allocate 3 consecutive order IDs atomically
1022 let parent_ib_id = self.allocate_order_id_range(3);
1023 let tp_ib_id = parent_ib_id + 1;
1024 let sl_ib_id = parent_ib_id + 2;
1025
1026 // Build bracket orders with OCA linking
1027 let action = side_to_action(request.side);
1028 let ib_orders = build_ib_bracket_with_oca(
1029 parent_ib_id,
1030 action,
1031 quantity,
1032 entry_price,
1033 tp_price,
1034 sl_price,
1035 ib_tif,
1036 );
1037
1038 // Generate client order IDs for children
1039 let parent_cid = request.parent_cid.clone();
1040 let (tp_cid, sl_cid) = derive_child_cids(&parent_cid);
1041
1042 // Determine opposite side for exit orders
1043 let exit_side = match request.side {
1044 Side::Buy => Side::Sell,
1045 Side::Sell => Side::Buy,
1046 };
1047
1048 // Register order contexts for all three legs
1049 let parent_ctx = OrderContext {
1050 instrument: instrument.clone(),
1051 side: request.side,
1052 price: Some(request.entry_price),
1053 quantity: request.quantity,
1054 kind: OrderKind::Limit,
1055 time_in_force: request.time_in_force,
1056 };
1057 let tp_ctx = OrderContext {
1058 instrument: instrument.clone(),
1059 side: exit_side,
1060 price: Some(request.take_profit_price),
1061 quantity: request.quantity,
1062 kind: OrderKind::Limit,
1063 time_in_force: request.time_in_force,
1064 };
1065 let sl_ctx = OrderContext {
1066 instrument: instrument.clone(),
1067 side: exit_side,
1068 price: None, // Stop orders don't have a limit price
1069 quantity: request.quantity,
1070 kind: OrderKind::Stop {
1071 trigger_price: request.stop_loss_price,
1072 },
1073 time_in_force: request.time_in_force,
1074 };
1075
1076 self.order_ids
1077 .register(parent_cid.clone(), parent_ib_id, parent_ctx);
1078 self.order_ids.register(tp_cid.clone(), tp_ib_id, tp_ctx);
1079 self.order_ids.register(sl_cid.clone(), sl_ib_id, sl_ctx);
1080
1081 // Place all three orders in spawn_blocking
1082 let client = self.client.clone();
1083 let result = tokio::task::spawn_blocking(move || {
1084 // Roll back legs already sent. Returns the ids whose cancel could not
1085 // be sent, and a suffix for the error message naming them. A refused
1086 // cancel must not vanish: ibapi refuses sends while the transport is
1087 // down, and the leg it names may then be live at TWS.
1088 let rollback = |ids: &[i32]| -> (Vec<i32>, String) {
1089 let failed: Vec<(i32, ibapi::Error)> = ids
1090 .iter()
1091 .filter_map(|&id| {
1092 let e = client.cancel_order(id, "").err()?;
1093 error!(order_id = id, error = %e, "bracket rollback cancel failed; leg may be live");
1094 Some((id, e))
1095 })
1096 .collect();
1097 if failed.is_empty() {
1098 return (Vec::new(), String::new());
1099 }
1100 let named: Vec<String> = failed.iter().map(|(id, e)| format!("{id} ({e})")).collect();
1101 let suffix = format!(
1102 "; rollback cancel failed for order ids {}, which may be live",
1103 named.join(", ")
1104 );
1105 (failed.into_iter().map(|(id, _)| id).collect(), suffix)
1106 };
1107
1108 // A later leg's send failed after the legs in `sent` were written:
1109 // roll them back. A leg whose cancel was refused may be held at TWS,
1110 // so it is unresolved. The failing leg itself is treated as never
1111 // having reached TWS, as in `send_error`.
1112 let leg_send_failure = |e: &ibapi::Error, message: String, sent: &[i32]| {
1113 let (cancel_failed, rollback) = rollback(sent);
1114 BracketFailure {
1115 error: leg_send_error(e, message, &rollback),
1116 unresolved: cancel_failed,
1117 }
1118 };
1119
1120 // Place parent (transmit=false, held until SL is sent)
1121 let parent_sub = match client.place_order(parent_ib_id, &contract, &ib_orders[0]) {
1122 Ok(s) => s,
1123 Err(e) => {
1124 return Err(BracketFailure {
1125 error: send_error(&e, format!("parent order failed: {e}")),
1126 unresolved: Vec::new(),
1127 });
1128 }
1129 };
1130
1131 // Place take-profit (transmit=false)
1132 let tp_sub = match client.place_order(tp_ib_id, &contract, &ib_orders[1]) {
1133 Ok(s) => s,
1134 Err(e) => {
1135 return Err(leg_send_failure(
1136 &e,
1137 format!("take_profit order failed: {e}"),
1138 &[parent_ib_id],
1139 ));
1140 }
1141 };
1142
1143 // Place stop-loss (transmit=true, triggers all)
1144 let sl_sub = match client.place_order(sl_ib_id, &contract, &ib_orders[2]) {
1145 Ok(s) => s,
1146 Err(e) => {
1147 return Err(leg_send_failure(
1148 &e,
1149 format!("stop_loss order failed: {e}"),
1150 &[parent_ib_id, tp_ib_id],
1151 ));
1152 }
1153 };
1154
1155 // Await the first status from each subscription. A leg held until
1156 // RTH (informational notice, e.g. 399) is treated as live with an
1157 // unknown fill (0.0) — the order is working, not rejected. `None`
1158 // means the subscription closed/timed out without a terminal status.
1159 let leg_status = |outcome: PlacementOutcome, leg: &str| match outcome {
1160 PlacementOutcome::Accepted { filled } => Some(Ok(filled)),
1161 PlacementOutcome::HeldPending(notice) => {
1162 warn!(leg, %notice, "bracket leg held/pending; tracking via stream");
1163 Some(Ok(0.0))
1164 }
1165 PlacementOutcome::Rejected(reason) => Some(Err(reason)),
1166 PlacementOutcome::NoStatus => None,
1167 };
1168
1169 let parent_status = leg_status(
1170 await_order_placement(parent_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
1171 "parent",
1172 );
1173 let tp_status = leg_status(
1174 await_order_placement(tp_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
1175 "take_profit",
1176 );
1177 let sl_status = leg_status(
1178 await_order_placement(sl_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
1179 "stop_loss",
1180 );
1181
1182 // Verify all three legs reported a successful status. A `None` here
1183 // means the subscription closed without producing a recognised event
1184 // — surface that as an error rather than silently treating it as
1185 // success. Any error or missing status cancels all legs.
1186 match (parent_status, tp_status, sl_status) {
1187 (Some(Ok(parent)), Some(Ok(tp)), Some(Ok(sl))) => Ok((parent, tp, sl)),
1188 (parent, tp, sl) => {
1189 let (cancel_failed, rollback) = rollback(&[parent_ib_id, tp_ib_id, sl_ib_id]);
1190 let unresolved = unresolved_legs(
1191 [(parent_ib_id, &parent), (tp_ib_id, &tp), (sl_ib_id, &sl)],
1192 &cancel_failed,
1193 );
1194 let show = |status: Option<Result<f64, String>>| match status {
1195 Some(status) => format!("{status:?}"),
1196 None => "no status (timed out or transport lost)".to_string(),
1197 };
1198 Err(BracketFailure {
1199 error: OrderError::Rejected(ApiError::OrderRejected(format!(
1200 "bracket order failed: parent={}, tp={}, sl={}{rollback}",
1201 show(parent),
1202 show(tp),
1203 show(sl)
1204 ))),
1205 unresolved,
1206 })
1207 }
1208 }
1209 })
1210 .await;
1211
1212 match result {
1213 Ok(Ok((parent_filled, tp_filled, sl_filled))) => {
1214 let now = Utc::now();
1215 let parent_filled_dec = parse_decimal_or_warn(parent_filled, "parent.filled");
1216 let tp_filled_dec = parse_decimal_or_warn(tp_filled, "tp.filled");
1217 let sl_filled_dec = parse_decimal_or_warn(sl_filled, "sl.filled");
1218
1219 BracketOrderResult {
1220 parent: Order {
1221 key: OrderKey {
1222 exchange: ExchangeId::Ibkr,
1223 instrument: instrument.clone(),
1224 strategy: request.strategy.clone(),
1225 cid: parent_cid,
1226 },
1227 side: request.side,
1228 price: Some(request.entry_price),
1229 quantity: request.quantity,
1230 kind: OrderKind::Limit,
1231 time_in_force: request.time_in_force,
1232 state: OrderState::active(Open::new(
1233 VenueOrderId::Assigned(OrderId::new(format_smolstr!(
1234 "{}",
1235 parent_ib_id
1236 ))),
1237 now,
1238 parent_filled_dec,
1239 )),
1240 },
1241 take_profit: Order {
1242 key: OrderKey {
1243 exchange: ExchangeId::Ibkr,
1244 instrument: instrument.clone(),
1245 strategy: request.strategy.clone(),
1246 cid: tp_cid,
1247 },
1248 side: exit_side,
1249 price: Some(request.take_profit_price),
1250 quantity: request.quantity,
1251 kind: OrderKind::Limit,
1252 time_in_force: request.time_in_force,
1253 state: OrderState::active(Open::new(
1254 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", tp_ib_id))),
1255 now,
1256 tp_filled_dec,
1257 )),
1258 },
1259 stop_loss: Order {
1260 key: OrderKey {
1261 exchange: ExchangeId::Ibkr,
1262 instrument: instrument.clone(),
1263 strategy: request.strategy.clone(),
1264 cid: sl_cid,
1265 },
1266 side: exit_side,
1267 price: None, // Stop orders don't have a limit price
1268 quantity: request.quantity,
1269 kind: OrderKind::Stop {
1270 trigger_price: request.stop_loss_price,
1271 },
1272 time_in_force: request.time_in_force,
1273 state: OrderState::active(Open::new(
1274 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", sl_ib_id))),
1275 now,
1276 sl_filled_dec,
1277 )),
1278 },
1279 }
1280 }
1281 Ok(Err(failure)) => failed_bracket(
1282 &request,
1283 [parent_ib_id, tp_ib_id, sl_ib_id],
1284 failure,
1285 &self.order_ids,
1286 ),
1287 Err(join_err) => {
1288 // The placement task panicked, and which legs it sent was lost
1289 // with it. Unlike `failed_bracket`, all three are released: a
1290 // leg it did send is then untracked, but keeping unsent legs
1291 // Open would report orders that do not exist.
1292 self.order_ids.remove_by_ib_id(parent_ib_id);
1293 self.order_ids.remove_by_ib_id(tp_ib_id);
1294 self.order_ids.remove_by_ib_id(sl_ib_id);
1295
1296 make_all_inactive_bracket(
1297 &request,
1298 OrderError::Rejected(ApiError::OrderRejected(format!(
1299 "task join error: {join_err}"
1300 ))),
1301 )
1302 }
1303 }
1304 }
1305}
1306
1307/// Derive bracket child CIDs from the parent CID.
1308///
1309/// Convention: `{parent}_tp` for take-profit, `{parent}_sl` for stop-loss.
1310/// All bracket call sites must use this helper to keep the naming consistent.
1311fn derive_child_cids(parent_cid: &ClientOrderId) -> (ClientOrderId, ClientOrderId) {
1312 (
1313 ClientOrderId::new(format_smolstr!("{}_tp", parent_cid.0)),
1314 ClientOrderId::new(format_smolstr!("{}_sl", parent_cid.0)),
1315 )
1316}
1317
1318/// A failed bracket placement: the error each settled leg carries, and the legs
1319/// whose fate at TWS is unknown.
1320struct BracketFailure {
1321 error: OrderError<AssetNameExchange, InstrumentNameExchange>,
1322 /// IB order ids of legs that may be live at TWS: no placement status
1323 /// arrived for them, or their rollback cancel could not be sent.
1324 unresolved: Vec<i32>,
1325}
1326
1327/// The legs of a bracket that failed after all three were sent whose fate at
1328/// TWS is unknown: those without a placement status (`None`), and those that
1329/// were accepted but whose rollback cancel could not be sent.
1330///
1331/// A rejected leg (`Some(Err)`) is settled whether or not its cancel went out:
1332/// its terminal status was consumed by the placement subscription and will not
1333/// be replayed, so keeping it `Open` would leave a phantom order. An accepted
1334/// leg whose cancel went out is settled as being cancelled.
1335fn unresolved_legs<T, E>(
1336 legs: [(i32, &Option<Result<T, E>>); 3],
1337 cancel_failed: &[i32],
1338) -> Vec<i32> {
1339 legs.into_iter()
1340 .filter(|(id, status)| match status {
1341 None => true,
1342 Some(Ok(_)) => cancel_failed.contains(id),
1343 Some(Err(_)) => false,
1344 })
1345 .map(|(id, _)| id)
1346 .collect()
1347}
1348
1349/// Classify a later bracket leg's send failure, after the legs sent before it
1350/// were rolled back. `rollback` is the suffix naming the cancels that could
1351/// not be sent, empty when every cancel went out.
1352///
1353/// Retrying is only safe when every rollback cancel went out, so only then does
1354/// the failure keep [`send_error`]'s classification. Otherwise a leg may still
1355/// be held at TWS, and the failure is a non-transient rejection naming it.
1356fn leg_send_error(
1357 e: &ibapi::Error,
1358 message: String,
1359 rollback: &str,
1360) -> OrderError<AssetNameExchange, InstrumentNameExchange> {
1361 if rollback.is_empty() {
1362 send_error(e, message)
1363 } else {
1364 OrderError::Rejected(ApiError::OrderRejected(format!("{message}{rollback}")))
1365 }
1366}
1367
1368/// Build the result of a failed bracket placement, releasing the order-id
1369/// mappings of its settled legs.
1370///
1371/// A leg in `failure.unresolved` comes back `Open` with zero fill and keeps its
1372/// mapping, as single-order placement does for an unknown outcome, so the
1373/// account stream can still resolve it. Every other leg was never sent, was
1374/// rejected, or had its cancel sent: it comes back `Inactive` with
1375/// `failure.error`, and its mapping is removed.
1376fn failed_bracket(
1377 request: &BracketOrderRequest,
1378 [parent_ib_id, tp_ib_id, sl_ib_id]: [i32; 3],
1379 failure: BracketFailure,
1380 order_ids: &OrderIdMap,
1381) -> BracketOrderResult {
1382 let BracketFailure { error, unresolved } = failure;
1383 if !unresolved.is_empty() {
1384 warn!(
1385 %error,
1386 ?unresolved,
1387 "bracket placement failed with legs of unknown fate; returning them Open, tracking via stream"
1388 );
1389 }
1390
1391 let mut result = make_all_inactive_bracket(request, error);
1392 let now = Utc::now();
1393 for (leg, ib_id) in [
1394 (&mut result.parent, parent_ib_id),
1395 (&mut result.take_profit, tp_ib_id),
1396 (&mut result.stop_loss, sl_ib_id),
1397 ] {
1398 if unresolved.contains(&ib_id) {
1399 leg.state = OrderState::active(Open::new(
1400 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{ib_id}"))),
1401 now,
1402 Decimal::ZERO,
1403 ));
1404 } else {
1405 order_ids.remove_by_ib_id(ib_id);
1406 }
1407 }
1408 result
1409}
1410
1411/// Helper to create a BracketOrderResult with all legs inactive (same error).
1412fn make_all_inactive_bracket(
1413 request: &BracketOrderRequest,
1414 error: OrderError<AssetNameExchange, InstrumentNameExchange>,
1415) -> BracketOrderResult {
1416 let exit_side = match request.side {
1417 Side::Buy => Side::Sell,
1418 Side::Sell => Side::Buy,
1419 };
1420
1421 let parent_cid = request.parent_cid.clone();
1422 let (tp_cid, sl_cid) = derive_child_cids(&parent_cid);
1423
1424 BracketOrderResult {
1425 parent: Order {
1426 key: OrderKey {
1427 exchange: ExchangeId::Ibkr,
1428 instrument: request.instrument.clone(),
1429 strategy: request.strategy.clone(),
1430 cid: parent_cid,
1431 },
1432 side: request.side,
1433 price: Some(request.entry_price),
1434 quantity: request.quantity,
1435 kind: OrderKind::Limit,
1436 time_in_force: request.time_in_force,
1437 state: OrderState::inactive(error.clone()),
1438 },
1439 take_profit: Order {
1440 key: OrderKey {
1441 exchange: ExchangeId::Ibkr,
1442 instrument: request.instrument.clone(),
1443 strategy: request.strategy.clone(),
1444 cid: tp_cid,
1445 },
1446 side: exit_side,
1447 price: Some(request.take_profit_price),
1448 quantity: request.quantity,
1449 kind: OrderKind::Limit,
1450 time_in_force: request.time_in_force,
1451 state: OrderState::inactive(error.clone()),
1452 },
1453 stop_loss: Order {
1454 key: OrderKey {
1455 exchange: ExchangeId::Ibkr,
1456 instrument: request.instrument.clone(),
1457 strategy: request.strategy.clone(),
1458 cid: sl_cid,
1459 },
1460 side: exit_side,
1461 price: None, // Stop orders don't have a limit price
1462 quantity: request.quantity,
1463 kind: OrderKind::Stop {
1464 trigger_price: request.stop_loss_price,
1465 },
1466 time_in_force: request.time_in_force,
1467 state: OrderState::inactive(error),
1468 },
1469 }
1470}
1471
1472impl ExecutionClient for IbkrClient {
1473 const EXCHANGE: ExchangeId = ExchangeId::Ibkr;
1474
1475 // Mirrors the security types `ContractConfig::to_contract` can build: `STK` and `CASH` (forex)
1476 // are both outright holdings and so map to `Spot`, `FUT` to `Future`, `OPT` to `Option`.
1477 //
1478 // `Cfd` is absent deliberately. IBKR does offer CFDs, but this client builds no `SecurityType`
1479 // for them, so a CFD instrument would fail `to_contract` with `UnrecognizedSecurityType` at
1480 // registration rather than trade.
1481 //
1482 // Scope: this declares which `InstrumentKind`s the client can represent AT ALL, and is checked
1483 // against `Instrument::kind`. It is NOT a check that a registered `ContractConfig` describes
1484 // the instrument it is keyed to -- `security_type` is a caller-populated `String` registered
1485 // independently into `ContractRegistry` and never compared against `Instrument::kind`. A
1486 // registry entry naming `STK` for an instrument modelled as `Option` still builds a stock
1487 // contract; this gate does not catch it.
1488 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
1489 InstrumentKindDiscriminant::Spot,
1490 InstrumentKindDiscriminant::Future,
1491 InstrumentKindDiscriminant::Option,
1492 ];
1493
1494 type Config = IbkrConfig;
1495 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1496
1497 /// Create a new IBKR client by connecting to TWS/Gateway.
1498 ///
1499 /// # Blocking
1500 ///
1501 /// This method performs blocking TCP I/O and IB API handshake. If called
1502 /// from an async context, wrap in `tokio::task::spawn_blocking` or call
1503 /// from a dedicated thread.
1504 ///
1505 /// # Panics
1506 ///
1507 /// Panics if connection fails. The `ExecutionClient` trait doesn't allow
1508 /// `new()` to return `Result`. Use `IbkrClient::connect_sync()` directly
1509 /// for fallible construction.
1510 #[track_caller]
1511 fn new(config: Self::Config) -> Self {
1512 #[allow(clippy::expect_used)] // Trait signature doesn't allow Result
1513 Self::connect_sync(config).expect("failed to connect to IB")
1514 }
1515
1516 /// Fetch account snapshot (balances, and which instruments hold a position).
1517 ///
1518 /// # Limitations
1519 ///
1520 /// - The `orders` field in each `InstrumentAccountSnapshot` is always empty.
1521 /// IB's positions endpoint returns position data only, not open orders.
1522 /// Use `fetch_open_orders()` or `account_stream()` for order state.
1523 ///
1524 /// - Position quantity and average cost from IB are not carried in the
1525 /// returned `InstrumentAccountSnapshot`: its `position` is always `None`,
1526 /// so the snapshot only indicates which instruments have positions, not
1527 /// their sizes. IB reports both, and `position` could carry them; this
1528 /// client does not fill it yet (#417).
1529 ///
1530 /// # Known Issue: ibapi Decode Errors
1531 ///
1532 /// You may see log errors like `"error decoding message: error occurred:
1533 /// unexpected message: Error"`. This is an upstream ibapi limitation where
1534 /// IB Error messages (type 4) on subscription channels aren't properly
1535 /// routed — they're logged but don't affect functionality.
1536 ///
1537 /// # Timeout
1538 ///
1539 /// IB's positions request is a live subscription that keeps streaming after
1540 /// the initial set, so the read ends once 5 seconds pass without a position
1541 /// update. **Every call therefore takes at least 5 seconds**, including for
1542 /// an account with no positions. If IB stalls mid-stream, the positions
1543 /// received so far are returned rather than blocking indefinitely.
1544 ///
1545 /// # Known Issue: Errors After a Connection Drop on `ibapi` 4.2.0
1546 ///
1547 /// After each connection drop, the next two calls fail with
1548 /// [`UnindexedClientError::Internal`]. The third succeeds. See the
1549 /// [module's Known Issues](self#known-issues-ibapi-420-shared-queues).
1550 async fn account_snapshot(
1551 &self,
1552 assets: &[AssetNameExchange],
1553 instruments: &[InstrumentNameExchange],
1554 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1555 // H-2 fix: Run balances and positions concurrently (independent IB requests)
1556 let client = self.client.clone();
1557 let contracts = self.contracts.clone();
1558 let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
1559 None
1560 } else {
1561 Some(instruments.iter().cloned().collect())
1562 };
1563
1564 let balances_future = self.fetch_balances(assets);
1565 let positions_future = tokio::task::spawn_blocking(move || {
1566 use ibapi::accounts::PositionUpdate;
1567
1568 // ibapi::Error is unstructured — we cannot distinguish connection failures
1569 // (transient, should retry) from API errors (e.g., invalid request).
1570 // Mapped to Internal (non-transient) conservatively; a caller needing reconnect
1571 // logic should drive it from connection state, not from the error type.
1572 let positions_sub = client
1573 .positions()
1574 .map_err(|e| UnindexedClientError::Internal(format!("positions: {e}")))?;
1575
1576 let mut snapshots = Vec::new();
1577 let mut seen = HashSet::new();
1578
1579 // Read until `POSITION_STREAM_TIMEOUT` passes with no update, not until
1580 // `PositionEnd`: see that constant for why.
1581 for pos_update in positions_sub.timeout_iter_data(POSITION_STREAM_TIMEOUT) {
1582 // Surface subscription errors rather than returning partial positions
1583 // (a truncated snapshot could be misread as positions having closed).
1584 let pos_update = match pos_update {
1585 Ok(p) => p,
1586 Err(e) => {
1587 return Err(UnindexedClientError::Internal(format!(
1588 "positions subscription: {e}"
1589 )));
1590 }
1591 };
1592 let PositionUpdate::Position(pos) = pos_update else {
1593 trace!(?pos_update, "Ignoring non-Position variant");
1594 continue;
1595 };
1596 let Some(instrument) = contracts.get_name_by_con_id(pos.contract.contract_id)
1597 else {
1598 continue;
1599 };
1600 if instruments_filter
1601 .as_ref()
1602 .is_some_and(|f| !f.contains(&instrument))
1603 {
1604 continue;
1605 }
1606 if seen.contains(&instrument) {
1607 debug!(
1608 instrument = %instrument,
1609 "Duplicate position for instrument (multi-account?), skipping"
1610 );
1611 continue;
1612 }
1613 seen.insert(instrument.clone());
1614 snapshots.push(InstrumentAccountSnapshot {
1615 instrument,
1616 orders: Vec::new(),
1617 // Nothing is read, so absence says nothing: see the limitation above and #371.
1618 orders_complete: false,
1619 position: None,
1620 isolated: None,
1621 });
1622 }
1623 Ok::<_, UnindexedClientError>(snapshots)
1624 });
1625
1626 // ibapi routes responses by request_id via thread-safe channels (RwLock<HashMap>),
1627 // so concurrent requests on the same client are safe.
1628 let (balances_result, positions_result) = tokio::join!(balances_future, positions_future);
1629 let balances = balances_result?;
1630 let instrument_snapshots = positions_result
1631 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))??;
1632
1633 Ok(AccountSnapshot {
1634 exchange: ExchangeId::Ibkr,
1635 balances,
1636 instruments: instrument_snapshots,
1637 })
1638 }
1639
1640 /// Stream account events (order updates, fills, commissions).
1641 ///
1642 /// # Thread Lifecycle
1643 ///
1644 /// Spawns two background threads: `ibkr-order-stream` reads the blocking IB
1645 /// subscription, and `ibkr-fill-recovery` watches for gaps in event delivery
1646 /// (see below). The watcher exits within about a second of the returned
1647 /// `BoxStream` being dropped or the stream ending. The reader exits when:
1648 /// - The returned `BoxStream` is dropped (channel closes), or the stream has
1649 /// ended, and the next IB event arrives
1650 /// - The IB subscription ends
1651 ///
1652 /// **Important:** If IB is stalled (no events flowing), the reader thread blocks
1653 /// on the iterator. Dropping the stream signals termination, but the thread won't
1654 /// observe it until the next IB event arrives. Disconnecting does not release
1655 /// it either: `ibapi` does not end the order-update subscription when the
1656 /// client shuts down (wboayue/rust-ibapi#871), so after a shutdown the thread
1657 /// stays blocked until the process exits.
1658 ///
1659 /// # Reconnects and Fill Recovery
1660 ///
1661 /// `ibapi` reconnects its socket to TWS/Gateway by itself, and this stream
1662 /// stays open across that: a transient drop produces neither an error nor
1663 /// `StreamTerminated`. TWS does not resend what it sent while the socket was
1664 /// down, and TWS losing its own link to IB's servers (notice 1100) opens the
1665 /// same kind of gap. Once delivery is restored, this stream asks TWS for the
1666 /// day's executions and emits the ones from the gap as `Trade` events. The
1667 /// gap is taken to start up to a poll interval before the drop was noticed,
1668 /// minus a lookback margin. Trades the stream already delivered are not
1669 /// emitted twice.
1670 ///
1671 /// **Order lifecycle events are not recovered.** An order that was cancelled,
1672 /// expired or rejected during the gap is not reported here. Reconcile with
1673 /// [`ExecutionClient::fetch_open_orders`] after a reconnect, minding its
1674 /// known issue on `ibapi` 4.2.0. The log line
1675 /// `Recovered IBKR fills after a gap in event delivery` marks one.
1676 ///
1677 /// While TWS reports its link to IB's servers lost (1100), nothing marks the
1678 /// gap on this stream until the link is restored and the recovered fills
1679 /// arrive.
1680 ///
1681 /// The stream ends with `StreamTerminated` when:
1682 /// - recovery fails three times for a reason other than the transport
1683 /// dropping again, rather than stay open with a gap;
1684 /// - the client shuts down for good, because `ibapi` gave up reconnecting or
1685 /// [`IbkrClient::disconnect`] was called. `ibapi` never ends the order
1686 /// update subscription itself, so this is detected from its notice stream,
1687 /// which it does close.
1688 ///
1689 /// After either, the reader thread stays blocked on the subscription, and
1690 /// holds `ibapi`'s single order-update slot, until TWS sends another event.
1691 /// Calling `account_stream` again on the same client fails meanwhile. After a
1692 /// shutdown, TWS sends nothing more, so replace the client.
1693 ///
1694 /// Shutdown is detected only while this stream is open. Called on a client
1695 /// that has already shut down, this method still returns a stream, which
1696 /// never ends. After a stream ends with a shutdown, replace the client rather
1697 /// than resubscribe on it.
1698 ///
1699 /// # Duplicate Events
1700 ///
1701 /// Orders submitted via `open_order()` will emit `OrderSnapshot` events on
1702 /// this stream in addition to the response from `open_order()` itself. This
1703 /// is inherent to IB's API — both the per-order subscription and the global
1704 /// order update stream receive the same `OrderStatus` events. Callers should
1705 /// deduplicate if needed.
1706 ///
1707 /// `Trade` events are delivered once each. Executions that answer an
1708 /// executions request, such as [`ExecutionClient::fetch_trades`], are not
1709 /// reported here as fills.
1710 ///
1711 /// # Filter Parameters
1712 ///
1713 /// The `assets` and `instruments` parameters are currently ignored. IB's
1714 /// `order_update_stream()` returns events for all orders on the account.
1715 /// Callers subscribing for a specific instrument will receive events for
1716 /// all instruments.
1717 async fn account_stream(
1718 &self,
1719 _assets: &[AssetNameExchange],
1720 _instruments: &[InstrumentNameExchange],
1721 ) -> Result<Self::AccountStream, UnindexedClientError> {
1722 let client = self.client.clone();
1723
1724 // M-8 fix: Wrap blocking subscription call in spawn_blocking
1725 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
1726 let (notices, order_sub) = tokio::task::spawn_blocking(move || {
1727 // Notices first, so a reconnect that lands while the order stream is being set up is
1728 // not missed.
1729 let notices = client.notice_stream()?;
1730 client.order_update_stream().map(|sub| (notices, sub))
1731 })
1732 .await
1733 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
1734 .map_err(|e| UnindexedClientError::Internal(format!("order updates: {e}")))?;
1735
1736 let contracts_clone = self.contracts.clone();
1737 let order_ids_clone = self.order_ids.clone();
1738 let pending_cancels_clone = self.pending_cancels.clone();
1739 let exec_buffer_clone = self.execution_buffer.clone();
1740
1741 let (tx, rx) = mpsc::unbounded_channel();
1742 let sink = recovery::EventSink::new(tx, new_dedup_cache());
1743
1744 // Spawned before the reader: if the reader fails to spawn, `rx` drops with the error and
1745 // the watcher sees its consumer gone.
1746 let watcher = recovery::RecoveryWatcher {
1747 client: self.client.clone(),
1748 notices,
1749 contracts: self.contracts.clone(),
1750 order_ids: self.order_ids.clone(),
1751 pending: self.execution_buffer.clone(),
1752 sink: sink.clone(),
1753 };
1754 let watcher_sink = sink.clone();
1755 std::thread::Builder::new()
1756 .name("ibkr-fill-recovery".to_string())
1757 .spawn(move || {
1758 // Same panic policy as the reader below: a panic ends recovery, and a stream that
1759 // can no longer recover must say so rather than stay open.
1760 if let Err(panic_info) = catch_unwind(AssertUnwindSafe(|| watcher.run())) {
1761 let msg = panic_message(panic_info.as_ref());
1762 error!("Fill recovery worker panicked: {msg}");
1763 watcher_sink.terminate(StreamTerminationReason::Error(format!(
1764 "IBKR fill-recovery worker panicked: {msg}"
1765 )));
1766 }
1767 })
1768 .map_err(|e| UnindexedClientError::TaskFailed(format!("thread spawn: {e}")))?;
1769
1770 std::thread::Builder::new()
1771 .name("ibkr-order-stream".to_string())
1772 .spawn(move || {
1773 // Panic safety: parking_lot mutexes do not poison on panic, so shared state
1774 // (ContractRegistry, OrderIdMap, etc.) remains usable. catch_unwind unwinds only
1775 // the inner closure — `sink` lives in this outer thread closure and is still open
1776 // afterward, so the panic handler below emits a terminal StreamTerminated
1777 // (a panic is a terminal stream death like any other).
1778 let result = catch_unwind(AssertUnwindSafe(|| {
1779 forward_order_updates(
1780 order_sub.iter_data(),
1781 &sink,
1782 &contracts_clone,
1783 &order_ids_clone,
1784 &pending_cancels_clone,
1785 &exec_buffer_clone,
1786 );
1787 }));
1788
1789 if let Err(panic_info) = result {
1790 let msg = panic_message(panic_info.as_ref());
1791 error!("Order stream worker panicked: {msg}");
1792 // A panic is a terminal stream death — surface it in-band so the consumer
1793 // gets a programmatic signal rather than inferring EOF.
1794 // Best-effort: a no-op if the consumer already dropped rx.
1795 sink.terminate(StreamTerminationReason::Error(format!(
1796 "IBKR order-update worker panicked: {msg}"
1797 )));
1798 }
1799 })
1800 .map_err(|e| UnindexedClientError::TaskFailed(format!("thread spawn: {e}")))?;
1801
1802 Ok(Box::pin(
1803 tokio_stream::wrappers::UnboundedReceiverStream::new(rx),
1804 ))
1805 }
1806
1807 /// Cancel an order.
1808 ///
1809 /// # Async Cancel Semantics
1810 ///
1811 /// Returns `Ok(Cancelled)` when the cancel request is **submitted**, not when
1812 /// the order is confirmed cancelled. The actual cancellation confirmation comes
1813 /// via `account_stream` as an `OrderStatus::Cancelled` event.
1814 ///
1815 /// The order ID mapping is retained until terminal status is received via
1816 /// `account_stream`, ensuring fill events arriving between cancel request and
1817 /// confirmation are not lost.
1818 async fn cancel_order(
1819 &self,
1820 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1821 ) -> Option<UnindexedOrderResponseCancel> {
1822 let key = OrderKey {
1823 exchange: request.key.exchange,
1824 instrument: request.key.instrument.clone(),
1825 strategy: request.key.strategy.clone(),
1826 cid: request.key.cid.clone(),
1827 };
1828
1829 let ib_order_id = match self.order_ids.get_ib_id(&request.key.cid) {
1830 Some(id) => id,
1831 None => {
1832 return Some(OrderResponseCancel {
1833 key,
1834 state: Err(crate::error::OrderError::Rejected(ApiError::OrderRejected(
1835 "order ID not found in map".to_string(),
1836 ))),
1837 });
1838 }
1839 };
1840
1841 let client = self.client.clone();
1842
1843 let result =
1844 tokio::task::spawn_blocking(move || client.cancel_order(ib_order_id, "")).await;
1845
1846 match result {
1847 Ok(Ok(_sub)) => {
1848 // Track user-initiated cancel for Cancelled vs Expired differentiation.
1849 // Insert only after cancel request succeeded — avoids stale entries if
1850 // the future is dropped before reaching this branch.
1851 self.pending_cancels.insert(ib_order_id);
1852
1853 // H-3 fix: Do NOT remove order_ids here. The mapping is needed to
1854 // correlate any fill events that arrive between now and when IB
1855 // confirms the cancel. Removal happens in account_stream when
1856 // OrderStatus::Cancelled is received.
1857 // IBKR cancel_order returns no filled qty; use ZERO and let
1858 // subsequent OrderStatus events provide accurate fill info.
1859 Some(OrderResponseCancel {
1860 key,
1861 state: Ok(Cancelled::new(
1862 OrderId::new(format_smolstr!("{}", ib_order_id)),
1863 Utc::now(),
1864 Decimal::ZERO,
1865 )),
1866 })
1867 }
1868 Ok(Err(e)) => {
1869 error!(order_id = ib_order_id, error = %e, "Failed to cancel order");
1870 Some(OrderResponseCancel {
1871 key,
1872 state: Err(send_error(&e, e.to_string())),
1873 })
1874 }
1875 Err(e) => {
1876 error!(order_id = ib_order_id, error = %e, "Task join error");
1877 Some(OrderResponseCancel {
1878 key,
1879 state: Err(crate::error::OrderError::Rejected(ApiError::OrderRejected(
1880 e.to_string(),
1881 ))),
1882 })
1883 }
1884 }
1885 }
1886
1887 /// Submit an order to IB.
1888 ///
1889 /// # Cancellation Safety
1890 ///
1891 /// This future registers the order ID mapping before submitting to IB.
1892 /// If the future is cancelled (e.g., via `tokio::select!` timeout) after
1893 /// registration but before completion:
1894 /// - The order may still be submitted to IB
1895 /// - The order ID mapping will leak (not cleaned up)
1896 /// - Subsequent fills will be processed via `account_stream`
1897 ///
1898 /// Callers should avoid cancelling this future mid-flight. If timeout
1899 /// behavior is needed, prefer setting IB's native order timeout via
1900 /// `TimeInForce` instead.
1901 async fn open_order(
1902 &self,
1903 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1904 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
1905 let key = OrderKey {
1906 exchange: ExchangeId::Ibkr,
1907 instrument: request.key.instrument.clone(),
1908 strategy: request.key.strategy.clone(),
1909 cid: request.key.cid.clone(),
1910 };
1911
1912 let contract = match self.contracts.get_contract(request.key.instrument) {
1913 Some(c) => c,
1914 None => {
1915 return Some(Order {
1916 key,
1917 side: request.state.side,
1918 price: request.state.price,
1919 quantity: request.state.quantity,
1920 kind: request.state.kind,
1921 time_in_force: request.state.time_in_force,
1922 state: OrderState::inactive(OrderError::Rejected(ApiError::InstrumentInvalid(
1923 request.key.instrument.clone(),
1924 "contract not registered".to_string(),
1925 ))),
1926 });
1927 }
1928 };
1929
1930 let quantity: f64 = match request.state.quantity.try_into() {
1931 Ok(q) => q,
1932 Err(_) => {
1933 return Some(Order {
1934 key,
1935 side: request.state.side,
1936 price: request.state.price,
1937 quantity: request.state.quantity,
1938 kind: request.state.kind,
1939 time_in_force: request.state.time_in_force,
1940 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1941 format!("quantity {} exceeds f64 range", request.state.quantity),
1942 ))),
1943 });
1944 }
1945 };
1946
1947 let ib_order = match build_ib_order(
1948 request.state.side,
1949 quantity,
1950 &request.state.kind,
1951 request.state.price,
1952 &request.state.time_in_force,
1953 ) {
1954 Ok(o) => o,
1955 Err(e) => {
1956 return Some(Order {
1957 key,
1958 side: request.state.side,
1959 price: request.state.price,
1960 quantity: request.state.quantity,
1961 kind: request.state.kind,
1962 time_in_force: request.state.time_in_force,
1963 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1964 e.to_string(),
1965 ))),
1966 });
1967 }
1968 };
1969
1970 let ib_order_id = self.allocate_order_id();
1971
1972 // Store order context for reconstructing Order from OrderStatus callbacks
1973 let context = OrderContext {
1974 instrument: request.key.instrument.clone(),
1975 side: request.state.side,
1976 price: request.state.price,
1977 quantity: request.state.quantity,
1978 kind: request.state.kind,
1979 time_in_force: request.state.time_in_force,
1980 };
1981 self.order_ids
1982 .register(request.key.cid.clone(), ib_order_id, context);
1983
1984 let client = self.client.clone();
1985 let side = request.state.side;
1986 let price = request.state.price;
1987 let req_quantity = request.state.quantity;
1988 let kind = request.state.kind;
1989 let tif = request.state.time_in_force;
1990
1991 // Move both place_order AND subscription iteration into spawn_blocking
1992 // to avoid blocking Tokio worker threads on IB's synchronous iterator.
1993 let result = tokio::task::spawn_blocking(move || {
1994 let sub = match client.place_order(ib_order_id, &contract, &ib_order) {
1995 Ok(s) => s,
1996 Err(e) => return Err(send_error(&e, e.to_string())),
1997 };
1998
1999 match await_order_placement(sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)) {
2000 PlacementOutcome::Accepted { filled } => {
2001 let filled = parse_decimal_or_warn(filled, "status.filled");
2002 Ok(Some((ib_order_id, filled)))
2003 }
2004 // Cancelled/Inactive/unexpected status or a genuine notice/error.
2005 // Don't remove the mapping here — the outer match centralizes all
2006 // error-path removals.
2007 PlacementOutcome::Rejected(reason) => {
2008 Err(OrderError::Rejected(ApiError::OrderRejected(reason)))
2009 }
2010 // Held until RTH (informational notice): the order is live; its
2011 // authoritative status arrives via account_stream. Surface as
2012 // "no terminal status yet" (Open with zero fill), same as below.
2013 PlacementOutcome::HeldPending(notice) => {
2014 warn!(ib_order_id, %notice, "order held/pending; tracking via stream");
2015 Ok(None)
2016 }
2017 // Subscription exhausted/timed out without a terminal status.
2018 // Do NOT remove the mapping — ExecutionData/CommissionReport may
2019 // still arrive via account_stream; clear_stale_order_ids() handles
2020 // stale mappings.
2021 PlacementOutcome::NoStatus => Ok(None),
2022 }
2023 })
2024 .await;
2025
2026 match result {
2027 Ok(Ok(Some((order_id, filled)))) => {
2028 // IB always returns order status via subscription - never immediate fills.
2029 // The filled quantity here is from the OrderStatus event, not a complete fill.
2030 Some(Order {
2031 key,
2032 side,
2033 price,
2034 quantity: req_quantity,
2035 kind,
2036 time_in_force: tif,
2037 state: OrderState::active(Open::new(
2038 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", order_id))),
2039 Utc::now(),
2040 filled,
2041 )),
2042 })
2043 }
2044 Ok(Ok(None)) => {
2045 // Subscription exhausted without terminal status. The order WAS submitted
2046 // (we have an IB order ID), but we lost tracking. Return Open with zero
2047 // filled — caller can query via fetch_open_orders or wait for account_stream.
2048 warn!(
2049 ib_order_id,
2050 "Order subscription ended without terminal status, returning Open"
2051 );
2052 Some(Order {
2053 key,
2054 side,
2055 price,
2056 quantity: req_quantity,
2057 kind,
2058 time_in_force: tif,
2059 state: OrderState::active(Open::new(
2060 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", ib_order_id))),
2061 Utc::now(),
2062 Decimal::ZERO,
2063 )),
2064 })
2065 }
2066 Ok(Err(error)) => {
2067 // Cleanup order_ids: the order was never sent (place_order error)
2068 // or TWS rejected it (Cancelled/Inactive or a genuine notice).
2069 self.order_ids.remove_by_ib_id(ib_order_id);
2070 Some(Order {
2071 key,
2072 side,
2073 price,
2074 quantity: req_quantity,
2075 kind,
2076 time_in_force: tif,
2077 state: OrderState::inactive(error),
2078 })
2079 }
2080 Err(e) => {
2081 self.order_ids.remove_by_ib_id(ib_order_id);
2082 Some(Order {
2083 key,
2084 side,
2085 price,
2086 quantity: req_quantity,
2087 kind,
2088 time_in_force: tif,
2089 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2090 e.to_string(),
2091 ))),
2092 })
2093 }
2094 }
2095 }
2096
2097 /// Fetch account balances.
2098 ///
2099 /// # Limitations
2100 ///
2101 /// The `time_exchange` field in returned balances uses `Utc::now()`, not
2102 /// the actual IB server timestamp. IB's account summary endpoint does not
2103 /// provide timestamps per balance update.
2104 async fn fetch_balances(
2105 &self,
2106 assets: &[AssetNameExchange],
2107 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
2108 let client = self.client.clone();
2109 let assets_filter: Option<HashSet<AssetNameExchange>> = if assets.is_empty() {
2110 None
2111 } else {
2112 Some(assets.iter().cloned().collect())
2113 };
2114
2115 tokio::task::spawn_blocking(move || {
2116 // IB's reqAccountSummary expects a group name ("All" for all linked accounts),
2117 // not an account ID. Using account ID causes error 321 "Unified group name is invalid".
2118 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
2119 let sub = client
2120 .account_summary(&ACCOUNT_GROUP_ALL, &["TotalCashValue", "AvailableFunds"])
2121 .map_err(|e| UnindexedClientError::Internal(format!("account_summary: {e}")))?;
2122
2123 let mut aggregator = BalanceAggregator::new();
2124 for summary in sub.iter_data() {
2125 // Surface errors rather than returning a partial balance set.
2126 let summary = summary
2127 .map_err(|e| UnindexedClientError::Internal(format!("account_summary: {e}")))?;
2128 match summary {
2129 AccountSummaryResult::Summary(s) => aggregator.process(&s),
2130 AccountSummaryResult::End => break,
2131 }
2132 }
2133
2134 let mut balances = aggregator.to_balances();
2135
2136 if let Some(ref filter) = assets_filter {
2137 balances.retain(|b| filter.contains(&b.asset));
2138 }
2139
2140 Ok(balances)
2141 })
2142 .await
2143 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
2144 }
2145
2146 /// Fetch open orders.
2147 ///
2148 /// # Limitations
2149 ///
2150 /// - The `filled_qty` in returned orders is set to zero. IB's open orders endpoint
2151 /// returns order definitions, not fill status. For accurate filled quantities,
2152 /// use `account_stream` which provides `OrderStatus` events with fill progress.
2153 /// - The `time_in_force` defaults to `GoodUntilCancelled`. IB's open orders endpoint
2154 /// does not return the original TIF setting.
2155 /// - This method blocks on IB's subscription until IB sends an end-of-data marker.
2156 /// If IB is stalled, this will block indefinitely.
2157 ///
2158 /// # Known Issue: Stale Results on `ibapi` 4.2.0
2159 ///
2160 /// The result may not be this call's answer. See the
2161 /// [module's Known Issues](self#known-issues-ibapi-420-shared-queues).
2162 ///
2163 /// - Order updates received since the previous call are read as part of the
2164 /// result. It can hold the same order more than once, and orders that have
2165 /// since filled or been cancelled, reported as open.
2166 /// - After the first connection drop, the next three calls fail with
2167 /// [`UnindexedClientError::Internal`], and every call after them returns
2168 /// the reply to the call three before it. Each later drop adds three more
2169 /// calls of lag, and three more failures, which come once the replies
2170 /// already queued have been read. Retrying does not catch up.
2171 ///
2172 /// Do not treat the result as authoritative open-order state on 4.2.0. The
2173 /// order events from [`ExecutionClient::account_stream`] are unaffected.
2174 async fn fetch_open_orders(
2175 &self,
2176 instruments: &[InstrumentNameExchange],
2177 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
2178 let client = self.client.clone();
2179 let contracts = self.contracts.clone();
2180 let order_ids = self.order_ids.clone();
2181 let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
2182 None
2183 } else {
2184 Some(instruments.iter().cloned().collect())
2185 };
2186
2187 tokio::task::spawn_blocking(move || {
2188 use ibapi::orders::Orders;
2189
2190 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
2191 let sub = client
2192 .all_open_orders()
2193 .map_err(|e| UnindexedClientError::Internal(format!("open_orders: {e}")))?;
2194
2195 let mut orders = Vec::new();
2196 for order_item in sub.iter_data() {
2197 // Surface errors rather than returning a partial open-order set,
2198 // which the caller could misread during order reconciliation.
2199 let order_item = order_item
2200 .map_err(|e| UnindexedClientError::Internal(format!("open_orders: {e}")))?;
2201 let order_data = match order_item {
2202 Orders::OrderData(data) => data,
2203 _ => continue,
2204 };
2205
2206 let instrument = match contracts.get_name_by_con_id(order_data.contract.contract_id)
2207 {
2208 Some(i) => i,
2209 None => continue,
2210 };
2211
2212 if instruments_filter
2213 .as_ref()
2214 .is_some_and(|f| !f.contains(&instrument))
2215 {
2216 continue;
2217 }
2218
2219 let client_id = order_ids
2220 .get_client_id(order_data.order_id)
2221 .unwrap_or_else(|| {
2222 ClientOrderId::new(format_smolstr!("{}", order_data.order_id))
2223 });
2224
2225 let side = match order_data.order.action {
2226 ibapi::orders::Action::Buy => Side::Buy,
2227 ibapi::orders::Action::Sell
2228 | ibapi::orders::Action::SellShort
2229 | ibapi::orders::Action::SellLong => Side::Sell,
2230 };
2231
2232 let price = order_data
2233 .order
2234 .limit_price
2235 .map(|p| parse_decimal_or_warn(p, "limit_price"));
2236 let kind = if order_data.order.order_type == "LMT" {
2237 OrderKind::Limit
2238 } else {
2239 OrderKind::Market
2240 };
2241
2242 orders.push(Order {
2243 key: OrderKey {
2244 exchange: ExchangeId::Ibkr,
2245 instrument,
2246 strategy: StrategyId::unknown(),
2247 cid: client_id,
2248 },
2249 side,
2250 price,
2251 quantity: parse_decimal_or_warn(
2252 order_data.order.total_quantity,
2253 "total_quantity",
2254 ),
2255 kind,
2256 // IB's open orders endpoint doesn't return TIF; default to GTC
2257 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
2258 state: Open::new(
2259 VenueOrderId::Assigned(OrderId::new(format_smolstr!(
2260 "{}",
2261 order_data.order_id
2262 ))),
2263 Utc::now(),
2264 Decimal::ZERO, // M-5: filled_qty unavailable from open orders endpoint
2265 ),
2266 });
2267 }
2268
2269 Ok(orders)
2270 })
2271 .await
2272 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
2273 }
2274
2275 /// Fetch historical trades (executions).
2276 ///
2277 /// # Limitations
2278 ///
2279 /// - IB only returns executions from the current trading day. The `time_since`
2280 /// parameter is applied client-side to filter within that day. For historical
2281 /// executions beyond today, use IB's Flex Query or Activity Statements.
2282 /// - **Fees are always zero.** IB's executions endpoint doesn't include commission
2283 /// data. For trades with accurate fees, use `account_stream()` which pairs
2284 /// `ExecutionData` with `CommissionReport` events.
2285 /// - This method blocks on IB's executions subscription until IB sends an
2286 /// end-of-data marker. If IB is stalled, this will block indefinitely.
2287 async fn fetch_trades(
2288 &self,
2289 time_since: DateTime<Utc>,
2290 instruments: &[InstrumentNameExchange],
2291 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
2292 let client = self.client.clone();
2293 let contracts = self.contracts.clone();
2294 let order_ids = self.order_ids.clone();
2295 let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
2296 None
2297 } else {
2298 Some(instruments.iter().cloned().collect())
2299 };
2300
2301 tokio::task::spawn_blocking(move || {
2302 use ibapi::orders::{ExecutionSide, Executions};
2303
2304 let exec_filter = ibapi::orders::ExecutionFilter::default();
2305 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
2306 let sub = client
2307 .executions(exec_filter)
2308 .map_err(|e| UnindexedClientError::Internal(format!("executions: {e}")))?;
2309
2310 let mut trades = Vec::new();
2311 for exec_item in sub.iter_data() {
2312 // Surface errors rather than returning a partial fill set, which the
2313 // caller could misread during fill recovery.
2314 let exec_item = exec_item
2315 .map_err(|e| UnindexedClientError::Internal(format!("executions: {e}")))?;
2316 let exec_data = match exec_item {
2317 Executions::ExecutionData(data) => data,
2318 _ => continue,
2319 };
2320
2321 let instrument = match contracts.get_name_by_con_id(exec_data.contract.contract_id)
2322 {
2323 Some(i) => i,
2324 None => continue,
2325 };
2326
2327 if instruments_filter
2328 .as_ref()
2329 .is_some_and(|f| !f.contains(&instrument))
2330 {
2331 continue;
2332 }
2333
2334 let exec = &exec_data.execution;
2335 let exec_time = match execution::parse_ib_timestamp(&exec.time) {
2336 Some(t) => t,
2337 None => {
2338 warn!(
2339 exec_id = %exec.execution_id,
2340 time = %exec.time,
2341 "Unparseable timestamp in execution, skipping"
2342 );
2343 continue;
2344 }
2345 };
2346
2347 if exec_time < time_since {
2348 continue;
2349 }
2350
2351 // `ExecutionSide` is a closed two-variant enum in ibapi 3.x
2352 // (the decoder rejects unknown wire values), so this is total.
2353 let side = match exec.side {
2354 ExecutionSide::Bought => Side::Buy,
2355 ExecutionSide::Sold => Side::Sell,
2356 };
2357
2358 let client_id = order_ids
2359 .get_client_id(exec.order_id)
2360 .unwrap_or_else(|| ClientOrderId::new(format_smolstr!("{}", exec.order_id)));
2361
2362 trades.push(Trade {
2363 id: TradeId::new(&exec.execution_id),
2364 order_id: OrderId::new(&client_id.0),
2365 instrument,
2366 strategy: StrategyId::unknown(),
2367 time_exchange: exec_time,
2368 side,
2369 price: parse_decimal_or_warn(exec.price, "exec.price"),
2370 quantity: parse_decimal_or_warn(exec.shares, "exec.shares"),
2371 order_filled_quantity: Some(parse_decimal_or_warn(
2372 exec.cumulative_quantity,
2373 "exec.cumulative_quantity",
2374 )),
2375 // IBKR executions API lacks commission data (available via CommissionReport callback).
2376 // "UNKNOWN" placeholder will fail indexing - use unindexed or correlate with WS.
2377 fees: AssetFees::new(AssetNameExchange::from("UNKNOWN"), Decimal::ZERO, None),
2378 });
2379 }
2380
2381 Ok(trades)
2382 })
2383 .await
2384 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
2385 }
2386}
2387
2388/// Build an Order from IB OrderStatus using stored OrderContext.
2389///
2390/// # IBKR Status Mapping
2391///
2392/// | `OrderStatusKind` | → OrderState | Rationale |
2393/// |---------------------------------------------|----------------------|---------------------------------------------|
2394/// | `Inactive` | OpenFailed(Rejected) | Order blocked by validation/margin/exchange |
2395/// | `Cancelled` | Cancelled or Expired | See differentiation logic below |
2396/// | `Filled` | FullyFilled | Order fully executed |
2397/// | `Submitted`/`PreSubmitted`/`PendingSubmit` | Active(Open) | Order working on exchange |
2398/// | `ApiPending`/`PendingCancel`/`ApiCancelled` | Active(Open) | Transitional; await a confirmed terminal |
2399/// | `Unknown(raw)` | Active(Open) + warn | Unmodelled status; stream is authoritative |
2400///
2401/// The match is exhaustive over `OrderStatusKind` (no wildcard), so a new ibapi
2402/// *variant* becomes a compile error rather than a silent `Active(Open)`.
2403///
2404/// Since ibapi 4.0 a new TWS *status string* no longer reaches that guard: it
2405/// decodes to `OrderStatusKind::Unknown(raw)` instead of failing the whole
2406/// subscription with `Error::Parse`. This function handles that variant
2407/// explicitly and logs it at `warn!`, so an unrecognised status is observable
2408/// rather than silent — but it is no longer a compile error.
2409///
2410/// # Cancelled vs Expired Differentiation
2411///
2412/// IBKR sends `"Cancelled"` status for both user-initiated cancellation and
2413/// time-based expiration. We differentiate using:
2414///
2415/// 1. If order ID is in `pending_cancels` (set by `cancel_order`) → `Cancelled`
2416/// 2. Else if `time_in_force == GoodUntilEndOfDay` → `Expired` (DAY order expired)
2417/// 3. Else → `Cancelled` (broker-initiated or external cancellation)
2418///
2419/// # Known Limitation
2420///
2421/// If the broker cancels a DAY order before market close (e.g., insufficient margin),
2422/// it will be misclassified as `Expired`. This is rare and acceptable given the
2423/// alternative (forking ibapi to preserve order_id in error callbacks).
2424fn make_order_from_status(
2425 status: &ibapi::orders::OrderStatus,
2426 client_id: ClientOrderId,
2427 ctx: &OrderContext,
2428 pending_cancels: &PendingCancels,
2429) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
2430{
2431 use ibapi::orders::OrderStatusKind;
2432
2433 let ib_id = status.order_id;
2434 let order_id = OrderId::new(format_smolstr!("{}", ib_id));
2435
2436 let filled_qty = parse_decimal_or_warn(status.filled, "status.filled");
2437 let state = match status.status {
2438 OrderStatusKind::Inactive => {
2439 // "Inactive" means the order was accepted by IB but is not working:
2440 // validation failure, margin issue, exchange closed, share location hold.
2441 // This is a placement failure, not a cancellation.
2442 OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2443 "IB status: Inactive (order blocked by validation/margin/exchange)".into(),
2444 )))
2445 }
2446 OrderStatusKind::Cancelled => {
2447 // Differentiate user-cancel from time-expiration
2448 let was_user_cancel = pending_cancels.remove(ib_id);
2449
2450 if was_user_cancel {
2451 // User called cancel_order() — definitely a cancellation
2452 OrderState::inactive(Cancelled::new(order_id, Utc::now(), filled_qty))
2453 } else if matches!(ctx.time_in_force, TimeInForce::GoodUntilEndOfDay) {
2454 // DAY order without pending cancel — expired at market close
2455 OrderState::inactive(Expired::new(order_id, Utc::now(), filled_qty))
2456 } else {
2457 // GTC/IOC/FOK without pending cancel — broker or exchange cancelled
2458 OrderState::inactive(Cancelled::new(order_id, Utc::now(), filled_qty))
2459 }
2460 }
2461 OrderStatusKind::Filled => {
2462 OrderState::fully_filled(Filled::new(order_id, Utc::now(), filled_qty, None))
2463 }
2464 // Working states (Submitted/PreSubmitted/PendingSubmit) and the
2465 // transitional ones (ApiPending/PendingCancel/ApiCancelled) are reported
2466 // as active/Open until a confirmed Cancelled/Inactive/Filled arrives.
2467 // ApiCancelled is deliberately NOT treated as terminal here: it precedes
2468 // a confirmed Cancelled, whose event reaps the order-id mapping (or
2469 // clear_stale does), mirroring the account_stream `is_terminal` guard.
2470 // Enumerated explicitly so a future ibapi variant fails to compile rather
2471 // than silently defaulting to Open.
2472 OrderStatusKind::Submitted
2473 | OrderStatusKind::PreSubmitted
2474 | OrderStatusKind::PendingSubmit
2475 | OrderStatusKind::ApiPending
2476 | OrderStatusKind::PendingCancel
2477 | OrderStatusKind::ApiCancelled => OrderState::active(Open::new(
2478 VenueOrderId::Assigned(order_id),
2479 Utc::now(),
2480 filled_qty,
2481 )),
2482 // A status string ibapi does not model. Upstream declines to classify
2483 // it — `is_active()` and `is_terminal()` are both false — and so do we:
2484 // report it active/Open so the order-id mapping is retained and the
2485 // account stream can still resolve the order's real state. The
2486 // alternative, treating it as a rejection, would drop the mapping for
2487 // an order that may well be live in the market and discard its
2488 // executions and commissions. An order that is in fact dead and left
2489 // Open here is reaped by `OrderIdMap::clear_stale`.
2490 OrderStatusKind::Unknown(ref raw) => {
2491 warn!(
2492 ib_id,
2493 status = raw.as_str(),
2494 "unmodelled IBKR order status; treating as live, account \
2495 stream is authoritative"
2496 );
2497 OrderState::active(Open::new(
2498 VenueOrderId::Assigned(order_id),
2499 Utc::now(),
2500 filled_qty,
2501 ))
2502 }
2503 };
2504
2505 Order {
2506 key: OrderKey {
2507 exchange: ExchangeId::Ibkr,
2508 instrument: ctx.instrument.clone(),
2509 strategy: StrategyId::unknown(),
2510 cid: client_id,
2511 },
2512 side: ctx.side,
2513 price: ctx.price,
2514 quantity: ctx.quantity,
2515 kind: ctx.kind,
2516 time_in_force: ctx.time_in_force,
2517 state,
2518 }
2519}
2520
2521pub use execution::parse_ib_timestamp;
2522
2523impl BracketOrderClient for IbkrClient {
2524 async fn open_bracket_order(
2525 &self,
2526 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
2527 ) -> UnifiedBracketOrderResult {
2528 let ibkr_request = BracketOrderRequest {
2529 instrument: request.key.instrument.clone(),
2530 strategy: request.key.strategy.clone(),
2531 parent_cid: request.key.cid.clone(),
2532 side: request.state.side,
2533 quantity: request.state.quantity,
2534 entry_price: request.state.entry_price,
2535 take_profit_price: request.state.take_profit_price,
2536 stop_loss_price: request.state.stop_loss_price,
2537 time_in_force: request.state.time_in_force,
2538 };
2539
2540 let result = self.open_bracket_order(ibkr_request).await;
2541
2542 UnifiedBracketOrderResult::with_all_legs(
2543 result.parent,
2544 result.take_profit,
2545 result.stop_loss,
2546 )
2547 }
2548}
2549
2550#[cfg(test)]
2551#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
2552mod contract_config_tests {
2553 use super::*;
2554 use contract::ContractConfigError as E;
2555
2556 /// A `ContractConfig` with every optional field present, so individual tests
2557 /// can null out exactly the field under test.
2558 fn full_config(security_type: &str) -> ContractConfig {
2559 ContractConfig {
2560 name: "TEST".to_string(),
2561 symbol: "AAPL".to_string(),
2562 security_type: security_type.to_string(),
2563 exchange: "SMART".to_string(),
2564 currency: "USD".to_string(),
2565 last_trade_date: Some("20260116".to_string()),
2566 strike: Some(150.0),
2567 right: Some("C".to_string()),
2568 }
2569 }
2570
2571 #[test]
2572 fn stk_and_cash_ignore_option_fields() {
2573 // STK/CASH never read last_trade_date/strike/right, so absence is fine.
2574 let mut stk = full_config("STK");
2575 stk.last_trade_date = None;
2576 stk.strike = None;
2577 stk.right = None;
2578 assert!(stk.to_contract().is_ok());
2579
2580 let mut cash = full_config("CASH");
2581 cash.last_trade_date = None;
2582 cash.strike = None;
2583 cash.right = None;
2584 assert!(cash.to_contract().is_ok());
2585 }
2586
2587 #[test]
2588 fn valid_fut_and_opt_build() {
2589 assert!(full_config("FUT").to_contract().is_ok());
2590 assert!(full_config("OPT").to_contract().is_ok());
2591 }
2592
2593 #[test]
2594 fn opt_missing_right_is_error_not_silent_call() {
2595 let mut cfg = full_config("OPT");
2596 cfg.right = None;
2597 assert_eq!(cfg.to_contract().unwrap_err(), E::MissingOptionRight);
2598 }
2599
2600 #[test]
2601 fn opt_unrecognized_right_is_error() {
2602 let mut cfg = full_config("OPT");
2603 cfg.right = Some("X".to_string());
2604 assert_eq!(
2605 cfg.to_contract().unwrap_err(),
2606 E::UnrecognizedOptionRight {
2607 right: "X".to_string()
2608 }
2609 );
2610 }
2611
2612 #[test]
2613 fn opt_missing_strike_is_error() {
2614 let mut cfg = full_config("OPT");
2615 cfg.strike = None;
2616 assert_eq!(cfg.to_contract().unwrap_err(), E::MissingStrike);
2617 }
2618
2619 #[test]
2620 fn fut_and_opt_missing_last_trade_date_is_error() {
2621 let mut fut = full_config("FUT");
2622 fut.last_trade_date = None;
2623 assert_eq!(fut.to_contract().unwrap_err(), E::MissingLastTradeDate);
2624
2625 let mut opt = full_config("OPT");
2626 opt.last_trade_date = None;
2627 assert_eq!(opt.to_contract().unwrap_err(), E::MissingLastTradeDate);
2628 }
2629
2630 #[test]
2631 fn unknown_security_type_is_error_not_silent_stk() {
2632 let cfg = full_config("BOND");
2633 assert_eq!(
2634 cfg.to_contract().unwrap_err(),
2635 E::UnrecognizedSecurityType {
2636 security_type: "BOND".to_string()
2637 }
2638 );
2639 }
2640}
2641
2642#[cfg(test)]
2643#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
2644mod order_status_tests {
2645 use super::*;
2646 use crate::order::state::ActiveOrderState;
2647 use ibapi::orders::{OrderStatus, OrderStatusKind, PlaceOrder};
2648
2649 fn status(kind: OrderStatusKind, filled: f64) -> Result<PlaceOrder, ibapi::Error> {
2650 Ok(PlaceOrder::OrderStatus(OrderStatus {
2651 order_id: 42,
2652 status: kind,
2653 filled,
2654 ..OrderStatus::default()
2655 }))
2656 }
2657
2658 fn ctx() -> OrderContext {
2659 OrderContext {
2660 instrument: InstrumentNameExchange::new("AAPL"),
2661 side: Side::Buy,
2662 price: Some(Decimal::from(150)),
2663 quantity: Decimal::from(10),
2664 kind: OrderKind::Limit,
2665 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
2666 }
2667 }
2668
2669 /// ibapi 4.0 preserves an unrecognised TWS status as
2670 /// `OrderStatusKind::Unknown(raw)` instead of failing the subscription with
2671 /// `Error::Parse`. Placement must treat it as live-but-indeterminate: the
2672 /// order exists, so the order-id mapping has to survive for the account
2673 /// stream to resolve it.
2674 #[test]
2675 fn unknown_status_is_held_pending_during_placement() {
2676 let events = vec![status(OrderStatusKind::Unknown("Reclassified".into()), 0.0)];
2677
2678 match await_order_placement(events) {
2679 PlacementOutcome::HeldPending(reason) => {
2680 assert!(
2681 reason.contains("Reclassified"),
2682 "the raw status must reach the caller, got {reason:?}"
2683 );
2684 }
2685 other => panic!("expected HeldPending, got {other:?}"),
2686 }
2687 }
2688
2689 /// Specifically *not* the `ApiPending` treatment. Falling through to the
2690 /// `PLACEMENT_STATUS_TIMEOUT` backstop yields `NoStatus`, which on the
2691 /// bracket path cancels all three legs — so an unrecognised status must
2692 /// produce a decision here rather than deferring to the timeout.
2693 #[test]
2694 fn unknown_status_does_not_fall_through_to_no_status() {
2695 let events = vec![status(OrderStatusKind::Unknown("Reclassified".into()), 0.0)];
2696 assert!(!matches!(
2697 await_order_placement(events),
2698 PlacementOutcome::NoStatus
2699 ));
2700
2701 // ApiPending, by contrast, keeps waiting and exhausts the iterator.
2702 let events = vec![status(OrderStatusKind::ApiPending, 0.0)];
2703 assert!(matches!(
2704 await_order_placement(events),
2705 PlacementOutcome::NoStatus
2706 ));
2707 }
2708
2709 /// ibapi 4.2 fails an in-flight placement with a transport error the moment
2710 /// the socket drops. TWS may already hold the order, so it must stay
2711 /// trackable (`NoStatus` keeps the order-id mapping), not read as rejected.
2712 #[test]
2713 fn transport_loss_while_awaiting_placement_is_no_status() {
2714 for e in [
2715 ibapi::Error::ConnectionReset,
2716 ibapi::Error::ConnectionFailed,
2717 ibapi::Error::Shutdown,
2718 ibapi::Error::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
2719 ] {
2720 let label = e.to_string();
2721 assert!(
2722 matches!(
2723 await_order_placement(vec![Err(e)]),
2724 PlacementOutcome::NoStatus
2725 ),
2726 "{label} must leave the placement unresolved, not rejected"
2727 );
2728 }
2729
2730 // A status that arrived before the drop still decides.
2731 let events = vec![
2732 status(OrderStatusKind::Submitted, 0.0),
2733 Err(ibapi::Error::ConnectionReset),
2734 ];
2735 assert!(matches!(
2736 await_order_placement(events),
2737 PlacementOutcome::Accepted { .. }
2738 ));
2739 }
2740
2741 #[test]
2742 fn non_transport_error_while_awaiting_placement_is_rejected() {
2743 let events = vec![Err(ibapi::Error::Simple("boom".into()))];
2744 assert!(matches!(
2745 await_order_placement(events),
2746 PlacementOutcome::Rejected(reason) if reason.contains("boom")
2747 ));
2748 }
2749
2750 /// A send refused because the transport is down or reconnecting never
2751 /// reached TWS: it is a transient connectivity error, not a venue rejection.
2752 #[test]
2753 fn send_error_separates_transport_loss_from_refusal() {
2754 let refused: OrderError<AssetNameExchange, InstrumentNameExchange> =
2755 send_error(&ibapi::Error::ConnectionReset, "down".into());
2756 assert!(
2757 matches!(&refused, OrderError::Connectivity(ConnectivityError::Socket(m)) if m == "down")
2758 );
2759 assert!(refused.is_transient());
2760
2761 let io: OrderError<AssetNameExchange, InstrumentNameExchange> = send_error(
2762 &ibapi::Error::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
2763 "io".into(),
2764 );
2765 assert!(io.is_transient());
2766
2767 // ibapi has given up reconnecting: retrying cannot succeed.
2768 for e in [
2769 ibapi::Error::Shutdown,
2770 ibapi::Error::ConnectionFailed,
2771 ibapi::Error::Simple("no".into()),
2772 ] {
2773 let label = e.to_string();
2774 let rejected: OrderError<AssetNameExchange, InstrumentNameExchange> =
2775 send_error(&e, "no".into());
2776 assert!(
2777 matches!(&rejected, OrderError::Rejected(ApiError::OrderRejected(m)) if m == "no"),
2778 "{label} must be a non-transient refusal"
2779 );
2780 assert!(!rejected.is_transient(), "{label}");
2781 }
2782 }
2783
2784 /// Regression anchors: the statuses that were already decisive must keep
2785 /// their outcomes across the 4.0 migration.
2786 #[test]
2787 fn known_statuses_keep_their_placement_outcomes() {
2788 assert!(matches!(
2789 await_order_placement(vec![status(OrderStatusKind::Submitted, 0.0)]),
2790 PlacementOutcome::Accepted { .. }
2791 ));
2792 assert!(matches!(
2793 await_order_placement(vec![status(OrderStatusKind::Filled, 10.0)]),
2794 PlacementOutcome::Accepted { filled } if filled == 10.0
2795 ));
2796 assert!(matches!(
2797 await_order_placement(vec![status(OrderStatusKind::Inactive, 0.0)]),
2798 PlacementOutcome::Rejected(_)
2799 ));
2800 assert!(matches!(
2801 await_order_placement(vec![status(OrderStatusKind::Cancelled, 0.0)]),
2802 PlacementOutcome::Rejected(_)
2803 ));
2804 }
2805
2806 /// An unmodelled status must not be reported as terminal. Treating it as a
2807 /// rejection would drop the order-id mapping for an order that may well be
2808 /// live in the market, discarding its executions and commissions.
2809 #[test]
2810 fn unknown_status_maps_to_active_open() {
2811 let raw = OrderStatus {
2812 order_id: 42,
2813 status: OrderStatusKind::Unknown("Reclassified".into()),
2814 filled: 3.0,
2815 ..OrderStatus::default()
2816 };
2817
2818 let order = make_order_from_status(
2819 &raw,
2820 ClientOrderId::new("cid-1"),
2821 &ctx(),
2822 &PendingCancels::new(),
2823 );
2824
2825 assert!(
2826 matches!(order.state, OrderState::Active(_)),
2827 "unmodelled status must stay active, got {:?}",
2828 order.state
2829 );
2830 }
2831
2832 /// An unmodelled status must not consume a pending cancel: the entry has to
2833 /// survive for the confirmed `Cancelled` that may still follow.
2834 #[test]
2835 fn unknown_status_leaves_pending_cancel_intact() {
2836 let pending = PendingCancels::new();
2837 pending.insert(42);
2838
2839 let raw = OrderStatus {
2840 order_id: 42,
2841 status: OrderStatusKind::Unknown("Reclassified".into()),
2842 ..OrderStatus::default()
2843 };
2844 let _ = make_order_from_status(&raw, ClientOrderId::new("cid-1"), &ctx(), &pending);
2845
2846 assert!(
2847 pending.remove(42),
2848 "the pending cancel must still be there for the confirmed Cancelled"
2849 );
2850 }
2851
2852 /// A rejected leg is settled even when its rollback cancel was refused: its
2853 /// terminal status will not be replayed, so keeping it Open would leave a
2854 /// phantom order.
2855 #[test]
2856 fn rejected_leg_with_failed_cancel_is_settled() {
2857 let accepted: Option<Result<f64, String>> = Some(Ok(0.0));
2858 let rejected: Option<Result<f64, String>> = Some(Err("Cancelled".into()));
2859
2860 assert_eq!(
2861 unresolved_legs([(1, &rejected), (2, &accepted), (3, &accepted)], &[1, 2, 3]),
2862 vec![2, 3]
2863 );
2864 }
2865
2866 /// A later leg's send failure keeps `send_error`'s classification only when
2867 /// every rollback cancel went out; otherwise it is a non-transient
2868 /// rejection naming the cancels that failed.
2869 #[test]
2870 fn leg_send_error_is_transient_only_after_a_clean_rollback() {
2871 let clean = leg_send_error(&ibapi::Error::ConnectionReset, "sl failed".into(), "");
2872 assert!(matches!(clean, OrderError::Connectivity(_)));
2873
2874 let dirty = leg_send_error(
2875 &ibapi::Error::ConnectionReset,
2876 "sl failed".into(),
2877 "; rollback cancel failed for order ids 10 (connection reset), which may be live",
2878 );
2879 match dirty {
2880 OrderError::Rejected(ApiError::OrderRejected(message)) => {
2881 assert!(message.starts_with("sl failed; rollback cancel failed"));
2882 }
2883 other => panic!("expected a non-transient rejection, got {other:?}"),
2884 }
2885 }
2886
2887 /// A leg is unresolved when it reported no status, or was accepted and its
2888 /// rollback cancel was refused; a leg whose cancel went out is settled.
2889 #[test]
2890 fn unresolved_legs_are_no_status_or_failed_cancel() {
2891 let accepted: Option<Result<f64, String>> = Some(Ok(0.0));
2892 let rejected: Option<Result<f64, String>> = Some(Err("201".into()));
2893 let no_status: Option<Result<f64, String>> = None;
2894
2895 assert_eq!(
2896 unresolved_legs([(1, &accepted), (2, &rejected), (3, &no_status)], &[]),
2897 vec![3]
2898 );
2899 assert_eq!(
2900 unresolved_legs([(1, &accepted), (2, &rejected), (3, &accepted)], &[1]),
2901 vec![1]
2902 );
2903 assert_eq!(
2904 unresolved_legs([(1, &no_status), (2, &no_status), (3, &accepted)], &[2]),
2905 vec![1, 2]
2906 );
2907 assert!(unresolved_legs([(1, &accepted), (2, &rejected), (3, &accepted)], &[]).is_empty());
2908 }
2909
2910 fn bracket_request() -> BracketOrderRequest {
2911 BracketOrderRequest {
2912 instrument: InstrumentNameExchange::new("AAPL"),
2913 strategy: StrategyId::new("test"),
2914 parent_cid: ClientOrderId::new("br"),
2915 side: Side::Buy,
2916 quantity: Decimal::from(10),
2917 entry_price: Decimal::from(150),
2918 take_profit_price: Decimal::from(160),
2919 stop_loss_price: Decimal::from(145),
2920 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
2921 }
2922 }
2923
2924 /// An unresolved leg comes back Open under its IB id and keeps its mapping,
2925 /// so the account stream can still resolve it; a settled leg comes back
2926 /// Inactive with the error and its mapping is released.
2927 #[test]
2928 fn failed_bracket_keeps_unresolved_legs_open_and_tracked() {
2929 let request = bracket_request();
2930 let (tp_cid, sl_cid) = derive_child_cids(&request.parent_cid);
2931 let order_ids = OrderIdMap::new();
2932 order_ids.register(request.parent_cid.clone(), 10, ctx());
2933 order_ids.register(tp_cid, 11, ctx());
2934 order_ids.register(sl_cid, 12, ctx());
2935
2936 let result = failed_bracket(
2937 &request,
2938 [10, 11, 12],
2939 BracketFailure {
2940 error: OrderError::Rejected(ApiError::OrderRejected("boom".into())),
2941 unresolved: vec![11],
2942 },
2943 &order_ids,
2944 );
2945
2946 assert!(matches!(result.parent.state, OrderState::Inactive(_)));
2947 assert!(matches!(result.stop_loss.state, OrderState::Inactive(_)));
2948 match &result.take_profit.state {
2949 OrderState::Active(ActiveOrderState::Open(open)) => {
2950 assert_eq!(open.id.assigned(), Some(&OrderId::new("11")));
2951 assert_eq!(open.filled_quantity, Decimal::ZERO);
2952 }
2953 other => panic!("unresolved leg must be Open, got {other:?}"),
2954 }
2955
2956 assert!(order_ids.get_client_id(10).is_none());
2957 assert!(order_ids.get_client_id(11).is_some());
2958 assert!(order_ids.get_client_id(12).is_none());
2959 }
2960
2961 /// With no unresolved leg the failure keeps its all-inactive shape and
2962 /// releases every mapping.
2963 #[test]
2964 fn failed_bracket_without_unresolved_legs_is_all_inactive() {
2965 let request = bracket_request();
2966 let (tp_cid, sl_cid) = derive_child_cids(&request.parent_cid);
2967 let order_ids = OrderIdMap::new();
2968 order_ids.register(request.parent_cid.clone(), 10, ctx());
2969 order_ids.register(tp_cid, 11, ctx());
2970 order_ids.register(sl_cid, 12, ctx());
2971
2972 let result = failed_bracket(
2973 &request,
2974 [10, 11, 12],
2975 BracketFailure {
2976 error: OrderError::Rejected(ApiError::OrderRejected("boom".into())),
2977 unresolved: Vec::new(),
2978 },
2979 &order_ids,
2980 );
2981
2982 for leg in [&result.parent, &result.take_profit, &result.stop_loss] {
2983 assert!(matches!(leg.state, OrderState::Inactive(_)));
2984 }
2985 for ib_id in [10, 11, 12] {
2986 assert!(order_ids.get_client_id(ib_id).is_none());
2987 }
2988 }
2989}
2990
2991#[cfg(test)]
2992#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
2993mod order_reader_tests {
2994 use super::*;
2995 use crate::client::dedup::new_dedup_cache;
2996 use ibapi::orders::{OrderStatus, OrderUpdate};
2997 use std::cell::Cell;
2998
2999 /// Status updates for an order this client did not place, so none is forwarded.
3000 fn unforwarded(n: usize) -> Vec<Result<OrderUpdate, ibapi::Error>> {
3001 (0..n)
3002 .map(|_| {
3003 Ok(OrderUpdate::OrderStatus(OrderStatus {
3004 order_id: 999,
3005 ..OrderStatus::default()
3006 }))
3007 })
3008 .collect()
3009 }
3010
3011 /// Run the reader over `updates` and return how many of them it pulled.
3012 fn run(sink: &recovery::EventSink, updates: Vec<Result<OrderUpdate, ibapi::Error>>) -> usize {
3013 let pulled = Cell::new(0);
3014 forward_order_updates(
3015 updates
3016 .into_iter()
3017 .inspect(|_| pulled.set(pulled.get() + 1)),
3018 sink,
3019 &ContractRegistry::new(),
3020 &OrderIdMap::new(),
3021 &PendingCancels::new(),
3022 &ExecutionBuffer::new(),
3023 );
3024 pulled.get()
3025 }
3026
3027 #[test]
3028 fn stops_on_the_first_update_after_the_stream_ends() {
3029 let (tx, _rx) = mpsc::unbounded_channel();
3030 let sink = recovery::EventSink::new(tx, new_dedup_cache());
3031 sink.terminate(StreamTerminationReason::Error("recovery failed".into()));
3032
3033 assert_eq!(run(&sink, unforwarded(3)), 1);
3034 }
3035
3036 #[test]
3037 fn stops_on_the_first_update_after_the_consumer_goes() {
3038 let (tx, rx) = mpsc::unbounded_channel();
3039 let sink = recovery::EventSink::new(tx, new_dedup_cache());
3040 drop(rx);
3041
3042 assert_eq!(run(&sink, unforwarded(3)), 1);
3043 }
3044
3045 #[test]
3046 fn open_stream_reads_past_unforwarded_updates() {
3047 let (tx, mut rx) = mpsc::unbounded_channel();
3048 let sink = recovery::EventSink::new(tx, new_dedup_cache());
3049
3050 assert_eq!(run(&sink, unforwarded(3)), 3);
3051 // The subscription ended, so the stream says so.
3052 assert!(matches!(
3053 rx.try_recv().unwrap().kind,
3054 AccountEventKind::StreamTerminated(StreamTerminationReason::Error(ref reason))
3055 if reason == "IBKR order-update stream ended"
3056 ));
3057 }
3058}