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//! # Balances and carrying costs
33//!
34//! Balances are read from IB's account summary, one per currency: `total` is `TotalCashValue` and
35//! `free` is `AvailableFunds`. No other tag is read, and [`ExecutionClient::account_stream`]
36//! delivers no balances.
37//!
38//! As IB documents it, margin interest and stock borrow fees accrue through the month, reported
39//! under the `AccruedCash` tag, and are posted to cash monthly. Until they are posted,
40//! `TotalCashValue`, and so `total`, leaves them out: up to a month of charges. This client does
41//! not read `AccruedCash`. Fills carry none of these charges, so PnL computed from them excludes
42//! them too.
43//!
44//! # Limitations
45//!
46//! - **Order types**: Market, Limit, Stop, StopLimit, TrailingStop, TrailingStopLimit,
47//! and Bracket (entry + take-profit + stop-loss) supported. No Algo orders.
48//! - **TimeInForce**: No `post_only` (IB has no maker-only orders)
49//! - **Client order ids** travel as each order's IB order reference, so they are at most 128
50//! ASCII characters
51//!
52//! # Reconnection & Recovery
53//!
54//! `ibapi` reconnects its socket to TWS/Gateway by itself, with the same client ID.
55//! In-flight requests fail with `ConnectionReset` at the drop, and new sends are refused
56//! until the reconnect completes. Order sends classify that as a transient
57//! [`OrderError::Connectivity`]. If the reconnect fails, `ibapi` shuts the client down.
58//!
59//! [`ExecutionClient::account_stream`] stays open across a successful reconnect. It
60//! detects the gap and, once delivery is restored, recovers the fills TWS sent while
61//! the socket was down, emitting each exactly once: as a `Trade`, or as a
62//! `TradeAmended` when it corrects an earlier execution. It then reports how each order
63//! it held as live ended during the gap, if it did. The same applies when TWS loses its
64//! own link to IB's servers (notice 1100) and later restores it (1101/1102). See that
65//! method for the details.
66//!
67//! Each order carries its client order id as its IB order reference, which IB lists with
68//! the order, its executions and its completion. So a client finds its orders by the id
69//! they were placed with even across a restart: [`ExecutionClient::account_snapshot`] and
70//! [`ExecutionClient::fetch_open_orders`] list them under it and track them again, and
71//! [`OrderStatusClient`] finds how they ended.
72//!
73//! # Caller Responsibilities
74//!
75//! 1. **Fill reconciliation**: when fill recovery fails repeatedly, the stream reports the
76//! gap as `AccountEventKind::FillRecoveryGaveUp` and stays open. Read the gap's fills
77//! with [`ExecutionClient::fetch_trades`].
78//! 2. **Permanent disconnect**: when `ibapi` gives up reconnecting,
79//! [`IbkrClient::disconnect`] is called, or TWS/Gateway ends the API session,
80//! `account_stream` ends with `StreamTerminated`, and `account_stream` on the
81//! shut-down client fails, so replace the client, by reconnecting with
82//! [`IbkrClient::connect_sync`] and choosing the client ID: the caller's decision. The old
83//! client's ID stays in use until every clone of it is dropped (see
84//! [`IbkrClient::disconnect`]). A new
85//! `IbkrClient` tracks the orders the old one placed once an account snapshot or
86//! `fetch_open_orders` lists them; until then their events are dropped.
87//! 3. **Stale state cleanup**: Periodically call [`IbkrClient::clear_stale_executions`],
88//! [`IbkrClient::clear_stale_order_ids`], and [`IbkrClient::clear_stale_pending_cancels`]
89//!
90//! **Rationale**: IBKR uses TCP to local TWS/Gateway, not cloud WebSocket. `ibapi`
91//! owns the transient reconnect. Replacing a client that is gone for good requires IB
92//! Gateway availability and client ID coordination, decisions that belong in the
93//! caller's wrapper, not the library.
94//!
95//! # See Also
96//!
97//! - [IB API Documentation](https://www.interactivebrokers.com/campus/ibkr-api-page/trader-workstation-api/)
98//! - `rustrade_data::exchange::ibkr` for market data
99
100pub mod account;
101mod connect;
102pub mod contract;
103mod ended_orders;
104pub mod execution;
105pub mod order;
106mod recovery;
107
108use crate::{
109 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, Snapshot, UnindexedAccountEvent,
110 UnindexedAccountSnapshot,
111 balance::AssetBalance,
112 client::{
113 BracketOrderClient, ClientInstrument, ExecutionClient, OrderStatusClient,
114 dedup::new_dedup_cache,
115 order_recovery::{KnownLiveOrders, SharedKnownLiveOrders},
116 },
117 error::{
118 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
119 },
120 order::{
121 Order, OrderKey, OrderKind, TimeInForce, UnindexedOrderSnapshot,
122 bracket::{
123 BracketOrderRequest as UnifiedBracketOrderRequest,
124 BracketOrderResult as UnifiedBracketOrderResult,
125 },
126 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
127 request::{
128 OrderRequestCancel, OrderRequestOpen, OrderResponseCancel, UnindexedOrderResponseCancel,
129 },
130 state::{Cancelled, Expired, Filled, Open, OrderState, UnindexedOrderState},
131 },
132 position::PositionReport,
133 trade::{Trade, TradesRead},
134};
135use account::{BalanceAggregator, PositionAggregator};
136use chrono::{DateTime, Utc};
137pub use connect::{ConnectOutcome, ContractSkipReason, IbkrConnectError, SkippedContract};
138use contract::ResolveContractError;
139use execution::{ExecutionBuffer, ExecutionRevision, parse_decimal_or_warn, try_decimal_or_warn};
140use futures::stream::BoxStream;
141use ibapi::{
142 accounts::{
143 AccountSummaryResult,
144 types::{AccountGroup, AccountId},
145 },
146 client::blocking::Client,
147};
148pub use order::{BracketOrderRequest, BracketOrderResult};
149use order::{
150 OrderContext, OrderIdMap, PendingCancels, build_ib_bracket_with_oca, build_ib_order,
151 side_to_action, time_in_force_to_ib,
152};
153use parking_lot::Mutex;
154use rust_decimal::Decimal;
155use rustrade_instrument::{
156 Side,
157 asset::name::AssetNameExchange,
158 exchange::ExchangeId,
159 ibkr::{ContractRegistry, ContractRegistryError},
160 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
161};
162use serde::{Deserialize, Serialize};
163use smol_str::format_smolstr;
164use std::{
165 collections::HashSet,
166 ops::ControlFlow,
167 panic::{AssertUnwindSafe, catch_unwind},
168 sync::Arc,
169};
170use thiserror::Error;
171use tokio::sync::mpsc;
172use tracing::{debug, error, info, trace, warn};
173
174/// Configuration for the IBKR execution client.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct IbkrConfig {
177 /// TWS/Gateway host (e.g., "127.0.0.1")
178 pub host: String,
179 /// TWS/Gateway port (7496=TWS live, 7497=TWS paper, 4001=GW live, 4002=GW paper)
180 pub port: u16,
181 /// Client ID (must be unique per connection)
182 pub client_id: i32,
183 /// Account ID (e.g., "DU123456" for paper).
184 ///
185 /// Currently unused — balance/position queries use "All" group.
186 /// Reserved for future multi-account routing (advisor accounts).
187 pub account: String,
188 /// Contracts to resolve and register on connect. [`IbkrClient::connect_sync`] fails if any
189 /// of them cannot be registered; [`IbkrClient::connect_sync_lenient`] connects without it
190 /// and reports it.
191 #[serde(default)]
192 pub contracts: Vec<ContractConfig>,
193}
194
195/// Pre-configured contract for startup registration.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ContractConfig {
198 pub name: String,
199 pub symbol: String,
200 pub security_type: String,
201 pub exchange: String,
202 pub currency: String,
203 #[serde(default)]
204 pub last_trade_date: Option<String>,
205 /// Strike price for options. Uses f64 because `ibapi::Contract.strike` is f64.
206 #[serde(default)]
207 pub strike: Option<f64>,
208 #[serde(default)]
209 pub right: Option<String>,
210}
211
212impl ContractConfig {
213 /// Convert this config into an [`ibapi::contracts::Contract`].
214 ///
215 /// # Errors
216 ///
217 /// Returns a [`ContractConfigError`](contract::ContractConfigError) instead of
218 /// silently fabricating a wrong contract when the config is incomplete or
219 /// unsupported:
220 /// - [`UnrecognizedSecurityType`](contract::ContractConfigError::UnrecognizedSecurityType)
221 /// — `security_type` is not one of `STK`/`FUT`/`OPT`/`CASH`.
222 /// - [`MissingLastTradeDate`](contract::ContractConfigError::MissingLastTradeDate)
223 /// — `last_trade_date` is absent on a `FUT` or `OPT` contract.
224 /// - [`MissingStrike`](contract::ContractConfigError::MissingStrike) — `strike`
225 /// is absent on an `OPT` contract.
226 /// - [`MissingOptionRight`](contract::ContractConfigError::MissingOptionRight) —
227 /// `right` is absent on an `OPT` contract.
228 /// - [`UnrecognizedOptionRight`](contract::ContractConfigError::UnrecognizedOptionRight)
229 /// — `right` is present but not one of `C`/`CALL`/`P`/`PUT` (case-insensitive).
230 ///
231 /// A security type added here must also be added to `security_types_of`, or every entry naming
232 /// it fails `validate_config`. Nothing enforces that: add the type to
233 /// `security_types_cover_every_type_to_contract_builds` too.
234 fn to_contract(&self) -> Result<ibapi::contracts::Contract, contract::ContractConfigError> {
235 use contract::ContractConfigError as E;
236 Ok(match self.security_type.as_str() {
237 "STK" => contract::stock_contract(&self.symbol, &self.exchange, &self.currency),
238 "FUT" => contract::futures_contract(
239 &self.symbol,
240 self.last_trade_date
241 .as_deref()
242 .ok_or(E::MissingLastTradeDate)?,
243 &self.exchange,
244 &self.currency,
245 ),
246 "OPT" => contract::option_contract(
247 &self.symbol,
248 self.last_trade_date
249 .as_deref()
250 .ok_or(E::MissingLastTradeDate)?,
251 self.strike.ok_or(E::MissingStrike)?,
252 self.right.as_deref().ok_or(E::MissingOptionRight)?,
253 &self.exchange,
254 &self.currency,
255 )?,
256 "CASH" => contract::forex_contract(&self.symbol, &self.currency),
257 other => {
258 return Err(E::UnrecognizedSecurityType {
259 security_type: other.to_string(),
260 });
261 }
262 })
263 }
264}
265
266/// The `ContractConfig::security_type`s that describe an instrument of `kind`: the inverse of the
267/// mapping `SUPPORTED_KINDS` states. Empty for a kind this client cannot trade, which
268/// `SUPPORTED_KINDS` rejects before any config is checked.
269fn security_types_of(kind: InstrumentKindDiscriminant) -> &'static [&'static str] {
270 match kind {
271 InstrumentKindDiscriminant::Spot => &["STK", "CASH"],
272 InstrumentKindDiscriminant::Future => &["FUT"],
273 InstrumentKindDiscriminant::Option => &["OPT"],
274 InstrumentKindDiscriminant::Perpetual | InstrumentKindDiscriminant::Cfd => &[],
275 }
276}
277
278/// Account group for "All" accounts (used by reqAccountSummary).
279static ACCOUNT_GROUP_ALL: std::sync::LazyLock<AccountGroup> =
280 std::sync::LazyLock::new(|| AccountGroup("All".to_string()));
281
282/// How long `account_snapshot` waits for IB's next report of an account's positions before
283/// giving up on that account's listing.
284///
285/// Each account's read ends at IB's end-of-listing marker. This only bounds a stall before it.
286const POSITION_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
287
288/// Maximum time to await an initial `OrderStatus` on an order-placement
289/// subscription before giving up. Bounds the placement loops so a silent TWS
290/// (e.g. a half-open socket) cannot hang them indefinitely. A timeout yields
291/// [`PlacementOutcome::NoStatus`]; the order may still have been submitted, so
292/// callers resolve the final state via the order-update/account stream.
293const PLACEMENT_STATUS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
294
295/// IBKR "Order Message" codes that are informational rather than rejections.
296///
297/// ibapi classifies the `200..=399` range as order rejections
298/// (`ORDER_REJECTION_CODE_RANGE`) — bar 202 and, since 4.1.0, 317, both of
299/// which are resolved ahead of the range — but IBKR uses code 399 as a generic order
300/// message — e.g. *"Your order will not be placed at the exchange until
301/// 09:30:00 US/Eastern"* — for an order that is in fact **accepted and held**
302/// (it proceeds to `PreSubmitted`). We therefore report the order as
303/// live-but-pending ([`PlacementOutcome::HeldPending`]) rather than rejected
304/// when one of these codes arrives.
305///
306/// # ibapi 4.0 splits code 399 by message text
307///
308/// `is_warning_message` now classifies a 399 whose text carries a `Warning:`
309/// line as a warning, and `classify_error` routes a warning owned by a
310/// request-bound subscription as a non-terminal `RoutedItem::Notice` rather
311/// than a stream-ending `RoutedItem::Error`. `timeout_iter_data` drops notices,
312/// so the two forms now reach [`await_order_placement`] differently:
313///
314/// - **399 carrying a `Warning:` line** — dropped, and the subscription stays
315/// open. The eventual `OrderStatus(PreSubmitted)` therefore arrives on the
316/// placement subscription itself and yields
317/// [`PlacementOutcome::Accepted`] without this list being consulted. Under
318/// ibapi 3.x that status was reachable only via the order-update/account
319/// stream, because the notice had already closed the subscription.
320/// - **399 without one** — still `Err(Error::Notice)`, still closes the
321/// subscription, and is still matched against this list.
322///
323/// Re-verified against ibapi 4.1.0, which reworked this classification: the
324/// warning band widened to `2100..=2199`, `classify_error`'s predicate became
325/// `is_informational_code`, and 317 joined the data advisories. The change is
326/// strictly additive and 399 is in neither the advisory nor the system list, so
327/// both forms above route exactly as described. Re-verified against ibapi 5.0.0, which
328/// routes an error frame by whether its id is an order's or a request's, and moves 316, 354
329/// and 366 out of the order-rejection category: 399 routes to its order as before, and the
330/// warning predicate is unchanged.
331///
332/// The list stays load-bearing for the second form. It documents the known gap
333/// between ibapi's range heuristic and IBKR's actual protocol semantics; if
334/// IBKR adds further informational codes in the 200-399 range, an out-of-RTH
335/// placement test will surface them and they can be added here.
336const INFORMATIONAL_ORDER_CODES: &[i32] = &[399];
337
338/// Outcome of awaiting the initial status on an order-placement subscription.
339///
340/// The authoritative acceptance/rejection signal in the IBKR protocol is the
341/// `OrderStatus` event, not an error notice — see [`await_order_placement`].
342///
343/// Every variant carries a distinct order-placement outcome that callers must
344/// dispatch on; dropping a value would silently discard the order's status.
345#[must_use]
346#[derive(Debug)]
347enum PlacementOutcome {
348 /// The order was acknowledged by IB and its order-id mapping must be
349 /// retained so the account stream can resolve its terminal/fill state. This
350 /// covers the working statuses (`Submitted`/`PreSubmitted`/`PendingSubmit`),
351 /// an immediate `Filled`, and the transitional cancel states
352 /// (`ApiCancelled`/`PendingCancel`) that precede a confirmed terminal status.
353 /// Carries the filled quantity reported with the status.
354 Accepted { filled: f64 },
355 /// A terminal rejection: a `Cancelled`/`Inactive` `OrderStatus`, a genuine
356 /// non-informational TWS notice, or an ibapi error other than transport
357 /// loss (see [`is_transport_loss`]). Carries the human-readable reason.
358 Rejected(String),
359 /// The order exists, but the placement subscription could not classify its
360 /// state: either an informational notice (see
361 /// [`INFORMATIONAL_ORDER_CODES`]) closed the subscription, or TWS reported
362 /// a status ibapi does not model (`OrderStatusKind::Unknown`). Either way
363 /// the order is live/held and its authoritative status will arrive via the
364 /// order-update/account stream. Carries the notice or status text.
365 HeldPending(String),
366 /// The subscription ended — or [`PLACEMENT_STATUS_TIMEOUT`] elapsed, or the
367 /// transport was lost — without any terminal status or informational
368 /// notice. The order may or may not have been accepted; resolve via the
369 /// order-update/account stream.
370 NoStatus,
371}
372
373/// Whether `e` is a transport loss `ibapi` recovers from by reconnecting by itself, so that the
374/// same request may succeed once it has: a [`ConnectivityError`].
375///
376/// Narrower than [`is_transport_loss`]: `ibapi` returns `ConnectionFailed` only once it has given
377/// up reconnecting, and `Shutdown` once the client is shut down, so retrying cannot help.
378fn is_transient_transport_loss(e: &ibapi::Error) -> bool {
379 matches!(e, ibapi::Error::ConnectionReset | ibapi::Error::Io(_))
380}
381
382/// Whether `e` means the TWS transport was lost, as opposed to TWS or ibapi
383/// answering the request.
384///
385/// Since ibapi 4.2 the sync transport fails in-flight requests with one of these
386/// the moment the socket drops, and refuses new sends until the reconnect has
387/// completed. None of them says what TWS did with an order already written.
388fn is_transport_loss(e: &ibapi::Error) -> bool {
389 matches!(
390 e,
391 ibapi::Error::ConnectionReset
392 | ibapi::Error::ConnectionFailed
393 | ibapi::Error::Shutdown
394 | ibapi::Error::Io(_)
395 )
396}
397
398/// The instrument `execution` is in, or `None` when another API client placed its order, or this
399/// client does not track its order or its contract.
400///
401/// IB numbers orders per API client, so an order id names this client's order only together with
402/// this client's id.
403fn resolve_execution(
404 execution: &ibapi::orders::ExecutionData,
405 api_client_id: i32,
406 contracts: &ContractRegistry,
407 order_ids: &OrderIdMap,
408) -> Option<InstrumentNameExchange> {
409 let order_id = execution.execution.order_id;
410 let con_id = execution.contract.contract_id;
411
412 if execution.execution.client_id != api_client_id {
413 trace!(
414 ib_order_id = order_id,
415 api_client_id = execution.execution.client_id,
416 "ExecutionData for another API client's order, dropping"
417 );
418 return None;
419 }
420 // Fail-fast: skip second lookup if first fails
421 if !order_ids.contains(order_id) {
422 debug!(
423 ib_order_id = order_id,
424 con_id, "ExecutionData for unknown order ID, dropping"
425 );
426 return None;
427 }
428 let Some(instrument) = contracts.get_name_by_con_id(con_id) else {
429 debug!(
430 ib_order_id = order_id,
431 con_id, "ExecutionData for unknown contract ID, dropping"
432 );
433 return None;
434 };
435 Some(instrument)
436}
437
438/// How long a listing read waits for IB's next message before giving up on the read.
439///
440/// IB sends a listing in one burst and then marks its end, so a pause this long means TWS has
441/// stalled, not that the listing is long.
442const LISTING_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
443
444/// Read one IB listing, passing each item to `on_item`, until IB marks the end of the listing or
445/// `on_item` breaks.
446///
447/// `next` is the subscription's `next_timeout`. That answers `None` at the end marker, when its
448/// wait runs out, and when `ibapi` drops the subscription's channel. Only the first and last
449/// return early (see [`recovery::returned_early`]), so an early `None` is the end of the listing
450/// only while `connected` still says the client is connected.
451///
452/// # Errors
453/// [`UnindexedClientError::Internal`], naming `what`, when IB answers with an error, sends
454/// nothing for `stall_timeout` ([`LISTING_STALL_TIMEOUT`] outside tests) before the end of the
455/// listing, or the connection is gone when the listing stops. Either way what was read is not the
456/// whole listing, and must not be mistaken for it.
457///
458/// # Known limitation
459/// A connection that drops and is restored between the last item and that check reads as the end
460/// of the listing. `ibapi` fails a request in flight when its socket drops, which surfaces here as
461/// an error first, so this needs the drop to lose that error too.
462fn read_listing<T>(
463 what: &str,
464 stall_timeout: std::time::Duration,
465 connected: impl Fn() -> bool,
466 next: impl FnMut(
467 std::time::Duration,
468 ) -> Option<Result<ibapi::subscriptions::SubscriptionItem<T>, ibapi::Error>>,
469 on_item: impl FnMut(T) -> ControlFlow<()>,
470) -> Result<(), UnindexedClientError> {
471 read_listing_items(what, stall_timeout, connected, next, on_item)
472 .map_err(|e| UnindexedClientError::Internal(format!("{what}: {e}")))
473}
474
475/// Why a listing read ended short of IB's end-of-listing marker: see [`read_listing`].
476#[derive(Debug, Error)]
477enum ListingError {
478 /// IB answered the request with an error.
479 #[error(transparent)]
480 Ibapi(ibapi::Error),
481 /// The connection was gone when the listing stopped.
482 #[error("the connection dropped before the end of the listing")]
483 Dropped,
484 /// IB sent nothing for this long before the end of the listing.
485 #[error("IB sent nothing for {0:?} before the end of the listing")]
486 Stalled(std::time::Duration),
487}
488
489/// [`read_listing`], keeping why the read failed for a caller that tells IB's errors apart.
490/// `what` only names the listing in the notices logged during the read.
491fn read_listing_items<T>(
492 what: &str,
493 stall_timeout: std::time::Duration,
494 connected: impl Fn() -> bool,
495 mut next: impl FnMut(
496 std::time::Duration,
497 ) -> Option<Result<ibapi::subscriptions::SubscriptionItem<T>, ibapi::Error>>,
498 mut on_item: impl FnMut(T) -> ControlFlow<()>,
499) -> Result<(), ListingError> {
500 use ibapi::subscriptions::SubscriptionItem;
501
502 loop {
503 let waited = std::time::Instant::now();
504 match next(stall_timeout) {
505 Some(Ok(SubscriptionItem::Data(item))) => {
506 if on_item(item).is_break() {
507 return Ok(());
508 }
509 }
510 Some(Ok(SubscriptionItem::Notice(notice))) => {
511 debug!(%notice, what, "Notice during an IBKR listing read");
512 }
513 Some(Err(e)) => return Err(ListingError::Ibapi(e)),
514 None if recovery::returned_early(waited.elapsed(), stall_timeout) => {
515 if !connected() {
516 return Err(ListingError::Dropped);
517 }
518 return Ok(());
519 }
520 None => return Err(ListingError::Stalled(stall_timeout)),
521 }
522 }
523}
524
525/// IB's error code for a contract description that matches no contract ("No security definition
526/// has been found for the request").
527const NO_SECURITY_DEFINITION_CODE: i32 = 200;
528
529/// Resolve `contract` through IB's contract details: see [`IbkrClient::resolve_contract`].
530fn resolve_contract_blocking(
531 client: &Client,
532 contract: &ibapi::contracts::Contract,
533) -> Result<ibapi::contracts::Contract, ResolveContractError> {
534 let subscription = client
535 .contract_details_stream(contract)
536 .subscribe()
537 .map_err(contract_details_error)?;
538 let mut matches = Vec::new();
539 let read = read_listing_items(
540 "contract_details",
541 LISTING_STALL_TIMEOUT,
542 || client.is_connected(),
543 |timeout| subscription.next_timeout(timeout),
544 |details: ibapi::contracts::ContractDetails| {
545 matches.push(details.contract);
546 ControlFlow::Continue(())
547 },
548 );
549 classify_contract_details(read, matches)
550}
551
552/// The resolved contract from a contract-details read: its one match, or why there is not one.
553fn classify_contract_details(
554 read: Result<(), ListingError>,
555 mut matches: Vec<ibapi::contracts::Contract>,
556) -> Result<ibapi::contracts::Contract, ResolveContractError> {
557 match read {
558 Ok(()) => {}
559 Err(ListingError::Ibapi(e)) => return Err(contract_details_error(e)),
560 Err(e @ ListingError::Dropped) => {
561 return Err(ConnectivityError::Socket(e.to_string()).into());
562 }
563 Err(ListingError::Stalled(_)) => return Err(ConnectivityError::Timeout.into()),
564 }
565 if matches.len() > 1 {
566 return Err(ResolveContractError::Ambiguous { matches });
567 }
568 matches.pop().ok_or(ResolveContractError::NoMatch)
569}
570
571/// What an `ibapi` error answering a contract-details request means for the resolution.
572fn contract_details_error(e: ibapi::Error) -> ResolveContractError {
573 match e {
574 // IB answers a description that matches nothing with this error, not an empty listing.
575 ibapi::Error::Notice(notice) if notice.code == NO_SECURITY_DEFINITION_CODE => {
576 ResolveContractError::NoMatch
577 }
578 ibapi::Error::Notice(notice) => ResolveContractError::Refused {
579 code: notice.code,
580 message: notice.message,
581 },
582 e if is_transient_transport_loss(&e) => ConnectivityError::Socket(e.to_string()).into(),
583 e => ResolveContractError::Failed(e.to_string()),
584 }
585}
586
587/// Serializes the reads of IB's order listings.
588///
589/// IB answers an open-orders or completed-orders request without naming the request, so `ibapi`
590/// copies each answer to every such request of the kind in flight. Two reads at once would each
591/// see the other's orders, and one could stop at the other's end marker, short of its own
592/// listing. Each read holds this for its whole length.
593#[derive(Debug, Clone, Default)]
594struct ListingLock(Arc<Mutex<()>>);
595
596impl ListingLock {
597 /// Read IB's listing of every API client's open orders, each order followed by its status.
598 ///
599 /// # Errors
600 /// As [`read_listing`].
601 fn open_orders(
602 &self,
603 client: &Client,
604 ) -> Result<Vec<ibapi::orders::Orders>, UnindexedClientError> {
605 let _serial = self.0.lock();
606 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
607 let subscription = client
608 .all_open_orders()
609 .map_err(|e| UnindexedClientError::Internal(format!("open_orders: {e}")))?;
610 let mut listing = Vec::new();
611 read_listing(
612 "open_orders",
613 LISTING_STALL_TIMEOUT,
614 || client.is_connected(),
615 |timeout| subscription.next_timeout(timeout),
616 |item| {
617 listing.push(item);
618 ControlFlow::Continue(())
619 },
620 )?;
621 Ok(listing)
622 }
623
624 /// Read IB's listing of the account's completed orders: every API client's, and those
625 /// entered in TWS. Each item is handed to `on_item` as it arrives, since the listing can be
626 /// long and only a few of its orders are wanted.
627 ///
628 /// # Errors
629 /// As [`read_listing`].
630 fn completed_orders(
631 &self,
632 client: &Client,
633 mut on_item: impl FnMut(ibapi::orders::Orders),
634 ) -> Result<(), UnindexedClientError> {
635 let _serial = self.0.lock();
636 let subscription = client
637 .completed_orders(false)
638 .map_err(|e| UnindexedClientError::Internal(format!("completed_orders: {e}")))?;
639 read_listing(
640 "completed_orders",
641 LISTING_STALL_TIMEOUT,
642 || client.is_connected(),
643 |timeout| subscription.next_timeout(timeout),
644 |item| {
645 on_item(item);
646 ControlFlow::Continue(())
647 },
648 )
649 }
650}
651
652/// Read one account-summary listing into balances.
653///
654/// Errors and stalls are surfaced rather than a partial balance set returned (see
655/// [`read_listing`]). The subscription stays live after the listing, so its end is the `End` item,
656/// and a subscription that stops before it fails the read.
657fn read_account_summary(
658 stall_timeout: std::time::Duration,
659 connected: impl Fn() -> bool,
660 next: impl FnMut(
661 std::time::Duration,
662 ) -> Option<
663 Result<ibapi::subscriptions::SubscriptionItem<AccountSummaryResult>, ibapi::Error>,
664 >,
665) -> Result<BalanceAggregator, UnindexedClientError> {
666 let mut aggregator = BalanceAggregator::new();
667 let mut ended = false;
668 read_listing(
669 "account_summary",
670 stall_timeout,
671 connected,
672 next,
673 |summary| match summary {
674 AccountSummaryResult::Summary(s) => {
675 aggregator.process(&s);
676 ControlFlow::Continue(())
677 }
678 AccountSummaryResult::End => {
679 ended = true;
680 ControlFlow::Break(())
681 }
682 },
683 )?;
684 if !ended {
685 return Err(UnindexedClientError::Internal(
686 "account_summary: the subscription ended before the end of the listing".to_string(),
687 ));
688 }
689 Ok(aggregator)
690}
691
692/// Read one account's positions into `positions`, returning whether IB marked the end of the
693/// listing before [`POSITION_STALL_TIMEOUT`] passed without a report.
694///
695/// Positions-multi rather than `positions()`: its replies carry this request's ID, while
696/// `positions()` replies carry none and reach every open positions subscription, so another read's
697/// listing, end marker and all, could end this one early. IB documents the account parameter as
698/// optional only for a login with a single account, so it is always given.
699///
700/// Only reports for a registered instrument, and in `instruments` when that is given, are kept.
701fn read_account_positions(
702 client: &Client,
703 account: AccountId,
704 contracts: &ContractRegistry,
705 instruments: Option<&HashSet<InstrumentNameExchange>>,
706 positions: &mut PositionAggregator,
707) -> Result<bool, UnindexedClientError> {
708 use ibapi::accounts::{Position, PositionUpdateMulti};
709
710 let subscription = client
711 .positions_multi(Some(&account), None)
712 .map_err(|e| UnindexedClientError::Internal(format!("positions of {account}: {e}")))?;
713
714 for update in subscription.timeout_iter_data(POSITION_STALL_TIMEOUT) {
715 // Surface subscription errors rather than returning partial positions
716 // (a truncated snapshot could be misread as positions having closed).
717 let update = update.map_err(|e| {
718 UnindexedClientError::Internal(format!("positions subscription of {account}: {e}"))
719 })?;
720 let position = match update {
721 PositionUpdateMulti::Position(position) => Position {
722 account: position.account,
723 contract: position.contract,
724 position: position.position,
725 average_cost: position.average_cost,
726 },
727 // The subscription stays live after the listing, streaming changes; this read wants
728 // the listing alone. Dropping the subscription cancels it.
729 PositionUpdateMulti::PositionEnd => return Ok(true),
730 };
731 let Some(instrument) = contracts.get_name_by_con_id(position.contract.contract_id) else {
732 continue;
733 };
734 if instruments.is_some_and(|filter| !filter.contains(&instrument)) {
735 continue;
736 }
737 positions.process(instrument, position);
738 }
739 Ok(false)
740}
741
742/// Forward `updates`, from `ibapi`'s order-update subscription, to `sink` as account events,
743/// until the subscription ends or the account stream does.
744///
745/// A closed stream is noticed on the next update, whether or not that update would be
746/// forwarded, so the thread running this releases `ibapi`'s single order-update slot as soon as
747/// TWS sends anything.
748///
749/// `ibapi` ends the subscription with [`ibapi::Error::Shutdown`] when the client shuts down for
750/// good, and the stream is terminated with [`recovery::CLIENT_SHUT_DOWN`].
751fn forward_order_updates(
752 updates: impl IntoIterator<Item = Result<ibapi::orders::OrderUpdate, ibapi::Error>>,
753 sink: &recovery::EventSink,
754 api_client_id: i32,
755 contracts: &ContractRegistry,
756 order_ids: &OrderIdMap,
757 pending_cancels: &PendingCancels,
758 exec_buffer: &ExecutionBuffer,
759) {
760 use ibapi::orders::{OrderStatusKind, OrderUpdate};
761
762 for update in updates {
763 // Recovery ended the stream, or the consumer dropped it. Stop on this update rather than
764 // the next one that would be forwarded, which may never come.
765 if !sink.is_open() {
766 return;
767 }
768 let update = match update {
769 Ok(u) => u,
770 Err(ibapi::Error::Shutdown) => {
771 warn!("IBKR client shut down; terminating the account stream");
772 sink.terminate(StreamTerminationReason::Error(
773 recovery::CLIENT_SHUT_DOWN.to_string(),
774 ));
775 return;
776 }
777 Err(e) => {
778 // ibapi's own reconnect does not surface here (see the
779 // recovery module), so an error on the subscription is terminal.
780 // Surface it in-band as StreamTerminated(Error) so the caller
781 // gets a programmatic signal rather than inferring EOF.
782 // (best-effort — a no-op if the consumer already dropped rx.)
783 error!(error = %e, "Order stream subscription error");
784 sink.terminate(StreamTerminationReason::Error(e.to_string()));
785 return;
786 }
787 };
788 let event = match update {
789 // IB numbers orders per API client, and `ibapi` copies here every order status an
790 // open-orders listing carries, other clients' included.
791 OrderUpdate::OrderStatus(status) if status.client_id != api_client_id => {
792 trace!(
793 ib_order_id = status.order_id,
794 api_client_id = status.client_id,
795 "OrderStatus for another API client's order, dropping"
796 );
797 None
798 }
799 OrderUpdate::OrderStatus(status) => {
800 let ib_id = status.order_id;
801 // Single-lock methods for the terminal statuses, to avoid read+write. Each ends
802 // the order, freeing its client id. Only `Cancelled`/`Inactive` remove its entry:
803 // a `Filled` order's is retained so late-arriving ExecutionData/CommissionReport
804 // events still resolve it (reaped later by `OrderIdMap::clear_stale`).
805 let lookup_result = match status.status {
806 OrderStatusKind::Cancelled | OrderStatusKind::Inactive => {
807 order_ids.remove_and_get_context(ib_id)
808 }
809 OrderStatusKind::Filled => order_ids.release_client_id(ib_id),
810 _ => order_ids.get_client_id_and_context(ib_id),
811 };
812
813 match lookup_result {
814 // An order that already ended, whose id now names a later order: IB re-sent
815 // a status for it. Forwarded under the id, it would be read as the later
816 // order's, and a re-sent `Filled` would end that order.
817 Some((client_id, _)) if order_ids.names_other_order(&client_id, ib_id) => {
818 debug!(
819 ib_order_id = ib_id,
820 cid = %client_id,
821 "OrderStatus for an ended order whose client order id names a later order, dropping"
822 );
823 None
824 }
825 Some((client_id, ctx)) => {
826 let order =
827 make_order_from_status(&status, client_id, &ctx, pending_cancels);
828 Some(UnindexedAccountEvent {
829 exchange: ExchangeId::Ibkr,
830 kind: AccountEventKind::OrderSnapshot(Snapshot::new(order)),
831 })
832 }
833 None => {
834 debug!(ib_order_id = ib_id, "OrderStatus for unknown order ID");
835 None
836 }
837 }
838 }
839 OrderUpdate::ExecutionData(exec) => {
840 // ibapi copies an executions request's answers here too; they
841 // are not fills happening now, and recovery emits its own.
842 if recovery::is_replayed_execution(&exec) {
843 trace!(
844 exec_id = %exec.execution.execution_id,
845 request_id = exec.request_id,
846 "ExecutionData answering an executions request, skipping"
847 );
848 continue;
849 }
850 let Some(instrument) =
851 resolve_execution(&exec, api_client_id, contracts, order_ids)
852 else {
853 continue;
854 };
855 // Logged on arrival, so the correction is seen even if no commission report
856 // ever completes it.
857 if ExecutionRevision::parse(&exec.execution.execution_id)
858 .is_some_and(|revision| revision.is_correction())
859 {
860 warn!(
861 exec_id = %exec.execution.execution_id,
862 %instrument,
863 price = exec.execution.price,
864 shares = exec.execution.shares,
865 "IBKR corrected an execution; it will be reported as TradeAmended if its \
866 commission report arrives"
867 );
868 }
869
870 exec_buffer.add_execution(exec, instrument);
871 None
872 }
873 OrderUpdate::CommissionReport(report) => {
874 if let Some(trade) = exec_buffer.complete_with_commission(&report)
875 && !sink.send_execution(trade)
876 {
877 return;
878 }
879 None
880 }
881 _ => None,
882 };
883
884 if let Some(e) = event
885 && !sink.send(e)
886 {
887 // The consumer dropped rx, or recovery ended the stream: either
888 // way nothing more may be sent.
889 return;
890 }
891 }
892
893 // The subscription iterator ended without an error (e.g. clean
894 // disconnect/unsubscribe, or ibapi giving up reconnecting). Still a
895 // terminal stream death — surface it in-band so the consumer doesn't have
896 // to infer it from channel EOF.
897 sink.terminate(StreamTerminationReason::Error(
898 "IBKR order-update stream ended".to_string(),
899 ));
900}
901
902/// The message a worker thread panicked with.
903fn panic_message(panic_info: &(dyn std::any::Any + Send)) -> String {
904 panic_info
905 .downcast_ref::<&str>()
906 .map(|s| s.to_string())
907 .or_else(|| panic_info.downcast_ref::<String>().cloned())
908 .unwrap_or_else(|| "unknown panic".to_string())
909}
910
911/// Classify the error from a send (`place_order` / `cancel_order`) that failed
912/// before TWS acknowledged anything, carrying `message` as the reason.
913///
914/// A dropped or reconnecting transport (`ConnectionReset`, `Io`) is a transient
915/// [`OrderError::Connectivity`]: the request did not reach TWS and may be retried
916/// once the connection is back. `Shutdown` and `ConnectionFailed` mean ibapi has
917/// given up reconnecting, so they are non-transient, like any other refusal
918/// ([`ApiError::OrderRejected`]).
919///
920/// # Known limitation
921///
922/// ibapi refuses a send with `ConnectionReset` whenever the session is not
923/// connected, including after it has shut down for good, so a send refused on a
924/// dead client still reads as transient here. The client's streams ending is
925/// the signal that it will not come back.
926fn send_error<AssetKey, InstrumentKey>(
927 e: &ibapi::Error,
928 message: String,
929) -> OrderError<AssetKey, InstrumentKey> {
930 if is_transient_transport_loss(e) {
931 OrderError::Connectivity(ConnectivityError::Socket(message))
932 } else {
933 OrderError::Rejected(ApiError::OrderRejected(message))
934 }
935}
936
937/// Drive an order-placement subscription to its initial [`PlacementOutcome`].
938///
939/// Shared by the single-order and bracket-leg placement paths. Consumes events
940/// from a `place_order` subscription (typically `sub.timeout_iter_data(..)`)
941/// until a decisive outcome is reached.
942///
943/// # Why notices are not blanket rejections
944///
945/// ibapi delivers a TWS error frame that it does not classify as a warning as
946/// `Err(Error::Notice)`, and then closes the subscription. IB emits
947/// informational order messages (e.g. code 399, order held until RTH) the same
948/// way, so treating every `Err` as a rejection would falsely reject orders that
949/// are actually live. We instead key off the notice code: known informational
950/// codes yield [`PlacementOutcome::HeldPending`]; transport loss yields
951/// [`PlacementOutcome::NoStatus`], because the order may already be live; all
952/// other notices and errors yield [`PlacementOutcome::Rejected`]. The `OrderStatus`
953/// event — when one is delivered before the closing notice — remains
954/// authoritative.
955///
956/// ibapi 4.0 widened that warning classification beyond the `2100..=2169` range
957/// to include code 0 and the `Warning:` form of code 399, and now routes a
958/// warning owned by a request-bound subscription as a non-terminal notice that
959/// `timeout_iter_data` drops rather than an `Err` that ends the stream. See
960/// [`INFORMATIONAL_ORDER_CODES`] for what that changes here.
961fn await_order_placement<I>(events: I) -> PlacementOutcome
962where
963 I: IntoIterator<Item = Result<ibapi::orders::PlaceOrder, ibapi::Error>>,
964{
965 use ibapi::orders::PlaceOrder;
966
967 for event in events {
968 let event = match event {
969 Ok(event) => event,
970 // Informational order message (e.g. 399 held-until-RTH): the order
971 // is accepted; ibapi has closed the subscription, so stop here and
972 // let the update stream carry the real status.
973 Err(ibapi::Error::Notice(n)) if INFORMATIONAL_ORDER_CODES.contains(&n.code) => {
974 return PlacementOutcome::HeldPending(format!("[{}] {}", n.code, n.message));
975 }
976 // The socket dropped before TWS reported a status. ibapi 4.2 fails
977 // in-flight requests at the drop rather than after the reconnect, so
978 // this can arrive after TWS received the order: its fate is unknown,
979 // not rejected.
980 Err(e) if is_transport_loss(&e) => {
981 warn!(error = %e, "transport lost while awaiting order placement; status unknown");
982 return PlacementOutcome::NoStatus;
983 }
984 // Genuine notice (e.g. 201 reject) or another ibapi error.
985 Err(e) => return PlacementOutcome::Rejected(e.to_string()),
986 };
987
988 if let PlaceOrder::OrderStatus(status) = event {
989 use ibapi::orders::OrderStatusKind;
990 match status.status {
991 OrderStatusKind::Submitted
992 | OrderStatusKind::PreSubmitted
993 | OrderStatusKind::PendingSubmit
994 // A marketable order can fill before any working status is
995 // delivered on the placement subscription — ibapi sends
996 // `OrderStatus(Filled)` directly in that case. The order is
997 // live, not rejected; its authoritative terminal/fill state
998 // arrives via the account stream. Report it accepted so the
999 // order-id mapping is retained and the stream's
1000 // executions/commissions are captured (treating it as a
1001 // rejection would remove the mapping and drop those events).
1002 | OrderStatusKind::Filled => {
1003 return PlacementOutcome::Accepted {
1004 filled: status.filled,
1005 };
1006 }
1007 OrderStatusKind::Cancelled | OrderStatusKind::Inactive => {
1008 return PlacementOutcome::Rejected(status.status.to_string());
1009 }
1010 // Transitional cancel states: a cancellation was already requested
1011 // (e.g. a fast cancel racing the placement ack), but the order was
1012 // submitted — we have a placement subscription — and is still live.
1013 // `ApiCancelled` always precedes a confirmed `Cancelled`; both it
1014 // and `PendingCancel` resolve to a terminal status on the account
1015 // stream, which is authoritative. Report accepted so the order-id
1016 // mapping is retained and the stream's terminal/execution/commission
1017 // events are captured (treating these as a rejection would remove
1018 // the mapping and drop those events).
1019 OrderStatusKind::ApiCancelled | OrderStatusKind::PendingCancel => {
1020 return PlacementOutcome::Accepted {
1021 filled: status.filled,
1022 };
1023 }
1024 // Pre-transmission state: ibapi has not yet sent the order to IB
1025 // (e.g. awaiting a security-definition lookup). This is not a
1026 // placement decision — keep waiting for a definitive status. The
1027 // `PLACEMENT_STATUS_TIMEOUT` backstop yields `NoStatus` if none
1028 // arrives.
1029 OrderStatusKind::ApiPending => {}
1030 // TWS reported a status ibapi does not model. The order exists
1031 // — TWS is reporting on it — but neither we nor ibapi can
1032 // classify it: `is_active()` and `is_terminal()` are both false
1033 // for this variant. Report it live-but-indeterminate, which
1034 // retains the order-id mapping so the account stream can
1035 // resolve the real state.
1036 //
1037 // Deliberately NOT falling through to the `PLACEMENT_STATUS_TIMEOUT`
1038 // backstop the way `ApiPending` does: that yields `NoStatus`,
1039 // and on the bracket path `NoStatus` cancels all three legs —
1040 // a destructive response to a status we merely failed to
1041 // recognise.
1042 OrderStatusKind::Unknown(ref raw) => {
1043 warn!(
1044 ib_order_id = status.order_id,
1045 status = raw.as_str(),
1046 "unmodelled IBKR order status during placement; \
1047 treating as live, account stream is authoritative"
1048 );
1049 return PlacementOutcome::HeldPending(format!(
1050 "unmodelled order status: {raw}"
1051 ));
1052 }
1053 }
1054 }
1055 // Non-status events (OpenOrder, ExecutionData, CommissionReport): keep
1056 // waiting for the OrderStatus.
1057 }
1058
1059 PlacementOutcome::NoStatus
1060}
1061
1062/// Interactive Brokers execution client.
1063///
1064/// # Clone Behavior
1065///
1066/// Cloning creates a shallow copy with shared `Arc` references to the underlying
1067/// IB connection, contract registry, order ID map, and execution buffer. All clones
1068/// share the same TWS/Gateway connection and state.
1069#[derive(Clone)]
1070pub struct IbkrClient {
1071 config: Arc<IbkrConfig>,
1072 client: Arc<Client>,
1073 contracts: ContractRegistry,
1074 order_ids: OrderIdMap,
1075 pending_cancels: PendingCancels,
1076 execution_buffer: ExecutionBuffer,
1077 next_order_id: Arc<Mutex<i32>>,
1078 /// The orders this client has seen live and not yet seen end, for the account stream's check
1079 /// of how they ended while it was disconnected.
1080 known_live: SharedKnownLiveOrders,
1081 /// Held across each read of IB's open-order and completed-order listings (see
1082 /// [`ListingLock`]).
1083 listings: ListingLock,
1084 /// The completions taken as an earlier order's under a reused client order id, each warned
1085 /// about once.
1086 earlier_completions: ended_orders::EarlierCompletions,
1087}
1088
1089impl std::fmt::Debug for IbkrClient {
1090 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1091 f.debug_struct("IbkrClient")
1092 .field("config", &self.config)
1093 .field("contracts_count", &self.contracts.len())
1094 .field("pending_orders", &self.order_ids.len())
1095 .finish()
1096 }
1097}
1098
1099impl IbkrClient {
1100 /// Connect to TWS/Gateway and register every contract in [`IbkrConfig::contracts`]
1101 /// (sync, blocking).
1102 ///
1103 /// Each contract is built from its config, resolved as
1104 /// [`resolve_contract`](Self::resolve_contract) does and registered as
1105 /// [`register_contract`](Self::register_contract) does. If any of them fails, the
1106 /// connection is dropped and [`IbkrConnectError::Contracts`] lists every contract that
1107 /// failed, not just the first, so that a client never starts without a contract its caller
1108 /// configured. To connect anyway and get the failures back, use
1109 /// [`connect_sync_lenient`](Self::connect_sync_lenient).
1110 ///
1111 /// The library does not retry. If every contract in [`IbkrConnectError::Contracts`] has a
1112 /// [`reason`](SkippedContract::reason) that [`is_transient`](ContractSkipReason::is_transient),
1113 /// connecting again may succeed. Otherwise fix the config, or connect with
1114 /// [`connect_sync_lenient`](Self::connect_sync_lenient).
1115 ///
1116 /// # Errors
1117 ///
1118 /// - [`IbkrConnectError::Connect`] if TWS/Gateway cannot be reached or refuses the
1119 /// connection.
1120 /// - [`IbkrConnectError::Contracts`] if any configured contract cannot be registered.
1121 pub fn connect_sync(config: IbkrConfig) -> Result<Self, IbkrConnectError> {
1122 let ConnectOutcome { client, skipped } = Self::connect_inner(config)?;
1123 if skipped.is_empty() {
1124 Ok(client)
1125 } else {
1126 // Dropping the only `ibapi` client closes the connection, freeing the client ID.
1127 drop(client);
1128 Err(IbkrConnectError::Contracts(skipped))
1129 }
1130 }
1131
1132 /// Connect to TWS/Gateway as [`connect_sync`](Self::connect_sync) does, but without each
1133 /// configured contract that cannot be registered, returning those in
1134 /// [`ConnectOutcome::skipped`].
1135 ///
1136 /// Orders for a skipped contract's instrument are refused as for any unregistered
1137 /// instrument, so check `skipped`. To retry one, call
1138 /// [`resolve_contract`](Self::resolve_contract) and then
1139 /// [`register_contract`](Self::register_contract) on the connected client.
1140 ///
1141 /// Blocking, like [`connect_sync`](Self::connect_sync): it waits for the connection and for
1142 /// one contract details request per configured contract, each of which can take up to 10
1143 /// seconds if IB stops answering. From async code, call it inside
1144 /// `tokio::task::spawn_blocking`.
1145 ///
1146 /// # Errors
1147 ///
1148 /// Returns an error if TWS/Gateway cannot be reached or refuses the connection.
1149 pub fn connect_sync_lenient(
1150 config: IbkrConfig,
1151 ) -> Result<ConnectOutcome, UnindexedClientError> {
1152 Self::connect_inner(config)
1153 }
1154
1155 /// Connect, then register each configured contract, collecting the ones that fail.
1156 fn connect_inner(config: IbkrConfig) -> Result<ConnectOutcome, UnindexedClientError> {
1157 let url = format!("{}:{}", config.host, config.port);
1158 info!(url = %url, client_id = config.client_id, "Connecting to IB");
1159
1160 let client = Client::connect(&url, config.client_id).map_err(|e| {
1161 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1162 })?;
1163
1164 let next_id = client.next_order_id();
1165
1166 let contracts = ContractRegistry::new();
1167 let skipped = connect::register_configured(&config.contracts, &contracts, |contract| {
1168 resolve_contract_blocking(&client, contract)
1169 });
1170
1171 info!(
1172 contracts = contracts.len(),
1173 skipped = skipped.len(),
1174 next_order_id = next_id,
1175 "Connected to IB"
1176 );
1177
1178 let client = Self {
1179 config: Arc::new(config),
1180 client: Arc::new(client),
1181 contracts,
1182 order_ids: OrderIdMap::new(),
1183 pending_cancels: PendingCancels::new(),
1184 execution_buffer: ExecutionBuffer::new(),
1185 next_order_id: Arc::new(Mutex::new(next_id)),
1186 known_live: KnownLiveOrders::shared(ExchangeId::Ibkr),
1187 listings: ListingLock::default(),
1188 earlier_completions: ended_orders::EarlierCompletions::new(),
1189 };
1190 Ok(ConnectOutcome { client, skipped })
1191 }
1192
1193 /// Get the next order ID and increment the counter.
1194 fn allocate_order_id(&self) -> i32 {
1195 self.allocate_order_id_range(1)
1196 }
1197
1198 /// Allocate a contiguous range of order IDs atomically.
1199 ///
1200 /// Returns the first ID in the range. Caller uses `base`, `base+1`, ..., `base+(count-1)`.
1201 ///
1202 /// This is essential for bracket orders which require consecutive IDs (parent=N,
1203 /// take_profit=N+1, stop_loss=N+2). A single lock acquisition ensures no other
1204 /// concurrent `open_order` call can grab an ID in the middle of the range.
1205 ///
1206 /// # Panics
1207 ///
1208 /// Panics if `count` exceeds `i32::MAX`, or if the resulting range would overflow `i32::MAX`.
1209 #[allow(clippy::expect_used)] // Panic is correct: i32::MAX orders means system is broken
1210 fn allocate_order_id_range(&self, count: u32) -> i32 {
1211 let count_i32: i32 = count.try_into().expect("count exceeds i32::MAX");
1212 let mut id = self.next_order_id.lock();
1213 let base = *id;
1214 *id = id
1215 .checked_add(count_i32)
1216 .expect("order ID overflow: i32::MAX exceeded");
1217 base
1218 }
1219
1220 /// Resolve a contract description through IB's contract details, on this client's
1221 /// connection, into the one IB contract it matches.
1222 ///
1223 /// A contract built locally, such as `stock_contract("AAPL", "SMART", "USD")`, has no IB
1224 /// contract id until it is resolved, and [`register_contract`](Self::register_contract)
1225 /// refuses it. Resolve it here first:
1226 ///
1227 /// ```no_run
1228 /// # async fn example(client: rustrade_execution::client::ibkr::IbkrClient)
1229 /// # -> Result<(), Box<dyn std::error::Error>> {
1230 /// use rustrade_execution::client::ibkr::contract::stock_contract;
1231 ///
1232 /// let aapl = client
1233 /// .resolve_contract(&stock_contract("AAPL", "SMART", "USD"))
1234 /// .await?;
1235 /// client.register_contract("AAPL".into(), aapl)?;
1236 /// # Ok(())
1237 /// # }
1238 /// ```
1239 ///
1240 /// # Errors
1241 ///
1242 /// [`ResolveContractError::is_transient`] tells the failures worth retrying from the rest.
1243 ///
1244 /// - [`ResolveContractError::NoMatch`] when no IB contract matches the description.
1245 /// - [`ResolveContractError::Ambiguous`] when several do. Narrow the description, or pick
1246 /// one of the matches it carries.
1247 /// - [`ResolveContractError::Refused`] when IB answers the request with another error, such
1248 /// as a description it cannot validate. The same request is usually refused again; tell the
1249 /// errors worth retrying apart by IB's `code`.
1250 /// - [`ResolveContractError::Connectivity`] when the connection drops
1251 /// ([`ConnectivityError::Socket`]) or IB sends nothing for 10 s before finishing its answer
1252 /// ([`ConnectivityError::Timeout`]). Transient: retry once `ibapi` has reconnected.
1253 /// - [`ResolveContractError::Failed`] when the request cannot be made or read for any other
1254 /// reason, such as a client that has shut down or given up reconnecting.
1255 pub async fn resolve_contract(
1256 &self,
1257 contract: &ibapi::contracts::Contract,
1258 ) -> Result<ibapi::contracts::Contract, ResolveContractError> {
1259 let client = Arc::clone(&self.client);
1260 let contract = contract.clone();
1261 tokio::task::spawn_blocking(move || resolve_contract_blocking(&client, &contract))
1262 .await
1263 .map_err(|e| ResolveContractError::Failed(format!("task join: {e}")))?
1264 }
1265
1266 /// Register a contract IB has resolved for an instrument, so that orders can be placed on
1267 /// it and everything IB reports for it (fills, order listings, positions) is attributed to
1268 /// `name`.
1269 ///
1270 /// Resolve the contract first with [`resolve_contract`](Self::resolve_contract), or list it
1271 /// in [`IbkrConfig::contracts`] for [`connect_sync`](Self::connect_sync) to resolve.
1272 ///
1273 /// # Errors
1274 ///
1275 /// Leaves the registry unchanged and returns:
1276 /// - [`ContractRegistryError::Unresolved`] when the contract has no IB contract id. IB
1277 /// reports everything about a contract by that id, so without it the orders would fill
1278 /// with no fill ever reported.
1279 /// - [`ContractRegistryError::ContractIdTaken`] when the contract is already registered
1280 /// under another name.
1281 pub fn register_contract(
1282 &self,
1283 name: InstrumentNameExchange,
1284 contract: ibapi::contracts::Contract,
1285 ) -> Result<(), ContractRegistryError> {
1286 self.contracts.register(name, contract)
1287 }
1288
1289 /// Get the contract registry.
1290 pub fn contract_registry(&self) -> &ContractRegistry {
1291 &self.contracts
1292 }
1293
1294 /// Get the number of pending executions awaiting commission reports.
1295 ///
1296 /// Useful for monitoring whether commission reports are being received.
1297 /// A growing count may indicate IB connection issues or delayed reports.
1298 pub fn pending_execution_count(&self) -> usize {
1299 self.execution_buffer.pending_count()
1300 }
1301
1302 /// Clear stale executions older than the given duration.
1303 ///
1304 /// Returns the number of cleared entries.
1305 ///
1306 /// Call this periodically to prevent unbounded memory growth if commission
1307 /// reports are delayed or lost. A reasonable interval is 5-10 minutes with
1308 /// a max_age of 1 hour.
1309 pub fn clear_stale_executions(&self, max_age: std::time::Duration) -> usize {
1310 self.execution_buffer.clear_stale(max_age)
1311 }
1312
1313 /// Clear order ID mappings older than the given duration.
1314 ///
1315 /// Returns the number of cleared entries.
1316 ///
1317 /// # Why This Is Needed
1318 ///
1319 /// IB does not guarantee event ordering between `OrderStatus("Filled")` and
1320 /// `ExecutionData`/`CommissionReport`. For fast-filling orders (especially
1321 /// market orders), execution data may arrive AFTER the filled status — or
1322 /// the filled status may not arrive at all. So a `Filled` status frees the
1323 /// order's client order id but keeps its IB order ID's entry, and an order
1324 /// whose terminal status never arrives keeps both: its id cannot name a new
1325 /// order (see `open_order`'s "Client order ids") until this clears it.
1326 ///
1327 /// Call this periodically alongside `clear_stale_executions()`. A reasonable
1328 /// interval is 5-10 minutes with a max_age of 1 hour. An order still working
1329 /// past `max_age` is cleared too, and its id freed.
1330 pub fn clear_stale_order_ids(&self, max_age: std::time::Duration) -> usize {
1331 self.order_ids.clear_stale(max_age)
1332 }
1333
1334 /// Clear pending cancel entries older than the given duration.
1335 ///
1336 /// Returns the number of cleared entries.
1337 ///
1338 /// Pending cancels are tracked to differentiate user-initiated cancellation
1339 /// from time-based expiration. If a cancel request is submitted but the order
1340 /// never receives terminal status (e.g., network disconnect), the entry would
1341 /// remain indefinitely. Call this alongside other stale cleanup methods.
1342 pub fn clear_stale_pending_cancels(&self, max_age: std::time::Duration) -> usize {
1343 self.pending_cancels.clear_stale(max_age)
1344 }
1345
1346 /// Disconnect from IB Gateway.
1347 ///
1348 /// Shuts the `ibapi` client down for every clone sharing it: requests in flight fail, any
1349 /// active `account_stream()` ends with `StreamTerminated`, and later requests are refused.
1350 ///
1351 /// # Client ID release
1352 ///
1353 /// This does not release the API client ID. `ibapi` keeps the TCP connection open until the
1354 /// last clone of this client is dropped ([`IbkrClient`] implements [`Clone`] and shares one
1355 /// connection), and TWS/Gateway treats the ID as in use while the connection is open. To
1356 /// reconnect under the same ID, drop every clone first; otherwise
1357 /// [`connect_sync`](Self::connect_sync) is refused with IB error 326 ("client id is already in
1358 /// use").
1359 ///
1360 /// This is idempotent — calling it multiple times is safe.
1361 pub fn disconnect(&self) {
1362 debug!("Disconnecting IbkrClient");
1363 self.client.disconnect();
1364 }
1365
1366 /// Place a bracket order (entry + take-profit + stop-loss) with OCA linking.
1367 ///
1368 /// A bracket order consists of three linked orders:
1369 /// 1. **Entry**: Limit order to enter the position
1370 /// 2. **Take Profit**: Limit order to exit at profit target (OCA-linked to SL)
1371 /// 3. **Stop Loss**: Stop order to exit at loss limit (OCA-linked to TP)
1372 ///
1373 /// # OCA (One-Cancels-All) Behavior
1374 ///
1375 /// When either exit order fills, IB automatically cancels the other. This
1376 /// prevents the dangerous scenario where take-profit fills but stop-loss
1377 /// remains open, potentially opening an unintended opposing position.
1378 ///
1379 /// # Failure Semantics
1380 ///
1381 /// If any leg is rejected by IB (e.g., insufficient margin, invalid price),
1382 /// or cannot be sent, this method cancels every leg already sent and
1383 /// returns the legs as `Inactive`, except for legs of unknown fate (below).
1384 ///
1385 /// The same all-legs cancellation applies if any leg fails to report a
1386 /// status within the placement timeout, or the transport drops while
1387 /// waiting (an unknown/no-status outcome): leaving part of a bracket
1388 /// working while the rest is in an unknown state is unsafe, so every leg is
1389 /// cancelled and an error is returned.
1390 ///
1391 /// If any leg's client order id (the parent's, or the `_tp`/`_sl` id
1392 /// derived from it) is held by a live order, nothing is sent and every leg
1393 /// comes back `Inactive` with [`ApiError::DuplicateClientOrderId`]; the live
1394 /// order is unaffected. See `ExecutionClient::open_order`'s "Client order
1395 /// ids" for when an id is held.
1396 ///
1397 /// # Legs of Unknown Fate
1398 ///
1399 /// Cancelling does not settle a leg whose fate is unknown: a leg that
1400 /// reported no status, or whose rollback cancel could not be sent (ibapi
1401 /// refuses sends while its transport is down), may still be live or held
1402 /// at TWS. Such a leg comes back `Open` with zero fill and keeps its
1403 /// order-id mapping, as a no-status single order does, so the account
1404 /// stream can report how it ends. The other legs come back `Inactive` with
1405 /// the error. A leg IB rejected is settled even if its cancel was refused.
1406 /// So a failed bracket can return a mix of `Open` and `Inactive` legs, and
1407 /// every leg can be `Open` when none reported a status: an all-`Open`
1408 /// result is then indistinguishable from success, and the failure shows
1409 /// only in the log and on the account stream. Treat an `Open` leg as
1410 /// working until the account stream or
1411 /// [`ExecutionClient::fetch_open_orders`] says otherwise.
1412 ///
1413 /// # Cancellation Safety
1414 ///
1415 /// This future registers order ID mappings before submitting to IB. If
1416 /// cancelled mid-flight, orders may still be submitted and mappings will
1417 /// leak. Avoid cancelling this future; use IB's native order timeout via
1418 /// `TimeInForce` if timeout behavior is needed.
1419 ///
1420 /// # Example
1421 ///
1422 /// ```ignore
1423 /// let request = BracketOrderRequest {
1424 /// instrument: "AAPL".into(),
1425 /// strategy: StrategyId::new("my-strategy"),
1426 /// parent_cid: ClientOrderId::new("bracket-001"),
1427 /// side: Side::Buy,
1428 /// quantity: dec!(100),
1429 /// entry_price: dec!(150.00),
1430 /// take_profit_price: dec!(160.00), // +$10 profit target
1431 /// stop_loss_price: dec!(145.00), // -$5 stop loss
1432 /// time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1433 /// };
1434 /// let result = client.open_bracket_order(request).await;
1435 /// ```
1436 pub async fn open_bracket_order(&self, request: BracketOrderRequest) -> BracketOrderResult {
1437 let instrument = request.instrument.clone();
1438
1439 // Look up contract
1440 let contract = match self.contracts.get_contract(&instrument) {
1441 Some(c) => c,
1442 None => {
1443 return make_all_inactive_bracket(
1444 &request,
1445 OrderError::Rejected(ApiError::InstrumentInvalid(
1446 instrument,
1447 "contract not registered".to_string(),
1448 )),
1449 );
1450 }
1451 };
1452
1453 // Convert quantity to f64
1454 let quantity: f64 = match request.quantity.try_into() {
1455 Ok(q) => q,
1456 Err(_) => {
1457 return make_all_inactive_bracket(
1458 &request,
1459 OrderError::Rejected(ApiError::OrderRejected(format!(
1460 "quantity {} exceeds f64 range",
1461 request.quantity
1462 ))),
1463 );
1464 }
1465 };
1466
1467 // Convert prices to f64
1468 let entry_price: f64 = match request.entry_price.try_into() {
1469 Ok(p) => p,
1470 Err(_) => {
1471 return make_all_inactive_bracket(
1472 &request,
1473 OrderError::Rejected(ApiError::OrderRejected(format!(
1474 "entry_price {} exceeds f64 range",
1475 request.entry_price
1476 ))),
1477 );
1478 }
1479 };
1480 let tp_price: f64 = match request.take_profit_price.try_into() {
1481 Ok(p) => p,
1482 Err(_) => {
1483 return make_all_inactive_bracket(
1484 &request,
1485 OrderError::Rejected(ApiError::OrderRejected(format!(
1486 "take_profit_price {} exceeds f64 range",
1487 request.take_profit_price
1488 ))),
1489 );
1490 }
1491 };
1492 let sl_price: f64 = match request.stop_loss_price.try_into() {
1493 Ok(p) => p,
1494 Err(_) => {
1495 return make_all_inactive_bracket(
1496 &request,
1497 OrderError::Rejected(ApiError::OrderRejected(format!(
1498 "stop_loss_price {} exceeds f64 range",
1499 request.stop_loss_price
1500 ))),
1501 );
1502 }
1503 };
1504
1505 // Validate time_in_force and convert to IB wire format
1506 let ib_tif = match time_in_force_to_ib(&request.time_in_force) {
1507 Ok(tif) => tif,
1508 Err(e) => {
1509 return make_all_inactive_bracket(
1510 &request,
1511 OrderError::Rejected(ApiError::OrderRejected(format!(
1512 "TIF not supported by IB: {e}"
1513 ))),
1514 );
1515 }
1516 };
1517
1518 // Allocate 3 consecutive order IDs atomically
1519 let parent_ib_id = self.allocate_order_id_range(3);
1520 let tp_ib_id = parent_ib_id + 1;
1521 let sl_ib_id = parent_ib_id + 2;
1522
1523 // Build bracket orders with OCA linking
1524 let action = side_to_action(request.side);
1525 // Nothing has been sent yet, so on an error the three allocated IDs are simply skipped.
1526 let mut ib_orders = match build_ib_bracket_with_oca(
1527 parent_ib_id,
1528 action,
1529 quantity,
1530 entry_price,
1531 tp_price,
1532 sl_price,
1533 ib_tif,
1534 ) {
1535 Ok(orders) => orders,
1536 Err(e) => {
1537 return make_all_inactive_bracket(
1538 &request,
1539 OrderError::Rejected(ApiError::OrderRejected(e.to_string())),
1540 );
1541 }
1542 };
1543
1544 // Generate client order IDs for children
1545 let parent_cid = request.parent_cid.clone();
1546 let (tp_cid, sl_cid) = derive_child_cids(&parent_cid);
1547 // Each leg carries its own client order id as its order reference.
1548 for (ib_order, cid) in ib_orders.iter_mut().zip([&parent_cid, &tp_cid, &sl_cid]) {
1549 match order::order_ref(cid) {
1550 Ok(order_ref) => ib_order.order_ref = order_ref,
1551 Err(e) => {
1552 return make_all_inactive_bracket(
1553 &request,
1554 OrderError::Rejected(ApiError::OrderRejected(e.to_string())),
1555 );
1556 }
1557 }
1558 }
1559
1560 // Determine opposite side for exit orders
1561 let exit_side = match request.side {
1562 Side::Buy => Side::Sell,
1563 Side::Sell => Side::Buy,
1564 };
1565
1566 // Register order contexts for all three legs
1567 let parent_ctx = OrderContext {
1568 instrument: instrument.clone(),
1569 side: request.side,
1570 price: Some(request.entry_price),
1571 quantity: request.quantity,
1572 kind: OrderKind::Limit,
1573 time_in_force: request.time_in_force,
1574 };
1575 let tp_ctx = OrderContext {
1576 instrument: instrument.clone(),
1577 side: exit_side,
1578 price: Some(request.take_profit_price),
1579 quantity: request.quantity,
1580 kind: OrderKind::Limit,
1581 time_in_force: request.time_in_force,
1582 };
1583 let sl_ctx = OrderContext {
1584 instrument: instrument.clone(),
1585 side: exit_side,
1586 price: None, // Stop orders don't have a limit price
1587 quantity: request.quantity,
1588 kind: OrderKind::Stop {
1589 trigger_price: request.stop_loss_price,
1590 },
1591 time_in_force: request.time_in_force,
1592 };
1593
1594 // All three or none, before anything is sent: a leg under an id a live order holds would
1595 // take that order's mapping. On a refusal the three allocated IDs are simply skipped.
1596 if let Err(in_use) = self.order_ids.register_all([
1597 (parent_cid.clone(), parent_ib_id, parent_ctx),
1598 (tp_cid.clone(), tp_ib_id, tp_ctx),
1599 (sl_cid.clone(), sl_ib_id, sl_ctx),
1600 ]) {
1601 return make_all_inactive_bracket(
1602 &request,
1603 OrderError::Rejected(ApiError::DuplicateClientOrderId(in_use.to_string())),
1604 );
1605 }
1606 {
1607 let mut known = self.known_live.lock();
1608 for cid in [&parent_cid, &tp_cid, &sl_cid] {
1609 known.placing(cid);
1610 }
1611 }
1612
1613 // Place all three orders in spawn_blocking
1614 let client = self.client.clone();
1615 let result = tokio::task::spawn_blocking(move || {
1616 // Roll back legs already sent. Returns the ids whose cancel could not
1617 // be sent, and a suffix for the error message naming them. A refused
1618 // cancel must not vanish: ibapi refuses sends while the transport is
1619 // down, and the leg it names may then be live at TWS.
1620 let rollback = |ids: &[i32]| -> (Vec<i32>, String) {
1621 let failed: Vec<(i32, ibapi::Error)> = ids
1622 .iter()
1623 .filter_map(|&id| {
1624 let e = client.cancel_order(id, "").err()?;
1625 error!(order_id = id, error = %e, "bracket rollback cancel failed; leg may be live");
1626 Some((id, e))
1627 })
1628 .collect();
1629 if failed.is_empty() {
1630 return (Vec::new(), String::new());
1631 }
1632 let named: Vec<String> = failed.iter().map(|(id, e)| format!("{id} ({e})")).collect();
1633 let suffix = format!(
1634 "; rollback cancel failed for order ids {}, which may be live",
1635 named.join(", ")
1636 );
1637 (failed.into_iter().map(|(id, _)| id).collect(), suffix)
1638 };
1639
1640 // A later leg's send failed after the legs in `sent` were written:
1641 // roll them back. A leg whose cancel was refused may be held at TWS,
1642 // so it is unresolved. The failing leg itself is treated as never
1643 // having reached TWS, as in `send_error`.
1644 let leg_send_failure = |e: &ibapi::Error, message: String, sent: &[i32]| {
1645 let (cancel_failed, rollback) = rollback(sent);
1646 BracketFailure {
1647 error: leg_send_error(e, message, &rollback),
1648 unresolved: cancel_failed,
1649 }
1650 };
1651
1652 // Place parent (transmit=false, held until SL is sent)
1653 let parent_sub = match client.place_order(parent_ib_id, &contract, &ib_orders[0]) {
1654 Ok(s) => s,
1655 Err(e) => {
1656 return Err(BracketFailure {
1657 error: send_error(&e, format!("parent order failed: {e}")),
1658 unresolved: Vec::new(),
1659 });
1660 }
1661 };
1662
1663 // Place take-profit (transmit=false)
1664 let tp_sub = match client.place_order(tp_ib_id, &contract, &ib_orders[1]) {
1665 Ok(s) => s,
1666 Err(e) => {
1667 return Err(leg_send_failure(
1668 &e,
1669 format!("take_profit order failed: {e}"),
1670 &[parent_ib_id],
1671 ));
1672 }
1673 };
1674
1675 // Place stop-loss (transmit=true, triggers all)
1676 let sl_sub = match client.place_order(sl_ib_id, &contract, &ib_orders[2]) {
1677 Ok(s) => s,
1678 Err(e) => {
1679 return Err(leg_send_failure(
1680 &e,
1681 format!("stop_loss order failed: {e}"),
1682 &[parent_ib_id, tp_ib_id],
1683 ));
1684 }
1685 };
1686
1687 // Await the first status from each subscription. A leg held until
1688 // RTH (informational notice, e.g. 399) is treated as live with an
1689 // unknown fill (0.0) — the order is working, not rejected. `None`
1690 // means the subscription closed/timed out without a terminal status.
1691 let leg_status = |outcome: PlacementOutcome, leg: &str| match outcome {
1692 PlacementOutcome::Accepted { filled } => Some(Ok(filled)),
1693 PlacementOutcome::HeldPending(notice) => {
1694 warn!(leg, %notice, "bracket leg held/pending; tracking via stream");
1695 Some(Ok(0.0))
1696 }
1697 PlacementOutcome::Rejected(reason) => Some(Err(reason)),
1698 PlacementOutcome::NoStatus => None,
1699 };
1700
1701 let parent_status = leg_status(
1702 await_order_placement(parent_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
1703 "parent",
1704 );
1705 let tp_status = leg_status(
1706 await_order_placement(tp_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
1707 "take_profit",
1708 );
1709 let sl_status = leg_status(
1710 await_order_placement(sl_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
1711 "stop_loss",
1712 );
1713
1714 // Verify all three legs reported a successful status. A `None` here
1715 // means the subscription closed without producing a recognised event
1716 // — surface that as an error rather than silently treating it as
1717 // success. Any error or missing status cancels all legs.
1718 match (parent_status, tp_status, sl_status) {
1719 (Some(Ok(parent)), Some(Ok(tp)), Some(Ok(sl))) => Ok((parent, tp, sl)),
1720 (parent, tp, sl) => {
1721 let (cancel_failed, rollback) = rollback(&[parent_ib_id, tp_ib_id, sl_ib_id]);
1722 let unresolved = unresolved_legs(
1723 [(parent_ib_id, &parent), (tp_ib_id, &tp), (sl_ib_id, &sl)],
1724 &cancel_failed,
1725 );
1726 let show = |status: Option<Result<f64, String>>| match status {
1727 Some(status) => format!("{status:?}"),
1728 None => "no status (timed out or transport lost)".to_string(),
1729 };
1730 Err(BracketFailure {
1731 error: OrderError::Rejected(ApiError::OrderRejected(format!(
1732 "bracket order failed: parent={}, tp={}, sl={}{rollback}",
1733 show(parent),
1734 show(tp),
1735 show(sl)
1736 ))),
1737 unresolved,
1738 })
1739 }
1740 }
1741 })
1742 .await;
1743
1744 // Recorded only once sent, as for a single order.
1745 let result = match result {
1746 Ok(Ok((parent_filled, tp_filled, sl_filled))) => {
1747 let now = Utc::now();
1748 let parent_filled_dec = parse_decimal_or_warn(parent_filled, "parent.filled");
1749 let tp_filled_dec = parse_decimal_or_warn(tp_filled, "tp.filled");
1750 let sl_filled_dec = parse_decimal_or_warn(sl_filled, "sl.filled");
1751
1752 BracketOrderResult {
1753 parent: Order {
1754 key: OrderKey {
1755 exchange: ExchangeId::Ibkr,
1756 instrument: instrument.clone(),
1757 strategy: request.strategy.clone(),
1758 cid: parent_cid,
1759 },
1760 side: request.side,
1761 price: Some(request.entry_price),
1762 quantity: request.quantity,
1763 kind: OrderKind::Limit,
1764 time_in_force: request.time_in_force,
1765 state: OrderState::active(Open::new(
1766 VenueOrderId::Assigned(OrderId::new(format_smolstr!(
1767 "{}",
1768 parent_ib_id
1769 ))),
1770 now,
1771 parent_filled_dec,
1772 )),
1773 },
1774 take_profit: Order {
1775 key: OrderKey {
1776 exchange: ExchangeId::Ibkr,
1777 instrument: instrument.clone(),
1778 strategy: request.strategy.clone(),
1779 cid: tp_cid,
1780 },
1781 side: exit_side,
1782 price: Some(request.take_profit_price),
1783 quantity: request.quantity,
1784 kind: OrderKind::Limit,
1785 time_in_force: request.time_in_force,
1786 state: OrderState::active(Open::new(
1787 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", tp_ib_id))),
1788 now,
1789 tp_filled_dec,
1790 )),
1791 },
1792 stop_loss: Order {
1793 key: OrderKey {
1794 exchange: ExchangeId::Ibkr,
1795 instrument: instrument.clone(),
1796 strategy: request.strategy.clone(),
1797 cid: sl_cid,
1798 },
1799 side: exit_side,
1800 price: None, // Stop orders don't have a limit price
1801 quantity: request.quantity,
1802 kind: OrderKind::Stop {
1803 trigger_price: request.stop_loss_price,
1804 },
1805 time_in_force: request.time_in_force,
1806 state: OrderState::active(Open::new(
1807 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", sl_ib_id))),
1808 now,
1809 sl_filled_dec,
1810 )),
1811 },
1812 }
1813 }
1814 Ok(Err(failure)) => failed_bracket(
1815 &request,
1816 [parent_ib_id, tp_ib_id, sl_ib_id],
1817 failure,
1818 &self.order_ids,
1819 ),
1820 Err(join_err) => {
1821 // The placement task panicked, and which legs it sent was lost
1822 // with it. Unlike `failed_bracket`, all three are released: a
1823 // leg it did send is then untracked, but keeping unsent legs
1824 // Open would report orders that do not exist.
1825 self.order_ids.remove_by_ib_id(parent_ib_id);
1826 self.order_ids.remove_by_ib_id(tp_ib_id);
1827 self.order_ids.remove_by_ib_id(sl_ib_id);
1828
1829 make_all_inactive_bracket(
1830 &request,
1831 OrderError::Rejected(ApiError::OrderRejected(format!(
1832 "task join error: {join_err}"
1833 ))),
1834 )
1835 }
1836 };
1837 let mut known = self.known_live.lock();
1838 for leg in [&result.parent, &result.take_profit, &result.stop_loss] {
1839 known.placed(&leg.key, leg.quantity, leg.kind, &leg.state);
1840 }
1841 drop(known);
1842 result
1843 }
1844}
1845
1846/// Derive bracket child CIDs from the parent CID.
1847///
1848/// Convention: `{parent}_tp` for take-profit, `{parent}_sl` for stop-loss.
1849/// All bracket call sites must use this helper to keep the naming consistent.
1850fn derive_child_cids(parent_cid: &ClientOrderId) -> (ClientOrderId, ClientOrderId) {
1851 (
1852 ClientOrderId::new(format_smolstr!("{}_tp", parent_cid.0)),
1853 ClientOrderId::new(format_smolstr!("{}_sl", parent_cid.0)),
1854 )
1855}
1856
1857/// A failed bracket placement: the error each settled leg carries, and the legs
1858/// whose fate at TWS is unknown.
1859struct BracketFailure {
1860 error: OrderError<AssetNameExchange, InstrumentNameExchange>,
1861 /// IB order ids of legs that may be live at TWS: no placement status
1862 /// arrived for them, or their rollback cancel could not be sent.
1863 unresolved: Vec<i32>,
1864}
1865
1866/// The legs of a bracket that failed after all three were sent whose fate at
1867/// TWS is unknown: those without a placement status (`None`), and those that
1868/// were accepted but whose rollback cancel could not be sent.
1869///
1870/// A rejected leg (`Some(Err)`) is settled whether or not its cancel went out:
1871/// its terminal status was consumed by the placement subscription and will not
1872/// be replayed, so keeping it `Open` would leave a phantom order. An accepted
1873/// leg whose cancel went out is settled as being cancelled.
1874fn unresolved_legs<T, E>(
1875 legs: [(i32, &Option<Result<T, E>>); 3],
1876 cancel_failed: &[i32],
1877) -> Vec<i32> {
1878 legs.into_iter()
1879 .filter(|(id, status)| match status {
1880 None => true,
1881 Some(Ok(_)) => cancel_failed.contains(id),
1882 Some(Err(_)) => false,
1883 })
1884 .map(|(id, _)| id)
1885 .collect()
1886}
1887
1888/// Classify a later bracket leg's send failure, after the legs sent before it
1889/// were rolled back. `rollback` is the suffix naming the cancels that could
1890/// not be sent, empty when every cancel went out.
1891///
1892/// Retrying is only safe when every rollback cancel went out, so only then does
1893/// the failure keep [`send_error`]'s classification. Otherwise a leg may still
1894/// be held at TWS, and the failure is a non-transient rejection naming it.
1895fn leg_send_error(
1896 e: &ibapi::Error,
1897 message: String,
1898 rollback: &str,
1899) -> OrderError<AssetNameExchange, InstrumentNameExchange> {
1900 if rollback.is_empty() {
1901 send_error(e, message)
1902 } else {
1903 OrderError::Rejected(ApiError::OrderRejected(format!("{message}{rollback}")))
1904 }
1905}
1906
1907/// Build the result of a failed bracket placement, releasing the order-id
1908/// mappings of its settled legs.
1909///
1910/// A leg in `failure.unresolved` comes back `Open` with zero fill and keeps its
1911/// mapping, as single-order placement does for an unknown outcome, so the
1912/// account stream can still resolve it. Every other leg was never sent, was
1913/// rejected, or had its cancel sent: it comes back `Inactive` with
1914/// `failure.error`, and its mapping is removed.
1915fn failed_bracket(
1916 request: &BracketOrderRequest,
1917 [parent_ib_id, tp_ib_id, sl_ib_id]: [i32; 3],
1918 failure: BracketFailure,
1919 order_ids: &OrderIdMap,
1920) -> BracketOrderResult {
1921 let BracketFailure { error, unresolved } = failure;
1922 if !unresolved.is_empty() {
1923 warn!(
1924 %error,
1925 ?unresolved,
1926 "bracket placement failed with legs of unknown fate; returning them Open, tracking via stream"
1927 );
1928 }
1929
1930 let mut result = make_all_inactive_bracket(request, error);
1931 let now = Utc::now();
1932 for (leg, ib_id) in [
1933 (&mut result.parent, parent_ib_id),
1934 (&mut result.take_profit, tp_ib_id),
1935 (&mut result.stop_loss, sl_ib_id),
1936 ] {
1937 if unresolved.contains(&ib_id) {
1938 leg.state = OrderState::active(Open::new(
1939 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{ib_id}"))),
1940 now,
1941 Decimal::ZERO,
1942 ));
1943 } else {
1944 order_ids.remove_by_ib_id(ib_id);
1945 }
1946 }
1947 result
1948}
1949
1950/// Helper to create a BracketOrderResult with all legs inactive (same error).
1951fn make_all_inactive_bracket(
1952 request: &BracketOrderRequest,
1953 error: OrderError<AssetNameExchange, InstrumentNameExchange>,
1954) -> BracketOrderResult {
1955 let exit_side = match request.side {
1956 Side::Buy => Side::Sell,
1957 Side::Sell => Side::Buy,
1958 };
1959
1960 let parent_cid = request.parent_cid.clone();
1961 let (tp_cid, sl_cid) = derive_child_cids(&parent_cid);
1962
1963 BracketOrderResult {
1964 parent: Order {
1965 key: OrderKey {
1966 exchange: ExchangeId::Ibkr,
1967 instrument: request.instrument.clone(),
1968 strategy: request.strategy.clone(),
1969 cid: parent_cid,
1970 },
1971 side: request.side,
1972 price: Some(request.entry_price),
1973 quantity: request.quantity,
1974 kind: OrderKind::Limit,
1975 time_in_force: request.time_in_force,
1976 state: OrderState::inactive(error.clone()),
1977 },
1978 take_profit: Order {
1979 key: OrderKey {
1980 exchange: ExchangeId::Ibkr,
1981 instrument: request.instrument.clone(),
1982 strategy: request.strategy.clone(),
1983 cid: tp_cid,
1984 },
1985 side: exit_side,
1986 price: Some(request.take_profit_price),
1987 quantity: request.quantity,
1988 kind: OrderKind::Limit,
1989 time_in_force: request.time_in_force,
1990 state: OrderState::inactive(error.clone()),
1991 },
1992 stop_loss: Order {
1993 key: OrderKey {
1994 exchange: ExchangeId::Ibkr,
1995 instrument: request.instrument.clone(),
1996 strategy: request.strategy.clone(),
1997 cid: sl_cid,
1998 },
1999 side: exit_side,
2000 price: None, // Stop orders don't have a limit price
2001 quantity: request.quantity,
2002 kind: OrderKind::Stop {
2003 trigger_price: request.stop_loss_price,
2004 },
2005 time_in_force: request.time_in_force,
2006 state: OrderState::inactive(error),
2007 },
2008 }
2009}
2010
2011impl ExecutionClient for IbkrClient {
2012 const EXCHANGE: ExchangeId = ExchangeId::Ibkr;
2013
2014 // Mirrors the security types `ContractConfig::to_contract` can build: `STK` and `CASH` (forex)
2015 // are both outright holdings and so map to `Spot`, `FUT` to `Future`, `OPT` to `Option`.
2016 //
2017 // `Cfd` is absent deliberately. IBKR does offer CFDs, but this client builds no `SecurityType`
2018 // for them, so a CFD instrument would fail `to_contract` with `UnrecognizedSecurityType` at
2019 // registration rather than trade.
2020 //
2021 // Scope: this declares which `InstrumentKind`s the client can represent AT ALL. Whether each
2022 // `ContractConfig` describes the instrument it is keyed to is `validate_config`'s check.
2023 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
2024 InstrumentKindDiscriminant::Spot,
2025 InstrumentKindDiscriminant::Future,
2026 InstrumentKindDiscriminant::Option,
2027 ];
2028
2029 type Config = IbkrConfig;
2030 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
2031
2032 /// Rejects a `ContractConfig` that [`connect_sync`](Self::connect_sync) would refuse as invalid,
2033 /// or whose `security_type` contradicts the kind of the instrument it is keyed to by `name`:
2034 /// `STK` or `CASH` for a `Spot`, `FUT` for a `Future`, `OPT` for an `Option`. Every problem
2035 /// found is reported, not just the first.
2036 ///
2037 /// `Spot` covers both a stock and a currency pair, so `STK` and `CASH` cannot be told apart
2038 /// here: either is accepted for any `Spot` instrument.
2039 ///
2040 /// An entry keyed to no instrument on this exchange, and an instrument with no entry, are both
2041 /// accepted: a contract can also be registered later with
2042 /// [`register_contract`](Self::register_contract), which this cannot see.
2043 fn validate_config(
2044 config: &Self::Config,
2045 instruments: &[ClientInstrument<'_>],
2046 ) -> Result<(), String> {
2047 let problems = config
2048 .contracts
2049 .iter()
2050 .filter_map(|contract| {
2051 if let Err(error) = contract.to_contract() {
2052 return Some(format!("contract {:?}: {error}", contract.name));
2053 }
2054 let instrument = instruments
2055 .iter()
2056 .find(|instrument| instrument.name_exchange.as_ref() == contract.name)?;
2057 let expected = security_types_of(instrument.kind);
2058 if expected.is_empty() {
2059 // Unreachable through `ExecutionBuilder`, which checks `SUPPORTED_KINDS` first,
2060 // but a direct caller can pass any kind.
2061 return Some(format!(
2062 "contract {:?} is for an instrument of kind {}, which this client cannot \
2063 trade",
2064 contract.name, instrument.kind,
2065 ));
2066 }
2067 (!expected.contains(&contract.security_type.as_str())).then(|| {
2068 format!(
2069 "contract {:?} names security type {:?}, but its instrument's kind is \
2070 {}, which takes {}",
2071 contract.name,
2072 contract.security_type,
2073 instrument.kind,
2074 expected.join(" or "),
2075 )
2076 })
2077 })
2078 .collect::<Vec<_>>();
2079
2080 if problems.is_empty() {
2081 Ok(())
2082 } else {
2083 Err(problems.join("; "))
2084 }
2085 }
2086
2087 /// Create a new IBKR client by connecting to TWS/Gateway.
2088 ///
2089 /// # Blocking
2090 ///
2091 /// This method performs blocking TCP I/O and IB API handshake. If called
2092 /// from an async context, wrap in `tokio::task::spawn_blocking` or call
2093 /// from a dedicated thread.
2094 ///
2095 /// # Panics
2096 ///
2097 /// Panics if [`IbkrClient::connect_sync`] fails: when TWS/Gateway cannot be reached or
2098 /// refuses the connection, or when any contract in [`IbkrConfig::contracts`] cannot be
2099 /// registered. The `ExecutionClient` trait doesn't allow `new()` to return `Result`. Call
2100 /// [`IbkrClient::connect_sync`] or [`IbkrClient::connect_sync_lenient`] directly for
2101 /// fallible construction.
2102 #[track_caller]
2103 fn new(config: Self::Config) -> Self {
2104 #[allow(clippy::expect_used)] // Trait signature doesn't allow Result
2105 Self::connect_sync(config)
2106 .expect("failed to connect to IB or to register its configured contracts")
2107 }
2108
2109 /// Fetch account snapshot: balances, and the position and open orders of each registered
2110 /// instrument IB reports either in.
2111 ///
2112 /// # Positions
2113 ///
2114 /// Each instrument IB reports a position or an open order of this API client's in, and that
2115 /// is registered (and in `instruments`, when that is not empty), gets an
2116 /// `InstrumentAccountSnapshot`. Its `position` is
2117 /// [`PositionReport::Open`], carrying:
2118 /// - `quantity`: IB's signed position, negative when short, in shares for a stock and in
2119 /// contracts for a future or an option. `ibapi` hands it over as an `f64`, converted
2120 /// with `Decimal::try_from`, which rounds to the float's precision of about 15 significant
2121 /// digits (0.1 stays 0.1) rather than keeping its exact binary expansion.
2122 /// - `entry_price`: IB's average cost divided by the contract multiplier, so a future or an
2123 /// option is quoted as its orders are priced. It includes commissions. `None` when IB sends
2124 /// no average cost, or no multiplier for a contract other than a stock or a forex pair.
2125 /// - `time_exchange`: the time of the call, since IB does not timestamp positions.
2126 ///
2127 /// `unrealized_pnl` and the margin fields are `None`: IB's positions subscription does not
2128 /// carry them.
2129 ///
2130 /// A zero quantity, which IB reports for a position closed today, is
2131 /// [`PositionReport::Flat`]. So is each instrument in
2132 /// `instruments` that is registered with its contract ID but that IB did not list, provided IB
2133 /// marked the end of its listing (`PositionEnd`) during the read. Without that marker such
2134 /// instruments are left out, with a warning, since the listing may be incomplete.
2135 /// A requested instrument registered without a contract ID, or whose ID was registered again
2136 /// under another name, cannot be matched to IB's reports and is left out too.
2137 ///
2138 /// **Several accounts.** This client reads the positions of every account the login manages,
2139 /// one account at a time. When more than one account holds the same instrument, the first account to report a
2140 /// non-zero quantity is kept and the others are dropped with a warning; they are never summed.
2141 /// A consumer comparing the reported quantity with its own, as the `rustrade` engine's position
2142 /// drift check does, therefore compares against that one account.
2143 /// Which account comes first depends on the order IB lists the login's accounts in, so a
2144 /// caller holding the same instrument in several accounts should not rely on it.
2145 ///
2146 /// An instrument with an open order whose position the read could not establish, because IB
2147 /// did not mark the end of its listing, reports it
2148 /// [`PositionReport::Unreported`].
2149 ///
2150 /// # Orders
2151 ///
2152 /// Each instrument's `orders` are this API client's open orders in it, read as
2153 /// [`fetch_open_orders`](ExecutionClient::fetch_open_orders) reads them, from one listing of
2154 /// the account's open orders. An order this client does not track but placed under a client
2155 /// order id, such as one placed before a restart, is listed under that id, and tracked from
2156 /// then on.
2157 ///
2158 /// `orders_complete` is `true` when IB listed every open order to the end of the listing and
2159 /// each of the instrument's was read back under the client order id it was placed with and
2160 /// with its status. So it is `false` on an instrument with an order of this API client's
2161 /// that carries no client order id, which is listed under its IB order id: one placed by an
2162 /// earlier version of this client, or, for a client connected as 0, one entered in TWS. So it
2163 /// is too with an order whose shape this client never sends, one IB listed without its
2164 /// status, or one whose client order id names another order this client tracks. When the
2165 /// listing cannot be read, no instrument's orders are complete, with a warning, and the
2166 /// snapshot is still returned.
2167 ///
2168 /// # Errors
2169 ///
2170 /// [`UnindexedClientError::Internal`] if a position quantity does not convert to a
2171 /// `Decimal`, if the request for the login's accounts fails or yields none (as it does when the
2172 /// session ends during it), if a positions subscription fails, or as
2173 /// [`fetch_balances`](ExecutionClient::fetch_balances).
2174 ///
2175 /// # Known Issue: ibapi Decode Errors
2176 ///
2177 /// You may see log errors like `"error decoding message: error occurred:
2178 /// unexpected message: Error"`. This is an upstream ibapi limitation where
2179 /// IB Error messages (type 4) on subscription channels aren't properly
2180 /// routed — they're logged but don't affect functionality.
2181 ///
2182 /// # Timeout
2183 ///
2184 /// Each account's positions read ends at IB's end-of-listing marker. If IB sends
2185 /// nothing for 5 seconds before it, that account's read gives up and the next
2186 /// one starts, so a login with N accounts can wait up to N × 5 seconds. The
2187 /// positions received are returned rather than blocking indefinitely, without
2188 /// the flat reports that need every account's listing to be complete.
2189 ///
2190 /// Each read uses IB's positions-multi request for that account, whose replies
2191 /// carry a request ID, so concurrent calls on clones of this client never read
2192 /// each other's listing.
2193 async fn account_snapshot(
2194 &self,
2195 assets: &[AssetNameExchange],
2196 instruments: &[InstrumentNameExchange],
2197 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
2198 // H-2 fix: Run balances and positions concurrently (independent IB requests)
2199 let client = self.client.clone();
2200 let contracts = self.contracts.clone();
2201 let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
2202 None
2203 } else {
2204 Some(instruments.iter().cloned().collect())
2205 };
2206
2207 let balances_future = self.fetch_balances(assets);
2208 let positions_filter = instruments_filter.clone();
2209 let positions_future = tokio::task::spawn_blocking(move || {
2210 let instruments_filter = positions_filter;
2211 // ibapi::Error is unstructured — we cannot distinguish connection failures
2212 // (transient, should retry) from API errors (e.g., invalid request).
2213 // Mapped to Internal (non-transient) conservatively; a caller needing reconnect
2214 // logic should drive it from connection state, not from the error type.
2215 let accounts = client
2216 .managed_accounts()
2217 .map_err(|e| UnindexedClientError::Internal(format!("managed accounts: {e}")))?;
2218 if accounts.is_empty() {
2219 // `ibapi` also answers with no accounts when the session ends mid-request.
2220 return Err(UnindexedClientError::Internal(
2221 "no managed accounts: IB listed none, or the session ended".to_string(),
2222 ));
2223 }
2224
2225 let mut positions = PositionAggregator::default();
2226 let mut listed_all = true;
2227 for account in accounts {
2228 listed_all &= read_account_positions(
2229 &client,
2230 AccountId(account),
2231 &contracts,
2232 instruments_filter.as_ref(),
2233 &mut positions,
2234 )?;
2235 }
2236 if listed_all {
2237 positions.listing_ended();
2238 }
2239
2240 // IB's reports are attributed by contract ID, so a requested instrument can be
2241 // reported flat only if its ID is registered and resolves back to it: with no ID, or
2242 // one another name took over, IB's position in it would be missed.
2243 let requested: Vec<_> = instruments_filter
2244 .iter()
2245 .flatten()
2246 .filter(|instrument| {
2247 contracts.get_contract(instrument).is_some_and(|contract| {
2248 contract.contract_id != 0
2249 && contracts.get_name_by_con_id(contract.contract_id).as_ref()
2250 == Some(*instrument)
2251 })
2252 })
2253 .cloned()
2254 .collect();
2255 Ok::<_, UnindexedClientError>((positions, requested))
2256 });
2257
2258 let client = self.client.clone();
2259 let contracts = self.contracts.clone();
2260 let order_ids = self.order_ids.clone();
2261 let listings = self.listings.clone();
2262 let orders_future = tokio::task::spawn_blocking(move || {
2263 let listing = listings.open_orders(&client)?;
2264 Ok::<_, UnindexedClientError>(open_orders_from_listing(
2265 listing,
2266 client.client_id(),
2267 &contracts,
2268 &order_ids,
2269 ))
2270 });
2271
2272 // ibapi routes responses by request_id via thread-safe channels (RwLock<HashMap>),
2273 // so concurrent requests on the same client are safe. The open-order listing is a shared
2274 // request, serialized by `ListingLock`.
2275 let (balances_result, positions_result, orders_result) =
2276 tokio::join!(balances_future, positions_future, orders_future);
2277 let balances = balances_result?;
2278 let (positions, requested) = positions_result
2279 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))??;
2280 let listed = match orders_result
2281 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
2282 {
2283 Ok(mut listed) => {
2284 if let Some(filter) = &instruments_filter {
2285 listed.retain(|listed| filter.contains(&listed.instrument));
2286 }
2287 Some(listed)
2288 }
2289 Err(error) => {
2290 warn!(
2291 %error,
2292 "Could not read IBKR's open orders for the account snapshot; no instrument's \
2293 orders are reported complete"
2294 );
2295 None
2296 }
2297 };
2298 if let Some(listed) = &listed {
2299 remember_listed(&self.known_live, listed);
2300 }
2301 let mut orders = SnapshotOrders::new(listed);
2302
2303 // An instrument with open orders is attributed by contract ID too, so its position is
2304 // reported along with them.
2305 let reported = positions.into_reports(
2306 Utc::now(),
2307 requested
2308 .iter()
2309 .chain(orders.instruments())
2310 .collect::<HashSet<_>>(),
2311 )?;
2312 let mut instrument_snapshots = Vec::with_capacity(reported.len());
2313 for (instrument, position) in reported {
2314 let (orders, orders_complete) = orders.take(&instrument);
2315 instrument_snapshots.push(InstrumentAccountSnapshot {
2316 instrument,
2317 orders,
2318 orders_complete,
2319 position,
2320 isolated: None,
2321 });
2322 }
2323 // Instruments with open orders whose positions the read did not finish listing.
2324 instrument_snapshots.extend(orders.into_rest().map(|(instrument, (orders, complete))| {
2325 InstrumentAccountSnapshot {
2326 instrument,
2327 orders,
2328 orders_complete: complete,
2329 position: PositionReport::Unreported,
2330 isolated: None,
2331 }
2332 }));
2333
2334 Ok(AccountSnapshot {
2335 exchange: ExchangeId::Ibkr,
2336 balances,
2337 instruments: instrument_snapshots,
2338 })
2339 }
2340
2341 /// Stream account events (order updates, fills, commissions).
2342 ///
2343 /// # Thread Lifecycle
2344 ///
2345 /// Spawns two background threads: `ibkr-order-stream` reads the blocking IB
2346 /// subscription, and `ibkr-recovery` watches for gaps in event delivery
2347 /// (see below). The watcher exits within about a second of the returned
2348 /// `BoxStream` being dropped or the stream ending, or once the recovery or
2349 /// order check in progress then ends. The reader exits when:
2350 /// - The returned `BoxStream` is dropped (channel closes), or the stream has
2351 /// ended, and the next IB event arrives
2352 /// - The IB subscription ends
2353 ///
2354 /// **Important:** If IB is stalled (no events flowing), the reader thread blocks
2355 /// on the iterator. Dropping the stream signals termination, but the thread won't
2356 /// observe it until the next IB event arrives. A shutdown does end the
2357 /// subscription, so the thread exits then.
2358 ///
2359 /// # Reconnects and Recovery
2360 ///
2361 /// `ibapi` reconnects its socket to TWS/Gateway by itself, and this stream
2362 /// stays open across that: a transient drop produces neither an error nor
2363 /// `StreamTerminated`. TWS does not resend what it sent while the socket was
2364 /// down, and TWS losing its own link to IB's servers (notice 1100) opens the
2365 /// same kind of gap. Once delivery is restored, this stream asks TWS for the
2366 /// day's executions and emits the ones from the gap as `Trade` events. The
2367 /// gap is taken to start up to a poll interval before the drop was noticed,
2368 /// minus a lookback margin. Trades the stream already delivered are not
2369 /// emitted twice. The log line `Recovered IBKR fills after a gap in event
2370 /// delivery` marks one.
2371 ///
2372 /// When that read fails three times for a reason other than the transport
2373 /// dropping again, the gap is given up: the stream sends one
2374 /// `AccountEventKind::FillRecoveryGaveUp` covering every instrument, from the
2375 /// start of the gap to when it gave up, and stays open. Read that span with
2376 /// [`ExecutionClient::fetch_trades`].
2377 ///
2378 /// **Orders that ended during the gap.** After the fills, recovered or given
2379 /// up, the stream checks how each order the client holds as live ended:
2380 /// those placed through it, listed by `fetch_open_orders` or
2381 /// `account_snapshot`, or reported live on this stream, and not yet seen to
2382 /// end. It lists the open orders once, and looks up each held order the
2383 /// listing no longer shows in IB's completed orders, as
2384 /// [`OrderStatusClient::fetch_ended_orders`] does. Each that ended is sent
2385 /// as an `OrderSnapshot` of its inactive state, under `StrategyId::unknown`,
2386 /// and its client order id is freed, as when the stream reports an order
2387 /// ending itself. An order IB lists as neither is dropped from the set with a
2388 /// warning. A check that fails is retried after 1, 2, 4, 8 and 16 minutes
2389 /// while connected, then given up with an error log: an order that ended
2390 /// then stays live in engine state until the next gap's check, so reconcile
2391 /// with `fetch_open_orders`. The client holds at most 4,096 orders as live,
2392 /// forgetting the oldest beyond that. The check reads IB's listings on the
2393 /// recovery thread, each bounded by a 10-second stall timeout, and the thread
2394 /// reads notices again once it ends.
2395 ///
2396 /// Call this within a Tokio runtime with its time driver enabled, as
2397 /// `#[tokio::main]` and `Runtime::new` build: the recovery thread runs the
2398 /// order check on the runtime this was called on.
2399 ///
2400 /// While TWS reports its link to IB's servers lost (1100), nothing marks the
2401 /// gap on this stream until the link is restored and the recovered fills
2402 /// arrive.
2403 ///
2404 /// The stream ends with `StreamTerminated` when the client shuts down for
2405 /// good, because `ibapi` gave up reconnecting, [`IbkrClient::disconnect`] was
2406 /// called, or TWS/Gateway ended the API session. Called on a client that has
2407 /// already shut down, this method fails, so replace the client.
2408 ///
2409 /// # Duplicate Events
2410 ///
2411 /// Orders submitted via `open_order()` will emit `OrderSnapshot` events on
2412 /// this stream in addition to the response from `open_order()` itself. This
2413 /// is inherent to IB's API — both the per-order subscription and the global
2414 /// order update stream receive the same `OrderStatus` events. Callers should
2415 /// deduplicate if needed.
2416 ///
2417 /// `Trade` events are delivered once each. Executions that answer an
2418 /// executions request, such as [`ExecutionClient::fetch_trades`], are not
2419 /// reported here as fills.
2420 ///
2421 /// # Corrections
2422 ///
2423 /// IB reports a corrected execution as a further execution whose id differs
2424 /// only in the digits after the final period (`….01.02` corrects `….01.01`).
2425 /// It is sent as `AccountEventKind::TradeAmended`, `Corrected`, with the
2426 /// correction as the replacement `Trade`, once its commission report arrives.
2427 /// Its `original` is the revision this stream delivered before, or, for an
2428 /// execution from before the stream, the revision the correction's id says
2429 /// it corrects. A revision older than one already delivered is dropped.
2430 /// Fills recovered after a gap are delivered originals first, so a
2431 /// correction recovered with them comes after every original in the gap,
2432 /// not in time order.
2433 /// Each correction is logged at `warn!` when it arrives, so one that never
2434 /// gets a commission report is still seen. IB documents no busts.
2435 ///
2436 /// The revisions delivered are remembered in a bounded cache, like the dedup
2437 /// cache. A correction of an execution the cache has forgotten names the
2438 /// previous revision by its id, which is the original unless an
2439 /// intermediate revision was delivered and forgotten too.
2440 ///
2441 /// One case loses a fill: a correction that arrives live after a reconnect,
2442 /// before recovery has delivered its original from the gap. The correction
2443 /// is sent naming the original, and recovery then drops the original as
2444 /// older than a revision already delivered, so the trade reaches the stream
2445 /// only as the amendment.
2446 ///
2447 /// # Filter Parameters
2448 ///
2449 /// The `assets` and `instruments` parameters are currently ignored. IB's
2450 /// `order_update_stream()` returns events for all orders on the account.
2451 /// Callers subscribing for a specific instrument will receive events for
2452 /// all instruments.
2453 async fn account_stream(
2454 &self,
2455 _assets: &[AssetNameExchange],
2456 _instruments: &[InstrumentNameExchange],
2457 ) -> Result<Self::AccountStream, UnindexedClientError> {
2458 let client = self.client.clone();
2459
2460 // M-8 fix: Wrap blocking subscription call in spawn_blocking
2461 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
2462 let (notices, order_sub) = tokio::task::spawn_blocking(move || {
2463 // Notices first, so a reconnect that lands while the order stream is being set up is
2464 // not missed.
2465 let notices = client.notice_stream()?;
2466 client.order_update_stream().map(|sub| (notices, sub))
2467 })
2468 .await
2469 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
2470 .map_err(|e| UnindexedClientError::Internal(format!("order updates: {e}")))?;
2471
2472 let api_client_id = self.client.client_id();
2473 let contracts_clone = self.contracts.clone();
2474 let order_ids_clone = self.order_ids.clone();
2475 let pending_cancels_clone = self.pending_cancels.clone();
2476 let exec_buffer_clone = self.execution_buffer.clone();
2477
2478 let (tx, rx) = mpsc::unbounded_channel();
2479 let sink = recovery::EventSink::new(tx, new_dedup_cache(), self.known_live.clone());
2480
2481 // Spawned before the reader: if the reader fails to spawn, `rx` drops with the error and
2482 // the watcher sees its consumer gone.
2483 let watcher = recovery::RecoveryWatcher {
2484 client: self.client.clone(),
2485 notices,
2486 contracts: self.contracts.clone(),
2487 order_ids: self.order_ids.clone(),
2488 pending_cancels: self.pending_cancels.clone(),
2489 pending: self.execution_buffer.clone(),
2490 known: self.known_live.clone(),
2491 listings: self.listings.clone(),
2492 earlier_completions: self.earlier_completions.clone(),
2493 sink: sink.clone(),
2494 runtime: tokio::runtime::Handle::current(),
2495 };
2496 let watcher_sink = sink.clone();
2497 std::thread::Builder::new()
2498 .name("ibkr-recovery".to_string())
2499 .spawn(move || {
2500 // Same panic policy as the reader below: a panic ends recovery, and a stream that
2501 // can no longer recover must say so rather than stay open.
2502 if let Err(panic_info) = catch_unwind(AssertUnwindSafe(|| watcher.run())) {
2503 let msg = panic_message(panic_info.as_ref());
2504 error!("IBKR recovery worker panicked: {msg}");
2505 watcher_sink.terminate(StreamTerminationReason::Error(format!(
2506 "IBKR recovery worker panicked: {msg}"
2507 )));
2508 }
2509 })
2510 .map_err(|e| UnindexedClientError::TaskFailed(format!("thread spawn: {e}")))?;
2511
2512 std::thread::Builder::new()
2513 .name("ibkr-order-stream".to_string())
2514 .spawn(move || {
2515 // Panic safety: parking_lot mutexes do not poison on panic, so shared state
2516 // (ContractRegistry, OrderIdMap, etc.) remains usable. catch_unwind unwinds only
2517 // the inner closure — `sink` lives in this outer thread closure and is still open
2518 // afterward, so the panic handler below emits a terminal StreamTerminated
2519 // (a panic is a terminal stream death like any other).
2520 let result = catch_unwind(AssertUnwindSafe(|| {
2521 forward_order_updates(
2522 order_sub.iter_data(),
2523 &sink,
2524 api_client_id,
2525 &contracts_clone,
2526 &order_ids_clone,
2527 &pending_cancels_clone,
2528 &exec_buffer_clone,
2529 );
2530 }));
2531
2532 if let Err(panic_info) = result {
2533 let msg = panic_message(panic_info.as_ref());
2534 error!("Order stream worker panicked: {msg}");
2535 // A panic is a terminal stream death — surface it in-band so the consumer
2536 // gets a programmatic signal rather than inferring EOF.
2537 // Best-effort: a no-op if the consumer already dropped rx.
2538 sink.terminate(StreamTerminationReason::Error(format!(
2539 "IBKR order-update worker panicked: {msg}"
2540 )));
2541 }
2542 })
2543 .map_err(|e| UnindexedClientError::TaskFailed(format!("thread spawn: {e}")))?;
2544
2545 Ok(Box::pin(
2546 tokio_stream::wrappers::UnboundedReceiverStream::new(rx),
2547 ))
2548 }
2549
2550 /// Cancel an order.
2551 ///
2552 /// # Async Cancel Semantics
2553 ///
2554 /// Returns `Ok(Cancelled)` when the cancel request is **submitted**, not when
2555 /// the order is confirmed cancelled. The actual cancellation confirmation comes
2556 /// via `account_stream` as an `OrderStatus::Cancelled` event.
2557 ///
2558 /// The order ID mapping is retained until terminal status is received via
2559 /// `account_stream`, ensuring fill events arriving between cancel request and
2560 /// confirmation are not lost.
2561 async fn cancel_order(
2562 &self,
2563 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
2564 ) -> UnindexedOrderResponseCancel {
2565 let key = OrderKey {
2566 exchange: request.key.exchange,
2567 instrument: request.key.instrument.clone(),
2568 strategy: request.key.strategy.clone(),
2569 cid: request.key.cid.clone(),
2570 };
2571
2572 let ib_order_id = match self.order_ids.get_ib_id(&request.key.cid) {
2573 Some(id) => id,
2574 None => {
2575 return OrderResponseCancel {
2576 key,
2577 state: Err(crate::error::OrderError::Rejected(ApiError::OrderRejected(
2578 "order ID not found in map".to_string(),
2579 ))),
2580 };
2581 }
2582 };
2583
2584 let client = self.client.clone();
2585
2586 let result =
2587 tokio::task::spawn_blocking(move || client.cancel_order(ib_order_id, "")).await;
2588
2589 match result {
2590 Ok(Ok(_sub)) => {
2591 // Track user-initiated cancel for Cancelled vs Expired differentiation.
2592 // Insert only after cancel request succeeded — avoids stale entries if
2593 // the future is dropped before reaching this branch.
2594 self.pending_cancels.insert(ib_order_id);
2595
2596 // H-3 fix: Do NOT remove order_ids here. The mapping is needed to
2597 // correlate any fill events that arrive between now and when IB
2598 // confirms the cancel. Removal happens in account_stream when
2599 // OrderStatus::Cancelled is received.
2600 // IBKR cancel_order returns no filled qty, so it is unknown here; the
2601 // subsequent OrderStatus events report it.
2602 OrderResponseCancel {
2603 key,
2604 state: Ok(Cancelled::new(
2605 OrderId::new(format_smolstr!("{}", ib_order_id)),
2606 Utc::now(),
2607 None,
2608 )),
2609 }
2610 }
2611 Ok(Err(e)) => {
2612 error!(order_id = ib_order_id, error = %e, "Failed to cancel order");
2613 OrderResponseCancel {
2614 key,
2615 state: Err(send_error(&e, e.to_string())),
2616 }
2617 }
2618 Err(e) => {
2619 error!(order_id = ib_order_id, error = %e, "Task join error");
2620 OrderResponseCancel {
2621 key,
2622 state: Err(crate::error::OrderError::Rejected(ApiError::OrderRejected(
2623 e.to_string(),
2624 ))),
2625 }
2626 }
2627 }
2628 }
2629
2630 /// Submit an order to IB.
2631 ///
2632 /// # Client order ids
2633 ///
2634 /// The client order id is sent as the order's IB order reference, so it must be at most 128
2635 /// ASCII characters; any other is refused before anything is sent. TWS does not check the
2636 /// reference's uniqueness, so this client keeps each id unique itself: an open
2637 /// under an id that a live order holds is refused before anything is sent, as
2638 /// [`ApiError::DuplicateClientOrderId`], and the live order is unaffected. An order stops
2639 /// holding its id when the account stream reports it `Filled`, `Cancelled` or `Inactive`, or
2640 /// when [`clear_stale_order_ids`](IbkrClient::clear_stale_order_ids) reaps it, or when the
2641 /// account stream's check after a gap finds it ended. Without a running
2642 /// [`account_stream`](Self::account_stream), no order is reported ended, and an id is held
2643 /// until reaped.
2644 ///
2645 /// A cancel names the live order under its id, so cancelling a filled order's id once it is
2646 /// freed is refused locally as not found, rather than at TWS.
2647 ///
2648 /// # Cancellation Safety
2649 ///
2650 /// This future registers the order ID mapping before submitting to IB.
2651 /// If the future is cancelled (e.g., via `tokio::select!` timeout) after
2652 /// registration but before completion:
2653 /// - The order may still be submitted to IB
2654 /// - The order ID mapping will leak (not cleaned up)
2655 /// - Subsequent fills will be processed via `account_stream`
2656 /// - The client order id stays held, so a retry under it is refused as
2657 /// [`ApiError::DuplicateClientOrderId`] until the account stream reports
2658 /// the order ended or `clear_stale_order_ids` reaps it. Retry under a
2659 /// fresh id. The same holds when this returns `Open` without a status
2660 /// from TWS.
2661 ///
2662 /// Callers should avoid cancelling this future mid-flight. If timeout
2663 /// behavior is needed, prefer setting IB's native order timeout via
2664 /// `TimeInForce` instead.
2665 async fn open_order(
2666 &self,
2667 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
2668 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2669 let key = OrderKey {
2670 exchange: ExchangeId::Ibkr,
2671 instrument: request.key.instrument.clone(),
2672 strategy: request.key.strategy.clone(),
2673 cid: request.key.cid.clone(),
2674 };
2675
2676 let contract = match self.contracts.get_contract(request.key.instrument) {
2677 Some(c) => c,
2678 None => {
2679 return Order {
2680 key,
2681 side: request.state.side,
2682 price: request.state.price,
2683 quantity: request.state.quantity,
2684 kind: request.state.kind,
2685 time_in_force: request.state.time_in_force,
2686 state: OrderState::inactive(OrderError::Rejected(ApiError::InstrumentInvalid(
2687 request.key.instrument.clone(),
2688 "contract not registered".to_string(),
2689 ))),
2690 };
2691 }
2692 };
2693
2694 let quantity: f64 = match request.state.quantity.try_into() {
2695 Ok(q) => q,
2696 Err(_) => {
2697 return Order {
2698 key,
2699 side: request.state.side,
2700 price: request.state.price,
2701 quantity: request.state.quantity,
2702 kind: request.state.kind,
2703 time_in_force: request.state.time_in_force,
2704 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2705 format!("quantity {} exceeds f64 range", request.state.quantity),
2706 ))),
2707 };
2708 }
2709 };
2710
2711 let ib_order = match order::order_ref(&request.key.cid).and_then(|order_ref| {
2712 build_ib_order(
2713 request.state.side,
2714 quantity,
2715 &request.state.kind,
2716 request.state.price,
2717 &request.state.time_in_force,
2718 )
2719 .map(|order| ibapi::orders::Order { order_ref, ..order })
2720 }) {
2721 Ok(o) => o,
2722 Err(e) => {
2723 return Order {
2724 key,
2725 side: request.state.side,
2726 price: request.state.price,
2727 quantity: request.state.quantity,
2728 kind: request.state.kind,
2729 time_in_force: request.state.time_in_force,
2730 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2731 e.to_string(),
2732 ))),
2733 };
2734 }
2735 };
2736
2737 let ib_order_id = self.allocate_order_id();
2738
2739 // Store order context for reconstructing Order from OrderStatus callbacks
2740 let context = OrderContext {
2741 instrument: request.key.instrument.clone(),
2742 side: request.state.side,
2743 price: request.state.price,
2744 quantity: request.state.quantity,
2745 kind: request.state.kind,
2746 time_in_force: request.state.time_in_force,
2747 };
2748 // Before anything is sent: TWS does not check the uniqueness of the order reference that
2749 // carries the client order id, so this is the only place a second live order under it can
2750 // be refused. A refused id simply skips the allocated ID.
2751 if let Err(in_use) = self
2752 .order_ids
2753 .register(request.key.cid.clone(), ib_order_id, context)
2754 {
2755 return Order {
2756 key,
2757 side: request.state.side,
2758 price: request.state.price,
2759 quantity: request.state.quantity,
2760 kind: request.state.kind,
2761 time_in_force: request.state.time_in_force,
2762 state: OrderState::inactive(OrderError::Rejected(
2763 ApiError::DuplicateClientOrderId(in_use.to_string()),
2764 )),
2765 };
2766 }
2767 // The id names no live order, so this is a new order under it, though an earlier one
2768 // under it ended.
2769 self.known_live.lock().placing(&request.key.cid);
2770
2771 let client = self.client.clone();
2772 let side = request.state.side;
2773 let price = request.state.price;
2774 let req_quantity = request.state.quantity;
2775 let kind = request.state.kind;
2776 let tif = request.state.time_in_force;
2777
2778 // Move both place_order AND subscription iteration into spawn_blocking
2779 // to avoid blocking Tokio worker threads on IB's synchronous iterator.
2780 let result = tokio::task::spawn_blocking(move || {
2781 let sub = match client.place_order(ib_order_id, &contract, &ib_order) {
2782 Ok(s) => s,
2783 Err(e) => return Err(send_error(&e, e.to_string())),
2784 };
2785
2786 match await_order_placement(sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)) {
2787 PlacementOutcome::Accepted { filled } => {
2788 let filled = parse_decimal_or_warn(filled, "status.filled");
2789 Ok(Some((ib_order_id, filled)))
2790 }
2791 // Cancelled/Inactive/unexpected status or a genuine notice/error.
2792 // Don't remove the mapping here — the outer match centralizes all
2793 // error-path removals.
2794 PlacementOutcome::Rejected(reason) => {
2795 Err(OrderError::Rejected(ApiError::OrderRejected(reason)))
2796 }
2797 // Held until RTH (informational notice): the order is live; its
2798 // authoritative status arrives via account_stream. Surface as
2799 // "no terminal status yet" (Open with zero fill), same as below.
2800 PlacementOutcome::HeldPending(notice) => {
2801 warn!(ib_order_id, %notice, "order held/pending; tracking via stream");
2802 Ok(None)
2803 }
2804 // Subscription exhausted/timed out without a terminal status.
2805 // Do NOT remove the mapping — ExecutionData/CommissionReport may
2806 // still arrive via account_stream; clear_stale_order_ids() handles
2807 // stale mappings.
2808 PlacementOutcome::NoStatus => Ok(None),
2809 }
2810 })
2811 .await;
2812
2813 // Recorded only once sent: an open refused before sending, as under a client order id a
2814 // live order holds, must not end that order in the set.
2815 let order = match result {
2816 Ok(Ok(Some((order_id, filled)))) => {
2817 // IB always returns order status via subscription - never immediate fills.
2818 // The filled quantity here is from the OrderStatus event, not a complete fill.
2819 Order {
2820 key,
2821 side,
2822 price,
2823 quantity: req_quantity,
2824 kind,
2825 time_in_force: tif,
2826 state: OrderState::active(Open::new(
2827 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", order_id))),
2828 Utc::now(),
2829 filled,
2830 )),
2831 }
2832 }
2833 Ok(Ok(None)) => {
2834 // Subscription exhausted without terminal status. The order WAS submitted
2835 // (we have an IB order ID), but we lost tracking. Return Open with zero
2836 // filled — caller can query via fetch_open_orders or wait for account_stream.
2837 warn!(
2838 ib_order_id,
2839 "Order subscription ended without terminal status, returning Open"
2840 );
2841 Order {
2842 key,
2843 side,
2844 price,
2845 quantity: req_quantity,
2846 kind,
2847 time_in_force: tif,
2848 state: OrderState::active(Open::new(
2849 VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", ib_order_id))),
2850 Utc::now(),
2851 Decimal::ZERO,
2852 )),
2853 }
2854 }
2855 Ok(Err(error)) => {
2856 // Cleanup order_ids: the order was never sent (place_order error)
2857 // or TWS rejected it (Cancelled/Inactive or a genuine notice).
2858 self.order_ids.remove_by_ib_id(ib_order_id);
2859 Order {
2860 key,
2861 side,
2862 price,
2863 quantity: req_quantity,
2864 kind,
2865 time_in_force: tif,
2866 state: OrderState::inactive(error),
2867 }
2868 }
2869 Err(e) => {
2870 self.order_ids.remove_by_ib_id(ib_order_id);
2871 Order {
2872 key,
2873 side,
2874 price,
2875 quantity: req_quantity,
2876 kind,
2877 time_in_force: tif,
2878 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
2879 e.to_string(),
2880 ))),
2881 }
2882 }
2883 };
2884 self.known_live
2885 .lock()
2886 .placed(&order.key, order.quantity, order.kind, &order.state);
2887 order
2888 }
2889
2890 /// Fetch account balances.
2891 ///
2892 /// # Limitations
2893 ///
2894 /// The `time_exchange` field in returned balances uses `Utc::now()`, not
2895 /// the actual IB server timestamp. IB's account summary endpoint does not
2896 /// provide timestamps per balance update.
2897 ///
2898 /// # Errors
2899 ///
2900 /// [`UnindexedClientError::Internal`] if the request fails, IB answers with an
2901 /// error, IB sends nothing for 10 seconds before the end of the listing, or the
2902 /// connection drops during it.
2903 async fn fetch_balances(
2904 &self,
2905 assets: &[AssetNameExchange],
2906 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
2907 let client = self.client.clone();
2908 let assets_filter: Option<HashSet<AssetNameExchange>> = if assets.is_empty() {
2909 None
2910 } else {
2911 Some(assets.iter().cloned().collect())
2912 };
2913
2914 tokio::task::spawn_blocking(move || {
2915 // IB's reqAccountSummary expects a group name ("All" for all linked accounts),
2916 // not an account ID. Using account ID causes error 321 "Unified group name is invalid".
2917 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
2918 let sub = client
2919 .account_summary(&ACCOUNT_GROUP_ALL, &["TotalCashValue", "AvailableFunds"])
2920 .map_err(|e| UnindexedClientError::Internal(format!("account_summary: {e}")))?;
2921
2922 let mut balances = read_account_summary(
2923 LISTING_STALL_TIMEOUT,
2924 || client.is_connected(),
2925 |timeout| sub.next_timeout(timeout),
2926 )?
2927 .to_balances();
2928
2929 if let Some(ref filter) = assets_filter {
2930 balances.retain(|b| filter.contains(&b.asset));
2931 }
2932
2933 Ok(balances)
2934 })
2935 .await
2936 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
2937 }
2938
2939 /// Fetch the open orders this API client placed.
2940 ///
2941 /// IB numbers orders per API client, so orders other clients placed, and orders entered in
2942 /// TWS, are left out: their ids could name an order of this client's. IB reports orders
2943 /// entered in TWS under API client id 0, so a client connected as 0 keeps them.
2944 ///
2945 /// Each order is returned under the client order id it was placed with: the one this client
2946 /// tracks it under, with the kind, price and time in force it was placed with, or else the
2947 /// one its IB order reference carries, with those read back from IB's listing. An order
2948 /// this client does not track but placed under a client order id, such as one placed before
2949 /// a restart, is tracked from then on, so its fills and status reach the account stream and
2950 /// it can be cancelled. Every listed order of this client's is adopted so, whatever
2951 /// `instruments` asks for. An order without a reference, such as one placed by an earlier
2952 /// version of this client, or, for a client connected as 0, one entered in TWS, is returned
2953 /// under its IB order id; so is one whose reference names another order this client tracks.
2954 /// One whose order type or time in force this client never sends is left out, with a
2955 /// warning. Every order's filled quantity is the one IB reports with it.
2956 ///
2957 /// Each returned order named by the id it was placed with is held as live, for the account
2958 /// stream's check of how it ended after a gap.
2959 ///
2960 /// IB lists every client's open orders to every request for them, so this client and its
2961 /// clones read the listing one at a time.
2962 ///
2963 /// # Errors
2964 ///
2965 /// [`UnindexedClientError::Internal`] if the request fails, IB answers with an
2966 /// error, IB sends nothing for 10 seconds before the end of the listing, or the
2967 /// connection drops during it.
2968 async fn fetch_open_orders(
2969 &self,
2970 instruments: &[InstrumentNameExchange],
2971 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
2972 let client = self.client.clone();
2973 let contracts = self.contracts.clone();
2974 let order_ids = self.order_ids.clone();
2975 let listings = self.listings.clone();
2976 let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
2977 None
2978 } else {
2979 Some(instruments.iter().cloned().collect())
2980 };
2981
2982 let listed = tokio::task::spawn_blocking(move || {
2983 let listing = listings.open_orders(&client)?;
2984 let mut listed =
2985 open_orders_from_listing(listing, client.client_id(), &contracts, &order_ids);
2986 if let Some(filter) = &instruments_filter {
2987 listed.retain(|listed| filter.contains(&listed.instrument));
2988 }
2989 Ok::<_, UnindexedClientError>(listed)
2990 })
2991 .await
2992 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))??;
2993
2994 remember_listed(&self.known_live, &listed);
2995 Ok(listed
2996 .into_iter()
2997 .filter_map(|listed| listed.order)
2998 .collect())
2999 }
3000
3001 /// Fetch historical trades (executions).
3002 ///
3003 /// # Limitations
3004 ///
3005 /// - IB only returns executions from the current trading day. The span is applied
3006 /// client-side to filter within that day, and the read is always complete
3007 /// (`resume: None`) for what IB returned: a span reaching before today is not read
3008 /// before today. For historical executions beyond today, use IB's Flex Query or
3009 /// Activity Statements.
3010 /// - Only the executions of orders this API client placed are read. IB numbers
3011 /// orders per API client, so another client's execution could name an order id
3012 /// of this client's.
3013 /// - Each trade's fees come from the commission report IB sends with its
3014 /// execution. An execution whose report IB did not send is returned with a zero
3015 /// fee in [`UNKNOWN_FEE_ASSET`](execution::UNKNOWN_FEE_ASSET), with a warning.
3016 /// - Trades are returned in `time_exchange` order, then by id. A corrected execution keeps the
3017 /// position of its first revision.
3018 ///
3019 /// # Errors
3020 ///
3021 /// [`UnindexedClientError::Internal`] if the request fails, IB answers with an
3022 /// error, IB sends nothing for 10 seconds before the end of the listing, or the
3023 /// connection drops during it.
3024 ///
3025 /// # Corrections
3026 ///
3027 /// IB reports a corrected execution as a further execution whose id differs
3028 /// only in the digits after the final period. Each execution is returned
3029 /// once, at its latest revision, under that revision's id: the replacement a
3030 /// `TradeAmended` on [`ExecutionClient::account_stream`] names.
3031 async fn fetch_trades(
3032 &self,
3033 start: DateTime<Utc>,
3034 end: DateTime<Utc>,
3035 instruments: &[InstrumentNameExchange],
3036 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
3037 if start > end {
3038 return Ok(TradesRead::complete(Vec::new()));
3039 }
3040 let client = self.client.clone();
3041 let contracts = self.contracts.clone();
3042 let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
3043 None
3044 } else {
3045 Some(instruments.iter().cloned().collect())
3046 };
3047
3048 tokio::task::spawn_blocking(move || {
3049 let api_client_id = client.client_id();
3050 let exec_filter = ibapi::orders::ExecutionFilter {
3051 client_id: Some(api_client_id),
3052 ..ibapi::orders::ExecutionFilter::default()
3053 };
3054 // Note: ibapi errors are unstructured — see comment in account_snapshot() re: Internal
3055 let sub = client
3056 .executions(exec_filter)
3057 .map_err(|e| UnindexedClientError::Internal(format!("executions: {e}")))?;
3058
3059 let mut listing = Vec::new();
3060 read_listing(
3061 "executions",
3062 LISTING_STALL_TIMEOUT,
3063 || client.is_connected(),
3064 |timeout| sub.next_timeout(timeout),
3065 |item| {
3066 listing.push(item);
3067 ControlFlow::Continue(())
3068 },
3069 )?;
3070
3071 let trades = trades_from_executions(
3072 listing,
3073 api_client_id,
3074 &contracts,
3075 start..=end,
3076 instruments_filter.as_ref(),
3077 );
3078 Ok(TradesRead::complete(execution::keep_latest_revisions(
3079 trades,
3080 )))
3081 })
3082 .await
3083 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
3084 }
3085}
3086
3087/// The trades [`ExecutionClient::fetch_trades`] returns from what one executions request
3088/// listed: the executions of this API client's orders, in registered contracts, at a time in
3089/// `span`, in `instruments` when that is given. Each is paired with the commission report IB lists
3090/// after it.
3091///
3092/// An execution IB listed no commission report for is a trade with an unknown fee, warned about.
3093/// The trades are in time order, then by id.
3094fn trades_from_executions(
3095 listing: impl IntoIterator<Item = ibapi::orders::Executions>,
3096 api_client_id: i32,
3097 contracts: &ContractRegistry,
3098 span: std::ops::RangeInclusive<DateTime<Utc>>,
3099 instruments: Option<&HashSet<InstrumentNameExchange>>,
3100) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
3101 use ibapi::orders::Executions;
3102
3103 let awaiting_commission = ExecutionBuffer::new();
3104 let mut trades = Vec::new();
3105 for item in listing {
3106 match item {
3107 Executions::ExecutionData(data) => {
3108 let exec = &data.execution;
3109 if exec.client_id != api_client_id {
3110 // The request asked IB for this client's alone.
3111 debug!(
3112 exec_id = %exec.execution_id,
3113 api_client_id = exec.client_id,
3114 "Execution of another API client's order, skipping"
3115 );
3116 continue;
3117 }
3118 let Some(instrument) = contracts.get_name_by_con_id(data.contract.contract_id)
3119 else {
3120 continue;
3121 };
3122 if instruments.is_some_and(|filter| !filter.contains(&instrument)) {
3123 continue;
3124 }
3125 match execution::parse_ib_timestamp(&exec.time) {
3126 Some(time) if span.contains(&time) => {
3127 awaiting_commission.add_execution(data, instrument);
3128 }
3129 Some(_) => {}
3130 None => warn!(
3131 exec_id = %exec.execution_id,
3132 time = %exec.time,
3133 "Unparseable timestamp in execution, skipping"
3134 ),
3135 }
3136 }
3137 Executions::CommissionReport(report) => {
3138 if let Some(trade) = awaiting_commission.complete_with_commission(&report) {
3139 trades.push(trade);
3140 }
3141 }
3142 }
3143 }
3144
3145 let without_commission = awaiting_commission.take_without_commission();
3146 if !without_commission.is_empty() {
3147 warn!(
3148 count = without_commission.len(),
3149 "IBKR executions arrived without a commission report; returned with an unknown fee"
3150 );
3151 trades.extend(without_commission);
3152 }
3153 // In time order. IB lists commission reports after their executions, and a map holds those
3154 // without one, so neither gives that order.
3155 trades.sort_by(|a, b| {
3156 a.time_exchange
3157 .cmp(&b.time_exchange)
3158 .then_with(|| a.id.cmp(&b.id))
3159 });
3160 trades
3161}
3162
3163/// One of this API client's open orders, read back from an open-orders listing.
3164#[derive(Debug)]
3165struct ListedOpenOrder {
3166 instrument: InstrumentNameExchange,
3167 /// The order, or `None` when it cannot be read back: IB lists a shape this client never
3168 /// sends.
3169 order: Option<Order<ExchangeId, InstrumentNameExchange, Open>>,
3170 /// Whether the order is named by the client order id it was placed with, rather than by its
3171 /// IB order id.
3172 named_as_placed: bool,
3173 /// Whether IB listed the order's status, which carries its fill.
3174 paired_with_status: bool,
3175}
3176
3177impl ListedOpenOrder {
3178 /// Whether the order is reported exactly: read back, under the client order id it was placed
3179 /// with, and with the fill its status reports.
3180 fn is_exact(&self) -> bool {
3181 self.order.is_some() && self.named_as_placed && self.paired_with_status
3182 }
3183}
3184
3185/// This API client's open orders from what one open-orders request listed, by IB order id, each
3186/// read back as [`open_order_from_listing`] does. Orders in contracts not registered, and orders
3187/// that have ended, are left out.
3188fn open_orders_from_listing(
3189 listing: impl IntoIterator<Item = ibapi::orders::Orders>,
3190 api_client_id: i32,
3191 contracts: &ContractRegistry,
3192 order_ids: &OrderIdMap,
3193) -> Vec<ListedOpenOrder> {
3194 use ibapi::orders::Orders;
3195
3196 // IB lists each order, then its status, which carries the fill.
3197 let mut listed = std::collections::BTreeMap::new();
3198 let mut statuses = fnv::FnvHashMap::default();
3199 for item in listing {
3200 match item {
3201 Orders::OrderData(data) if data.order.client_id == api_client_id => {
3202 listed.insert(data.order_id, data);
3203 }
3204 Orders::OrderStatus(status) if status.client_id == api_client_id => {
3205 statuses.insert(status.order_id, status);
3206 }
3207 _ => {}
3208 }
3209 }
3210 listed
3211 .into_values()
3212 .filter_map(|data| {
3213 open_order_from_listing(&data, statuses.get(&data.order_id), contracts, order_ids)
3214 })
3215 .collect()
3216}
3217
3218/// The open order IB lists as `data`, with `status`, the status IB lists after it, or `None` when
3219/// it is not in a registered contract or has ended.
3220///
3221/// An order is named by the client order id it was placed with: the one this client tracks it
3222/// under, or else its order reference, which carries the id ([`order::order_ref`]). Either way an
3223/// order this client tracks keeps the shape it was placed with, and any other is read back by
3224/// [`order::order_shape_from_ib`]. An order named by its reference that this client does not track,
3225/// such as one placed before a restart, is adopted: tracked from then on under that id, so its
3226/// fills and status reach the account stream and it can be cancelled.
3227///
3228/// An order without a reference, such as one placed by an earlier version of this client, is named
3229/// by its IB order id, which is not an id it was placed with, so it is not
3230/// [exact](ListedOpenOrder::is_exact). So is one whose reference names another order this client
3231/// tracks, under that id or its IB order id, which cannot be adopted. Nor is one IB listed without
3232/// its status, whose fill is the order's own figure, which IB leaves zero.
3233fn open_order_from_listing(
3234 data: &ibapi::orders::OrderData,
3235 status: Option<&ibapi::orders::OrderStatus>,
3236 contracts: &ContractRegistry,
3237 order_ids: &OrderIdMap,
3238) -> Option<ListedOpenOrder> {
3239 let ib_id = data.order_id;
3240 let instrument = contracts.get_name_by_con_id(data.contract.contract_id)?;
3241
3242 // IB can still list an order that has just ended.
3243 let state = status.map_or(&data.order_state.status, |status| &status.status);
3244 if state.is_terminal() {
3245 debug!(
3246 ib_order_id = ib_id,
3247 ?state,
3248 "Listed open order has ended, skipping"
3249 );
3250 return None;
3251 }
3252 // The status carries the fill. Without one, the order's own figure, which IB may leave zero.
3253 let filled = status.map_or(data.order.filled_quantity, |status| status.filled);
3254 let filled = try_decimal_or_warn(filled, format_args!("filled quantity of order {ib_id}"))
3255 .unwrap_or(Decimal::ZERO);
3256 let order_ref = (!data.order.order_ref.is_empty())
3257 .then(|| ClientOrderId::new(data.order.order_ref.as_str()));
3258
3259 // The order this client tracks under IB order id `ib_id`, unless that id now names another:
3260 // IB numbers orders per API client and reuses the numbers across sessions.
3261 let tracked = order_ids
3262 .get_client_id_and_context(ib_id)
3263 .filter(|(cid, ctx)| {
3264 ctx.instrument == instrument && order_ref.as_ref().is_none_or(|r| r == cid)
3265 });
3266 let (cid, ctx, named_as_placed) = match tracked {
3267 Some((cid, ctx)) => (cid, ctx, true),
3268 None => {
3269 let shape = match order::order_shape_from_ib(&data.order) {
3270 Ok(shape) => shape,
3271 Err(reason) => {
3272 warn!(
3273 ib_order_id = ib_id,
3274 %instrument,
3275 %reason,
3276 "Open IBKR order cannot be read back, leaving it out"
3277 );
3278 return Some(ListedOpenOrder {
3279 instrument,
3280 order: None,
3281 named_as_placed: false,
3282 paired_with_status: status.is_some(),
3283 });
3284 }
3285 };
3286 let ctx = OrderContext {
3287 instrument: instrument.clone(),
3288 side: order::action_to_side(&data.order.action),
3289 price: shape.price,
3290 quantity: parse_decimal_or_warn(data.order.total_quantity, "total_quantity"),
3291 kind: shape.kind,
3292 time_in_force: shape.time_in_force,
3293 };
3294 match order_ref {
3295 Some(cid) if adopt_listed_order(order_ids, &cid, ib_id, &ctx) => (cid, ctx, true),
3296 // Not trackable under its id, which names another order here, so not reported
3297 // under it either: two orders would share it.
3298 Some(_) | None => (ClientOrderId::new(format_smolstr!("{ib_id}")), ctx, false),
3299 }
3300 }
3301 };
3302
3303 Some(ListedOpenOrder {
3304 instrument,
3305 order: Some(Order {
3306 key: OrderKey {
3307 exchange: ExchangeId::Ibkr,
3308 instrument: ctx.instrument,
3309 strategy: StrategyId::unknown(),
3310 cid,
3311 },
3312 side: ctx.side,
3313 price: ctx.price,
3314 quantity: ctx.quantity,
3315 kind: ctx.kind,
3316 time_in_force: ctx.time_in_force,
3317 state: Open::new(
3318 VenueOrderId::Assigned(execution::ib_order_id(ib_id)),
3319 Utc::now(),
3320 filled,
3321 ),
3322 }),
3323 named_as_placed,
3324 paired_with_status: status.is_some(),
3325 })
3326}
3327
3328/// The open orders of an account snapshot, by instrument, each instrument's with whether they are
3329/// all of its open orders.
3330#[derive(Debug)]
3331struct SnapshotOrders {
3332 by_instrument: fnv::FnvHashMap<InstrumentNameExchange, (Vec<UnindexedOrderSnapshot>, bool)>,
3333 /// Whether the listing was read to its end, so an instrument it shows no order on has none.
3334 read: bool,
3335}
3336
3337impl SnapshotOrders {
3338 /// The orders of `listed`, the listing read, or `None` when it could not be read.
3339 ///
3340 /// An instrument's orders are complete when each of them was read back
3341 /// [exactly](ListedOpenOrder::is_exact): IB lists every open order of the account, unpaged,
3342 /// so only one this client cannot name as placed, or read back, can be missing from them.
3343 fn new(listed: Option<Vec<ListedOpenOrder>>) -> Self {
3344 let read = listed.is_some();
3345 let mut by_instrument = fnv::FnvHashMap::default();
3346 for listed in listed.into_iter().flatten() {
3347 let exact = listed.is_exact();
3348 let (orders, complete): &mut (Vec<_>, bool) = by_instrument
3349 .entry(listed.instrument)
3350 .or_insert_with(|| (Vec::new(), true));
3351 *complete &= exact;
3352 if let Some(order) = listed.order {
3353 orders.push(order.into());
3354 }
3355 }
3356 Self {
3357 by_instrument,
3358 read,
3359 }
3360 }
3361
3362 /// The instruments with an open order.
3363 fn instruments(&self) -> impl Iterator<Item = &InstrumentNameExchange> {
3364 self.by_instrument.keys()
3365 }
3366
3367 /// Take `instrument`'s orders, and whether they are complete.
3368 fn take(&mut self, instrument: &InstrumentNameExchange) -> (Vec<UnindexedOrderSnapshot>, bool) {
3369 self.by_instrument
3370 .remove(instrument)
3371 .unwrap_or_else(|| (Vec::new(), self.read))
3372 }
3373
3374 /// The instruments not yet taken, with their orders.
3375 fn into_rest(
3376 self,
3377 ) -> impl Iterator<Item = (InstrumentNameExchange, (Vec<UnindexedOrderSnapshot>, bool))> {
3378 self.by_instrument.into_iter()
3379 }
3380}
3381
3382/// The client order ids of this API client's orders in an open-orders listing, for the account
3383/// stream's check of how the orders it holds as live ended: each order's reference, or else the
3384/// id this client tracks it under.
3385///
3386/// Unlike [`open_orders_from_listing`], nothing is read back or adopted, and an order IB lists
3387/// with a status that has ended is kept. IB's completed orders may not list it yet, and its end
3388/// reaches the account stream as that status, so the check need not look it up.
3389fn listed_cids(
3390 listing: impl IntoIterator<Item = ibapi::orders::Orders>,
3391 api_client_id: i32,
3392 order_ids: &OrderIdMap,
3393) -> fnv::FnvHashSet<ClientOrderId> {
3394 listing
3395 .into_iter()
3396 .filter_map(|item| match item {
3397 ibapi::orders::Orders::OrderData(data) if data.order.client_id == api_client_id => {
3398 if data.order.order_ref.is_empty() {
3399 order_ids.get_client_id(data.order_id)
3400 } else {
3401 Some(ClientOrderId::new(data.order.order_ref.as_str()))
3402 }
3403 }
3404 _ => None,
3405 })
3406 .collect()
3407}
3408
3409/// Hold each order of `listed` named by the client order id it was placed with as live, for the
3410/// account stream's check of how it ended if the stream misses its end. One named by its IB order
3411/// id could not be looked up by it once ended.
3412fn remember_listed(known: &SharedKnownLiveOrders, listed: &[ListedOpenOrder]) {
3413 let mut known = known.lock();
3414 for listed in listed.iter().filter(|listed| listed.named_as_placed) {
3415 if let Some(order) = &listed.order {
3416 known.live(&order.key, order.quantity, order.kind, &order.state);
3417 }
3418 }
3419}
3420
3421/// Track the listed open order `ib_id` under `cid`, the id its order reference carries, unless
3422/// either already names an order here. Returns whether it is tracked.
3423fn adopt_listed_order(
3424 order_ids: &OrderIdMap,
3425 cid: &ClientOrderId,
3426 ib_id: i32,
3427 ctx: &OrderContext,
3428) -> bool {
3429 match order_ids.adopt(cid.clone(), ib_id, ctx.clone()) {
3430 Ok(()) => {
3431 info!(
3432 ib_order_id = ib_id,
3433 %cid,
3434 instrument = %ctx.instrument,
3435 "Adopted an open IBKR order this client did not track"
3436 );
3437 true
3438 }
3439 Err(refused) => {
3440 warn!(
3441 ib_order_id = ib_id,
3442 %cid,
3443 reason = %refused,
3444 "Listed IBKR order cannot be tracked under its client order id; listing it under \
3445 its IB order id"
3446 );
3447 false
3448 }
3449 }
3450}
3451
3452/// How long after a good-till-date order's expiry a cancellation is still read as the expiry,
3453/// rather than as a cancellation before it: IB's clock and this host's differ, and the status
3454/// takes time to arrive.
3455const GTD_EXPIRY_TOLERANCE: chrono::Duration = chrono::Duration::seconds(5);
3456
3457/// Whether an order IB reports `Cancelled` at `at` expired rather than was cancelled: a cancel of
3458/// it was not requested through this client (`user_cancel`), and it is a day order, or a
3459/// good-till-date order at or past its expiry, give or take [`GTD_EXPIRY_TOLERANCE`]. An order of
3460/// any other time in force was cancelled by the broker or the exchange.
3461fn cancelled_at_expiry(user_cancel: bool, time_in_force: &TimeInForce, at: DateTime<Utc>) -> bool {
3462 !user_cancel
3463 && match time_in_force {
3464 TimeInForce::GoodUntilEndOfDay => true,
3465 TimeInForce::GoodTillDate { expiry } => at + GTD_EXPIRY_TOLERANCE >= *expiry,
3466 _ => false,
3467 }
3468}
3469
3470/// Build an Order from IB OrderStatus using stored OrderContext.
3471///
3472/// # IBKR Status Mapping
3473///
3474/// | `OrderStatusKind` | → OrderState | Rationale |
3475/// |---------------------------------------------|----------------------|---------------------------------------------|
3476/// | `Inactive` | OpenFailed(Rejected) | Order blocked by validation/margin/exchange |
3477/// | `Cancelled` | Cancelled or Expired | See differentiation logic below |
3478/// | `Filled` | FullyFilled | Order fully executed |
3479/// | `Submitted`/`PreSubmitted`/`PendingSubmit` | Active(Open) | Order working on exchange |
3480/// | `ApiPending`/`PendingCancel`/`ApiCancelled` | Active(Open) | Transitional; await a confirmed terminal |
3481/// | `Unknown(raw)` | Active(Open) + warn | Unmodelled status; stream is authoritative |
3482///
3483/// The match is exhaustive over `OrderStatusKind` (no wildcard), so a new ibapi
3484/// *variant* becomes a compile error rather than a silent `Active(Open)`.
3485///
3486/// Since ibapi 4.0 a new TWS *status string* no longer reaches that guard: it
3487/// decodes to `OrderStatusKind::Unknown(raw)` instead of failing the whole
3488/// subscription with `Error::Parse`. This function handles that variant
3489/// explicitly and logs it at `warn!`, so an unrecognised status is observable
3490/// rather than silent — but it is no longer a compile error.
3491///
3492/// # Cancelled vs Expired Differentiation
3493///
3494/// IBKR sends `"Cancelled"` status for both user-initiated cancellation and
3495/// time-based expiration. We differentiate using:
3496///
3497/// 1. If order ID is in `pending_cancels` (set by `cancel_order`) → `Cancelled`
3498/// 2. Else if `time_in_force == GoodUntilEndOfDay` → `Expired` (DAY order expired)
3499/// 3. Else if `time_in_force` is `GoodTillDate` and its expiry has passed, give or take
3500/// [`GTD_EXPIRY_TOLERANCE`] → `Expired`
3501/// 4. Else → `Cancelled` (broker-initiated or external cancellation)
3502///
3503/// # Known Limitation
3504///
3505/// If the broker cancels a DAY order before market close (e.g., insufficient margin),
3506/// or a GTD order is cancelled outside this client, as in TWS, within
3507/// [`GTD_EXPIRY_TOLERANCE`] of its expiry, it will be misclassified as `Expired`. This
3508/// is rare and acceptable given the alternative (forking ibapi to preserve order_id in
3509/// error callbacks).
3510fn make_order_from_status(
3511 status: &ibapi::orders::OrderStatus,
3512 client_id: ClientOrderId,
3513 ctx: &OrderContext,
3514 pending_cancels: &PendingCancels,
3515) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3516{
3517 use ibapi::orders::OrderStatusKind;
3518
3519 let ib_id = status.order_id;
3520 let order_id = execution::ib_order_id(ib_id);
3521
3522 // `None` when IB reports a fill that is not a number, so that an ended order does not report
3523 // an unknown fill as zero.
3524 let reported_fill = try_decimal_or_warn(
3525 status.filled,
3526 format_args!("status.filled of order {ib_id}"),
3527 );
3528 // A live order's fill only grows, so an unknown one reads as nothing filled until IB reports
3529 // more.
3530 let filled_qty = reported_fill.unwrap_or(Decimal::ZERO);
3531 let state = match status.status {
3532 OrderStatusKind::Inactive => {
3533 // "Inactive" means the order was accepted by IB but is not working:
3534 // validation failure, margin issue, exchange closed, share location hold.
3535 // This is a placement failure, not a cancellation.
3536 OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
3537 "IB status: Inactive (order blocked by validation/margin/exchange)".into(),
3538 )))
3539 }
3540 OrderStatusKind::Cancelled => {
3541 let now = Utc::now();
3542 if cancelled_at_expiry(pending_cancels.remove(ib_id), &ctx.time_in_force, now) {
3543 OrderState::inactive(Expired::new(order_id, now, reported_fill))
3544 } else {
3545 OrderState::inactive(Cancelled::new(order_id, now, reported_fill))
3546 }
3547 }
3548 OrderStatusKind::Filled => {
3549 // A filled order filled its whole quantity, whether or not IB's figure parsed.
3550 let filled_qty = reported_fill.unwrap_or(ctx.quantity);
3551 OrderState::fully_filled(Filled::new(order_id, Utc::now(), filled_qty, None))
3552 }
3553 // Working states (Submitted/PreSubmitted/PendingSubmit) and the
3554 // transitional ones (ApiPending/PendingCancel/ApiCancelled) are reported
3555 // as active/Open until a confirmed Cancelled/Inactive/Filled arrives.
3556 // ApiCancelled is deliberately NOT treated as terminal here: it precedes
3557 // a confirmed Cancelled, whose event reaps the order-id mapping (or
3558 // clear_stale does), mirroring the account_stream `is_terminal` guard.
3559 // Enumerated explicitly so a future ibapi variant fails to compile rather
3560 // than silently defaulting to Open.
3561 OrderStatusKind::Submitted
3562 | OrderStatusKind::PreSubmitted
3563 | OrderStatusKind::PendingSubmit
3564 | OrderStatusKind::ApiPending
3565 | OrderStatusKind::PendingCancel
3566 | OrderStatusKind::ApiCancelled => OrderState::active(Open::new(
3567 VenueOrderId::Assigned(order_id),
3568 Utc::now(),
3569 filled_qty,
3570 )),
3571 // A status string ibapi does not model. Upstream declines to classify
3572 // it — `is_active()` and `is_terminal()` are both false — and so do we:
3573 // report it active/Open so the order-id mapping is retained and the
3574 // account stream can still resolve the order's real state. The
3575 // alternative, treating it as a rejection, would drop the mapping for
3576 // an order that may well be live in the market and discard its
3577 // executions and commissions. An order that is in fact dead and left
3578 // Open here is reaped by `OrderIdMap::clear_stale`.
3579 OrderStatusKind::Unknown(ref raw) => {
3580 warn!(
3581 ib_id,
3582 status = raw.as_str(),
3583 "unmodelled IBKR order status; treating as live, account \
3584 stream is authoritative"
3585 );
3586 OrderState::active(Open::new(
3587 VenueOrderId::Assigned(order_id),
3588 Utc::now(),
3589 filled_qty,
3590 ))
3591 }
3592 };
3593
3594 Order {
3595 key: OrderKey {
3596 exchange: ExchangeId::Ibkr,
3597 instrument: ctx.instrument.clone(),
3598 strategy: StrategyId::unknown(),
3599 cid: client_id,
3600 },
3601 side: ctx.side,
3602 price: ctx.price,
3603 quantity: ctx.quantity,
3604 kind: ctx.kind,
3605 time_in_force: ctx.time_in_force,
3606 state,
3607 }
3608}
3609
3610pub use execution::parse_ib_timestamp;
3611
3612impl BracketOrderClient for IbkrClient {
3613 async fn open_bracket_order(
3614 &self,
3615 request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
3616 ) -> UnifiedBracketOrderResult {
3617 let ibkr_request = BracketOrderRequest {
3618 instrument: request.key.instrument.clone(),
3619 strategy: request.key.strategy.clone(),
3620 parent_cid: request.key.cid.clone(),
3621 side: request.state.side,
3622 quantity: request.state.quantity,
3623 entry_price: request.state.entry_price,
3624 take_profit_price: request.state.take_profit_price,
3625 stop_loss_price: request.state.stop_loss_price,
3626 time_in_force: request.state.time_in_force,
3627 };
3628
3629 let result = self.open_bracket_order(ibkr_request).await;
3630
3631 UnifiedBracketOrderResult::with_all_legs(
3632 result.parent,
3633 result.take_profit,
3634 result.stop_loss,
3635 )
3636 }
3637}
3638
3639/// Looks each order up in IB's listing of the account's completed orders, by the order reference
3640/// that carries the client order id it was placed with, so it finds an order this client placed
3641/// before a restart too. One listing is read per call, and, when an order ended cancelled or
3642/// expired, one read of the last seven days' executions for its fill.
3643///
3644/// A filled order is reported fully filled, without an average price; a cancelled or expired one
3645/// with the fill its executions show, which is nothing when they show none of it and the order
3646/// was placed through this client under six days ago, since the read then covers its whole life,
3647/// and otherwise unknown (`None`); a placement IB accepted and then rejected, or left `Inactive`,
3648/// as [`OpenFailed`](crate::order::state::InactiveOrderState::OpenFailed). A cancelled order is
3649/// reported expired by the rule [`ExecutionClient::account_stream`] applies. An order IB lists
3650/// under no client order id, such as one entered in TWS, cannot be found.
3651///
3652/// IB lists the orders completed before a Gateway restart under API client id 0, so a completed
3653/// order is matched by its client order id whichever of this client's id and 0 it is listed
3654/// under. Client order ids must therefore be unique across the API clients on the account.
3655///
3656/// When IB lists several completed orders under one id, the one that completed last is reported.
3657/// One that completed more than 5 seconds before this client began tracking the order now under
3658/// that id is an earlier order's, under a reused id, and the order is not reported ended, with a
3659/// warning: should IB's clock lag this host's by more, an order that did end is held as live.
3660///
3661/// The account stream runs the same lookup itself after a gap in event delivery; see
3662/// [`ExecutionClient::account_stream`].
3663///
3664/// # Errors
3665///
3666/// [`UnindexedClientError::Internal`] when a listing fails, IB sends nothing for 10 seconds
3667/// before the end of one, or the connection drops during it.
3668impl OrderStatusClient for IbkrClient {
3669 async fn fetch_ended_orders(
3670 &self,
3671 orders: &[crate::order::UnindexedOrderKey],
3672 ) -> Result<Vec<crate::order::UnindexedInactiveOrder>, UnindexedClientError> {
3673 if orders.is_empty() {
3674 return Ok(Vec::new());
3675 }
3676 let orders = orders.to_vec();
3677 let client = self.client.clone();
3678 let listings = self.listings.clone();
3679 let contracts = self.contracts.clone();
3680 let order_ids = self.order_ids.clone();
3681 let pending_cancels = self.pending_cancels.clone();
3682 let earlier_completions = self.earlier_completions.clone();
3683 tokio::task::spawn_blocking(move || {
3684 let reader = ended_orders::EndedOrderReader::new(
3685 &client,
3686 &listings,
3687 &contracts,
3688 &order_ids,
3689 &pending_cancels,
3690 &earlier_completions,
3691 );
3692 // Each lookup is answered from listings read on this thread, so the futures are
3693 // ready at once and nothing awaits the runtime.
3694 futures::executor::block_on(crate::client::order_recovery::fetch_ended_by_key(
3695 &orders,
3696 |key| std::future::ready(reader.lookup(&key)),
3697 ))
3698 })
3699 .await
3700 .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
3701 }
3702}
3703
3704#[cfg(test)]
3705#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
3706mod contract_config_tests {
3707 use super::*;
3708 use contract::ContractConfigError as E;
3709
3710 /// A `ContractConfig` with every optional field present, so individual tests
3711 /// can null out exactly the field under test.
3712 fn full_config(security_type: &str) -> ContractConfig {
3713 ContractConfig {
3714 name: "TEST".to_string(),
3715 symbol: "AAPL".to_string(),
3716 security_type: security_type.to_string(),
3717 exchange: "SMART".to_string(),
3718 currency: "USD".to_string(),
3719 last_trade_date: Some("20260116".to_string()),
3720 strike: Some(150.0),
3721 right: Some("C".to_string()),
3722 }
3723 }
3724
3725 #[test]
3726 fn stk_and_cash_ignore_option_fields() {
3727 // STK/CASH never read last_trade_date/strike/right, so absence is fine.
3728 let mut stk = full_config("STK");
3729 stk.last_trade_date = None;
3730 stk.strike = None;
3731 stk.right = None;
3732 assert!(stk.to_contract().is_ok());
3733
3734 let mut cash = full_config("CASH");
3735 cash.last_trade_date = None;
3736 cash.strike = None;
3737 cash.right = None;
3738 assert!(cash.to_contract().is_ok());
3739 }
3740
3741 #[test]
3742 fn valid_fut_and_opt_build() {
3743 assert!(full_config("FUT").to_contract().is_ok());
3744 assert!(full_config("OPT").to_contract().is_ok());
3745 }
3746
3747 #[test]
3748 fn opt_missing_right_is_error_not_silent_call() {
3749 let mut cfg = full_config("OPT");
3750 cfg.right = None;
3751 assert_eq!(cfg.to_contract().unwrap_err(), E::MissingOptionRight);
3752 }
3753
3754 #[test]
3755 fn opt_unrecognized_right_is_error() {
3756 let mut cfg = full_config("OPT");
3757 cfg.right = Some("X".to_string());
3758 assert_eq!(
3759 cfg.to_contract().unwrap_err(),
3760 E::UnrecognizedOptionRight {
3761 right: "X".to_string()
3762 }
3763 );
3764 }
3765
3766 #[test]
3767 fn opt_missing_strike_is_error() {
3768 let mut cfg = full_config("OPT");
3769 cfg.strike = None;
3770 assert_eq!(cfg.to_contract().unwrap_err(), E::MissingStrike);
3771 }
3772
3773 #[test]
3774 fn fut_and_opt_missing_last_trade_date_is_error() {
3775 let mut fut = full_config("FUT");
3776 fut.last_trade_date = None;
3777 assert_eq!(fut.to_contract().unwrap_err(), E::MissingLastTradeDate);
3778
3779 let mut opt = full_config("OPT");
3780 opt.last_trade_date = None;
3781 assert_eq!(opt.to_contract().unwrap_err(), E::MissingLastTradeDate);
3782 }
3783
3784 #[test]
3785 fn unknown_security_type_is_error_not_silent_stk() {
3786 let cfg = full_config("BOND");
3787 assert_eq!(
3788 cfg.to_contract().unwrap_err(),
3789 E::UnrecognizedSecurityType {
3790 security_type: "BOND".to_string()
3791 }
3792 );
3793 }
3794
3795 fn ibkr_config(contracts: Vec<ContractConfig>) -> IbkrConfig {
3796 IbkrConfig {
3797 host: "127.0.0.1".to_string(),
3798 port: 4002,
3799 client_id: 1,
3800 account: String::new(),
3801 contracts,
3802 }
3803 }
3804
3805 fn named(name: &str, security_type: &str) -> ContractConfig {
3806 ContractConfig {
3807 name: name.to_string(),
3808 ..full_config(security_type)
3809 }
3810 }
3811
3812 #[test]
3813 fn validate_config_accepts_each_security_type_its_kind_takes() {
3814 let names = ["STOCK", "FX", "FUTURE", "OPTION"].map(InstrumentNameExchange::new);
3815 let instruments = [
3816 ClientInstrument::new(&names[0], InstrumentKindDiscriminant::Spot),
3817 ClientInstrument::new(&names[1], InstrumentKindDiscriminant::Spot),
3818 ClientInstrument::new(&names[2], InstrumentKindDiscriminant::Future),
3819 ClientInstrument::new(&names[3], InstrumentKindDiscriminant::Option),
3820 ];
3821 let config = ibkr_config(vec![
3822 named("STOCK", "STK"),
3823 named("FX", "CASH"),
3824 named("FUTURE", "FUT"),
3825 named("OPTION", "OPT"),
3826 ]);
3827
3828 assert_eq!(IbkrClient::validate_config(&config, &instruments), Ok(()));
3829 }
3830
3831 /// The disagreement the kind gate cannot see: a stock contract registered for an instrument
3832 /// the engine models as an option would route option orders as stock orders.
3833 #[test]
3834 fn validate_config_rejects_a_security_type_its_instrument_kind_contradicts() {
3835 let name = InstrumentNameExchange::new("AAPL-C150");
3836 let instruments = [ClientInstrument::new(
3837 &name,
3838 InstrumentKindDiscriminant::Option,
3839 )];
3840 let config = ibkr_config(vec![named("AAPL-C150", "STK")]);
3841
3842 let error = IbkrClient::validate_config(&config, &instruments).unwrap_err();
3843 assert!(error.contains("AAPL-C150"), "{error}");
3844 assert!(error.contains("STK"), "{error}");
3845 assert!(error.contains("OPT"), "{error}");
3846 }
3847
3848 /// `connect_sync` refuses an invalid entry only once connected; built through
3849 /// `ExecutionBuilder`, it fails the build before connecting. Every problem is reported, not just the first.
3850 #[test]
3851 fn validate_config_reports_every_invalid_or_contradicting_entry() {
3852 let name = InstrumentNameExchange::new("ES");
3853 let instruments = [ClientInstrument::new(
3854 &name,
3855 InstrumentKindDiscriminant::Future,
3856 )];
3857 let mut missing_date = named("NQ", "FUT");
3858 missing_date.last_trade_date = None;
3859 let config = ibkr_config(vec![named("ES", "STK"), missing_date]);
3860
3861 let error = IbkrClient::validate_config(&config, &instruments).unwrap_err();
3862 assert!(error.contains("\"ES\""), "{error}");
3863 assert!(error.contains("\"NQ\""), "{error}");
3864 }
3865
3866 /// Every (kind, security type) pair: accepted exactly where `security_types_of` lists it, and
3867 /// a kind this client cannot trade rejected whatever the type.
3868 #[test]
3869 fn validate_config_checks_every_kind_against_every_security_type() {
3870 use InstrumentKindDiscriminant as K;
3871 let accepted = [
3872 (K::Spot, "STK"),
3873 (K::Spot, "CASH"),
3874 (K::Future, "FUT"),
3875 (K::Option, "OPT"),
3876 ];
3877 let name = InstrumentNameExchange::new("X");
3878 for kind in [K::Spot, K::Perpetual, K::Future, K::Option, K::Cfd] {
3879 for security_type in ["STK", "CASH", "FUT", "OPT"] {
3880 let instruments = [ClientInstrument::new(&name, kind)];
3881 let config = ibkr_config(vec![named("X", security_type)]);
3882 assert_eq!(
3883 IbkrClient::validate_config(&config, &instruments).is_ok(),
3884 accepted.contains(&(kind, security_type)),
3885 "{kind} with {security_type}"
3886 );
3887 }
3888 }
3889 }
3890
3891 /// `security_types_of` is written separately from `to_contract`'s match, so a type added
3892 /// there must be added here too: an entry naming a type no kind lists fails every check.
3893 #[test]
3894 fn security_types_cover_every_type_to_contract_builds() {
3895 use InstrumentKindDiscriminant as K;
3896 let listed = [K::Spot, K::Perpetual, K::Future, K::Option, K::Cfd]
3897 .into_iter()
3898 .flat_map(security_types_of)
3899 .copied()
3900 .collect::<Vec<_>>();
3901
3902 // Every type listed must be one `to_contract` can build.
3903 for security_type in &listed {
3904 assert!(
3905 full_config(security_type).to_contract().is_ok(),
3906 "{security_type}"
3907 );
3908 }
3909
3910 // Every type `to_contract` builds must be listed. A `match` cannot be enumerated, so this
3911 // names them by hand, and a type added to `to_contract` must be added here as well.
3912 // `BOND` stands for a type it does not build.
3913 for security_type in ["STK", "CASH", "FUT", "OPT", "BOND"] {
3914 assert_eq!(
3915 full_config(security_type).to_contract().is_ok(),
3916 listed.contains(&security_type),
3917 "{security_type}"
3918 );
3919 }
3920 }
3921
3922 /// A kind this client cannot trade is named as such, rather than as a mismatch with no
3923 /// security type to expect.
3924 #[test]
3925 fn validate_config_names_a_kind_this_client_cannot_trade() {
3926 let name = InstrumentNameExchange::new("X");
3927 let instruments = [ClientInstrument::new(
3928 &name,
3929 InstrumentKindDiscriminant::Perpetual,
3930 )];
3931 let config = ibkr_config(vec![named("X", "STK")]);
3932
3933 let error = IbkrClient::validate_config(&config, &instruments).unwrap_err();
3934 assert!(error.contains("perpetual"), "{error}");
3935 assert!(error.contains("cannot trade"), "{error}");
3936 }
3937
3938 /// A contract can also be registered later with `register_contract`, so neither side of an
3939 /// unmatched pair is an error here.
3940 #[test]
3941 fn validate_config_accepts_entries_and_instruments_without_a_counterpart() {
3942 let name = InstrumentNameExchange::new("MSFT");
3943 let instruments = [ClientInstrument::new(
3944 &name,
3945 InstrumentKindDiscriminant::Spot,
3946 )];
3947 let config = ibkr_config(vec![named("SOMETHING-ELSE", "OPT")]);
3948
3949 assert_eq!(IbkrClient::validate_config(&config, &instruments), Ok(()));
3950 }
3951}
3952
3953#[cfg(test)]
3954#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
3955mod order_status_tests {
3956 use super::*;
3957 use crate::order::state::ActiveOrderState;
3958 use ibapi::orders::{OrderStatus, OrderStatusKind, PlaceOrder};
3959
3960 fn status(kind: OrderStatusKind, filled: f64) -> Result<PlaceOrder, ibapi::Error> {
3961 Ok(PlaceOrder::OrderStatus(OrderStatus {
3962 order_id: 42,
3963 status: kind,
3964 filled,
3965 ..OrderStatus::default()
3966 }))
3967 }
3968
3969 fn ctx() -> OrderContext {
3970 OrderContext {
3971 instrument: InstrumentNameExchange::new("AAPL"),
3972 side: Side::Buy,
3973 price: Some(Decimal::from(150)),
3974 quantity: Decimal::from(10),
3975 kind: OrderKind::Limit,
3976 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
3977 }
3978 }
3979
3980 /// ibapi 4.0 preserves an unrecognised TWS status as
3981 /// `OrderStatusKind::Unknown(raw)` instead of failing the subscription with
3982 /// `Error::Parse`. Placement must treat it as live-but-indeterminate: the
3983 /// order exists, so the order-id mapping has to survive for the account
3984 /// stream to resolve it.
3985 #[test]
3986 fn unknown_status_is_held_pending_during_placement() {
3987 let events = vec![status(OrderStatusKind::Unknown("Reclassified".into()), 0.0)];
3988
3989 match await_order_placement(events) {
3990 PlacementOutcome::HeldPending(reason) => {
3991 assert!(
3992 reason.contains("Reclassified"),
3993 "the raw status must reach the caller, got {reason:?}"
3994 );
3995 }
3996 other => panic!("expected HeldPending, got {other:?}"),
3997 }
3998 }
3999
4000 /// Specifically *not* the `ApiPending` treatment. Falling through to the
4001 /// `PLACEMENT_STATUS_TIMEOUT` backstop yields `NoStatus`, which on the
4002 /// bracket path cancels all three legs — so an unrecognised status must
4003 /// produce a decision here rather than deferring to the timeout.
4004 #[test]
4005 fn unknown_status_does_not_fall_through_to_no_status() {
4006 let events = vec![status(OrderStatusKind::Unknown("Reclassified".into()), 0.0)];
4007 assert!(!matches!(
4008 await_order_placement(events),
4009 PlacementOutcome::NoStatus
4010 ));
4011
4012 // ApiPending, by contrast, keeps waiting and exhausts the iterator.
4013 let events = vec![status(OrderStatusKind::ApiPending, 0.0)];
4014 assert!(matches!(
4015 await_order_placement(events),
4016 PlacementOutcome::NoStatus
4017 ));
4018 }
4019
4020 /// ibapi 4.2 fails an in-flight placement with a transport error the moment
4021 /// the socket drops. TWS may already hold the order, so it must stay
4022 /// trackable (`NoStatus` keeps the order-id mapping), not read as rejected.
4023 #[test]
4024 fn transport_loss_while_awaiting_placement_is_no_status() {
4025 for e in [
4026 ibapi::Error::ConnectionReset,
4027 ibapi::Error::ConnectionFailed,
4028 ibapi::Error::Shutdown,
4029 ibapi::Error::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
4030 ] {
4031 let label = e.to_string();
4032 assert!(
4033 matches!(
4034 await_order_placement(vec![Err(e)]),
4035 PlacementOutcome::NoStatus
4036 ),
4037 "{label} must leave the placement unresolved, not rejected"
4038 );
4039 }
4040
4041 // A status that arrived before the drop still decides.
4042 let events = vec![
4043 status(OrderStatusKind::Submitted, 0.0),
4044 Err(ibapi::Error::ConnectionReset),
4045 ];
4046 assert!(matches!(
4047 await_order_placement(events),
4048 PlacementOutcome::Accepted { .. }
4049 ));
4050 }
4051
4052 #[test]
4053 fn non_transport_error_while_awaiting_placement_is_rejected() {
4054 let events = vec![Err(ibapi::Error::Simple("boom".into()))];
4055 assert!(matches!(
4056 await_order_placement(events),
4057 PlacementOutcome::Rejected(reason) if reason.contains("boom")
4058 ));
4059 }
4060
4061 /// A send refused because the transport is down or reconnecting never
4062 /// reached TWS: it is a transient connectivity error, not a venue rejection.
4063 #[test]
4064 fn send_error_separates_transport_loss_from_refusal() {
4065 let refused: OrderError<AssetNameExchange, InstrumentNameExchange> =
4066 send_error(&ibapi::Error::ConnectionReset, "down".into());
4067 assert!(
4068 matches!(&refused, OrderError::Connectivity(ConnectivityError::Socket(m)) if m == "down")
4069 );
4070 assert!(refused.is_transient());
4071
4072 let io: OrderError<AssetNameExchange, InstrumentNameExchange> = send_error(
4073 &ibapi::Error::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
4074 "io".into(),
4075 );
4076 assert!(io.is_transient());
4077
4078 // ibapi has given up reconnecting: retrying cannot succeed.
4079 for e in [
4080 ibapi::Error::Shutdown,
4081 ibapi::Error::ConnectionFailed,
4082 ibapi::Error::Simple("no".into()),
4083 ] {
4084 let label = e.to_string();
4085 let rejected: OrderError<AssetNameExchange, InstrumentNameExchange> =
4086 send_error(&e, "no".into());
4087 assert!(
4088 matches!(&rejected, OrderError::Rejected(ApiError::OrderRejected(m)) if m == "no"),
4089 "{label} must be a non-transient refusal"
4090 );
4091 assert!(!rejected.is_transient(), "{label}");
4092 }
4093 }
4094
4095 /// Regression anchors: the statuses that were already decisive must keep
4096 /// their outcomes across the 4.0 migration.
4097 #[test]
4098 fn known_statuses_keep_their_placement_outcomes() {
4099 assert!(matches!(
4100 await_order_placement(vec![status(OrderStatusKind::Submitted, 0.0)]),
4101 PlacementOutcome::Accepted { .. }
4102 ));
4103 assert!(matches!(
4104 await_order_placement(vec![status(OrderStatusKind::Filled, 10.0)]),
4105 PlacementOutcome::Accepted { filled } if filled == 10.0
4106 ));
4107 assert!(matches!(
4108 await_order_placement(vec![status(OrderStatusKind::Inactive, 0.0)]),
4109 PlacementOutcome::Rejected(_)
4110 ));
4111 assert!(matches!(
4112 await_order_placement(vec![status(OrderStatusKind::Cancelled, 0.0)]),
4113 PlacementOutcome::Rejected(_)
4114 ));
4115 }
4116
4117 /// An unmodelled status must not be reported as terminal. Treating it as a
4118 /// rejection would drop the order-id mapping for an order that may well be
4119 /// live in the market, discarding its executions and commissions.
4120 #[test]
4121 fn unknown_status_maps_to_active_open() {
4122 let raw = OrderStatus {
4123 order_id: 42,
4124 status: OrderStatusKind::Unknown("Reclassified".into()),
4125 filled: 3.0,
4126 ..OrderStatus::default()
4127 };
4128
4129 let order = make_order_from_status(
4130 &raw,
4131 ClientOrderId::new("cid-1"),
4132 &ctx(),
4133 &PendingCancels::new(),
4134 );
4135
4136 assert!(
4137 matches!(order.state, OrderState::Active(_)),
4138 "unmodelled status must stay active, got {:?}",
4139 order.state
4140 );
4141 }
4142
4143 /// A cancelled order carries what IB reports filled, and a fill that is not a number stays
4144 /// unknown rather than reading as zero; a filled order filled its whole quantity regardless.
4145 #[test]
4146 fn an_ended_status_carries_its_fill_or_none() {
4147 let ended = |status: OrderStatusKind, filled: f64| {
4148 let raw = OrderStatus {
4149 order_id: 42,
4150 status,
4151 filled,
4152 ..OrderStatus::default()
4153 };
4154 make_order_from_status(
4155 &raw,
4156 ClientOrderId::new("cid-1"),
4157 &ctx(),
4158 &PendingCancels::new(),
4159 )
4160 .state
4161 };
4162 let cancelled_fill = |state: OrderState<_, _>| match state {
4163 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
4164 cancelled.filled_quantity
4165 }
4166 other => panic!("expected Cancelled, got {other:?}"),
4167 };
4168
4169 assert_eq!(
4170 cancelled_fill(ended(OrderStatusKind::Cancelled, 3.0)),
4171 Some(Decimal::from(3))
4172 );
4173 assert_eq!(
4174 cancelled_fill(ended(OrderStatusKind::Cancelled, f64::NAN)),
4175 None
4176 );
4177 assert!(matches!(
4178 ended(OrderStatusKind::Filled, f64::NAN),
4179 OrderState::Inactive(crate::order::state::InactiveOrderState::FullyFilled(Filled { filled_quantity, .. }))
4180 if filled_quantity == ctx().quantity
4181 ));
4182 }
4183
4184 /// A good-till-date order cancelled at or past its expiry, with no cancel requested, expired.
4185 /// One cancelled before its expiry was cancelled.
4186 #[test]
4187 fn a_good_till_date_order_cancelled_at_its_expiry_expired() {
4188 let cancelled_at = |expiry: DateTime<Utc>| {
4189 let ctx = OrderContext {
4190 time_in_force: TimeInForce::GoodTillDate { expiry },
4191 ..ctx()
4192 };
4193 let raw = OrderStatus {
4194 order_id: 42,
4195 status: OrderStatusKind::Cancelled,
4196 ..OrderStatus::default()
4197 };
4198 make_order_from_status(
4199 &raw,
4200 ClientOrderId::new("cid-1"),
4201 &ctx,
4202 &PendingCancels::new(),
4203 )
4204 .state
4205 };
4206 use crate::order::state::InactiveOrderState;
4207
4208 assert!(matches!(
4209 cancelled_at(Utc::now() - chrono::Duration::minutes(1)),
4210 OrderState::Inactive(InactiveOrderState::Expired(_))
4211 ));
4212 // IB's clock may run ahead of this host's.
4213 assert!(matches!(
4214 cancelled_at(Utc::now() + chrono::Duration::seconds(2)),
4215 OrderState::Inactive(InactiveOrderState::Expired(_))
4216 ));
4217 assert!(matches!(
4218 cancelled_at(Utc::now() + chrono::Duration::hours(1)),
4219 OrderState::Inactive(InactiveOrderState::Cancelled(_))
4220 ));
4221 }
4222
4223 /// An unmodelled status must not consume a pending cancel: the entry has to
4224 /// survive for the confirmed `Cancelled` that may still follow.
4225 #[test]
4226 fn unknown_status_leaves_pending_cancel_intact() {
4227 let pending = PendingCancels::new();
4228 pending.insert(42);
4229
4230 let raw = OrderStatus {
4231 order_id: 42,
4232 status: OrderStatusKind::Unknown("Reclassified".into()),
4233 ..OrderStatus::default()
4234 };
4235 let _ = make_order_from_status(&raw, ClientOrderId::new("cid-1"), &ctx(), &pending);
4236
4237 assert!(
4238 pending.remove(42),
4239 "the pending cancel must still be there for the confirmed Cancelled"
4240 );
4241 }
4242
4243 /// A rejected leg is settled even when its rollback cancel was refused: its
4244 /// terminal status will not be replayed, so keeping it Open would leave a
4245 /// phantom order.
4246 #[test]
4247 fn rejected_leg_with_failed_cancel_is_settled() {
4248 let accepted: Option<Result<f64, String>> = Some(Ok(0.0));
4249 let rejected: Option<Result<f64, String>> = Some(Err("Cancelled".into()));
4250
4251 assert_eq!(
4252 unresolved_legs([(1, &rejected), (2, &accepted), (3, &accepted)], &[1, 2, 3]),
4253 vec![2, 3]
4254 );
4255 }
4256
4257 /// A later leg's send failure keeps `send_error`'s classification only when
4258 /// every rollback cancel went out; otherwise it is a non-transient
4259 /// rejection naming the cancels that failed.
4260 #[test]
4261 fn leg_send_error_is_transient_only_after_a_clean_rollback() {
4262 let clean = leg_send_error(&ibapi::Error::ConnectionReset, "sl failed".into(), "");
4263 assert!(matches!(clean, OrderError::Connectivity(_)));
4264
4265 let dirty = leg_send_error(
4266 &ibapi::Error::ConnectionReset,
4267 "sl failed".into(),
4268 "; rollback cancel failed for order ids 10 (connection reset), which may be live",
4269 );
4270 match dirty {
4271 OrderError::Rejected(ApiError::OrderRejected(message)) => {
4272 assert!(message.starts_with("sl failed; rollback cancel failed"));
4273 }
4274 other => panic!("expected a non-transient rejection, got {other:?}"),
4275 }
4276 }
4277
4278 /// A leg is unresolved when it reported no status, or was accepted and its
4279 /// rollback cancel was refused; a leg whose cancel went out is settled.
4280 #[test]
4281 fn unresolved_legs_are_no_status_or_failed_cancel() {
4282 let accepted: Option<Result<f64, String>> = Some(Ok(0.0));
4283 let rejected: Option<Result<f64, String>> = Some(Err("201".into()));
4284 let no_status: Option<Result<f64, String>> = None;
4285
4286 assert_eq!(
4287 unresolved_legs([(1, &accepted), (2, &rejected), (3, &no_status)], &[]),
4288 vec![3]
4289 );
4290 assert_eq!(
4291 unresolved_legs([(1, &accepted), (2, &rejected), (3, &accepted)], &[1]),
4292 vec![1]
4293 );
4294 assert_eq!(
4295 unresolved_legs([(1, &no_status), (2, &no_status), (3, &accepted)], &[2]),
4296 vec![1, 2]
4297 );
4298 assert!(unresolved_legs([(1, &accepted), (2, &rejected), (3, &accepted)], &[]).is_empty());
4299 }
4300
4301 fn bracket_request() -> BracketOrderRequest {
4302 BracketOrderRequest {
4303 instrument: InstrumentNameExchange::new("AAPL"),
4304 strategy: StrategyId::new("test"),
4305 parent_cid: ClientOrderId::new("br"),
4306 side: Side::Buy,
4307 quantity: Decimal::from(10),
4308 entry_price: Decimal::from(150),
4309 take_profit_price: Decimal::from(160),
4310 stop_loss_price: Decimal::from(145),
4311 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
4312 }
4313 }
4314
4315 /// An unresolved leg comes back Open under its IB id and keeps its mapping,
4316 /// so the account stream can still resolve it; a settled leg comes back
4317 /// Inactive with the error and its mapping is released.
4318 #[test]
4319 fn failed_bracket_keeps_unresolved_legs_open_and_tracked() {
4320 let request = bracket_request();
4321 let (tp_cid, sl_cid) = derive_child_cids(&request.parent_cid);
4322 let order_ids = OrderIdMap::new();
4323 order_ids
4324 .register(request.parent_cid.clone(), 10, ctx())
4325 .unwrap();
4326 order_ids.register(tp_cid, 11, ctx()).unwrap();
4327 order_ids.register(sl_cid, 12, ctx()).unwrap();
4328
4329 let result = failed_bracket(
4330 &request,
4331 [10, 11, 12],
4332 BracketFailure {
4333 error: OrderError::Rejected(ApiError::OrderRejected("boom".into())),
4334 unresolved: vec![11],
4335 },
4336 &order_ids,
4337 );
4338
4339 assert!(matches!(result.parent.state, OrderState::Inactive(_)));
4340 assert!(matches!(result.stop_loss.state, OrderState::Inactive(_)));
4341 match &result.take_profit.state {
4342 OrderState::Active(ActiveOrderState::Open(open)) => {
4343 assert_eq!(open.id.assigned(), Some(&OrderId::new("11")));
4344 assert_eq!(open.filled_quantity, Decimal::ZERO);
4345 }
4346 other => panic!("unresolved leg must be Open, got {other:?}"),
4347 }
4348
4349 assert!(order_ids.get_client_id(10).is_none());
4350 assert!(order_ids.get_client_id(11).is_some());
4351 assert!(order_ids.get_client_id(12).is_none());
4352 }
4353
4354 /// With no unresolved leg the failure keeps its all-inactive shape and
4355 /// releases every mapping.
4356 #[test]
4357 fn failed_bracket_without_unresolved_legs_is_all_inactive() {
4358 let request = bracket_request();
4359 let (tp_cid, sl_cid) = derive_child_cids(&request.parent_cid);
4360 let order_ids = OrderIdMap::new();
4361 order_ids
4362 .register(request.parent_cid.clone(), 10, ctx())
4363 .unwrap();
4364 order_ids.register(tp_cid, 11, ctx()).unwrap();
4365 order_ids.register(sl_cid, 12, ctx()).unwrap();
4366
4367 let result = failed_bracket(
4368 &request,
4369 [10, 11, 12],
4370 BracketFailure {
4371 error: OrderError::Rejected(ApiError::OrderRejected("boom".into())),
4372 unresolved: Vec::new(),
4373 },
4374 &order_ids,
4375 );
4376
4377 for leg in [&result.parent, &result.take_profit, &result.stop_loss] {
4378 assert!(matches!(leg.state, OrderState::Inactive(_)));
4379 }
4380 for ib_id in [10, 11, 12] {
4381 assert!(order_ids.get_client_id(ib_id).is_none());
4382 }
4383 }
4384}
4385
4386#[cfg(test)]
4387#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
4388mod order_reader_tests {
4389 use super::*;
4390 use crate::client::dedup::new_dedup_cache;
4391 use crate::trade::TradeId;
4392 use ibapi::orders::{OrderStatus, OrderUpdate};
4393 use std::cell::Cell;
4394
4395 /// Status updates for an order this client did not place, so none is forwarded.
4396 fn unforwarded(n: usize) -> Vec<Result<OrderUpdate, ibapi::Error>> {
4397 (0..n)
4398 .map(|_| {
4399 Ok(OrderUpdate::OrderStatus(OrderStatus {
4400 order_id: 999,
4401 ..OrderStatus::default()
4402 }))
4403 })
4404 .collect()
4405 }
4406
4407 /// Run the reader over `updates` and return how many of them it pulled.
4408 fn run(sink: &recovery::EventSink, updates: Vec<Result<OrderUpdate, ibapi::Error>>) -> usize {
4409 let pulled = Cell::new(0);
4410 forward_order_updates(
4411 updates
4412 .into_iter()
4413 .inspect(|_| pulled.set(pulled.get() + 1)),
4414 sink,
4415 0,
4416 &ContractRegistry::new(),
4417 &OrderIdMap::new(),
4418 &PendingCancels::new(),
4419 &ExecutionBuffer::new(),
4420 );
4421 pulled.get()
4422 }
4423
4424 #[test]
4425 fn stops_on_the_first_update_after_the_stream_ends() {
4426 let (tx, _rx) = mpsc::unbounded_channel();
4427 let sink = recovery::EventSink::new(
4428 tx,
4429 new_dedup_cache(),
4430 KnownLiveOrders::shared(ExchangeId::Ibkr),
4431 );
4432 sink.terminate(StreamTerminationReason::Error("recovery failed".into()));
4433
4434 assert_eq!(run(&sink, unforwarded(3)), 1);
4435 }
4436
4437 #[test]
4438 fn stops_on_the_first_update_after_the_consumer_goes() {
4439 let (tx, rx) = mpsc::unbounded_channel();
4440 let sink = recovery::EventSink::new(
4441 tx,
4442 new_dedup_cache(),
4443 KnownLiveOrders::shared(ExchangeId::Ibkr),
4444 );
4445 drop(rx);
4446
4447 assert_eq!(run(&sink, unforwarded(3)), 1);
4448 }
4449
4450 /// How each terminal status leaves an order's client id and IB-id entry: `Filled` frees the
4451 /// id and keeps the entry for late executions, `Cancelled`/`Inactive` remove both, and a
4452 /// working status keeps both.
4453 #[test]
4454 fn a_terminal_status_frees_the_orders_client_id() {
4455 use ibapi::orders::OrderStatusKind;
4456
4457 for (kind, entry_kept, id_held) in [
4458 (OrderStatusKind::Filled, true, false),
4459 (OrderStatusKind::Cancelled, false, false),
4460 (OrderStatusKind::Inactive, false, false),
4461 (OrderStatusKind::Submitted, true, true),
4462 ] {
4463 let (tx, mut rx) = mpsc::unbounded_channel();
4464 let sink = recovery::EventSink::new(
4465 tx,
4466 new_dedup_cache(),
4467 KnownLiveOrders::shared(ExchangeId::Ibkr),
4468 );
4469 let order_ids = OrderIdMap::new();
4470 let cid = ClientOrderId::new("cid-7");
4471 order_ids
4472 .register(
4473 cid.clone(),
4474 7,
4475 OrderContext {
4476 instrument: InstrumentNameExchange::new("AAPL"),
4477 side: Side::Buy,
4478 price: Some(Decimal::from(100)),
4479 quantity: Decimal::ONE,
4480 kind: OrderKind::Limit,
4481 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
4482 },
4483 )
4484 .unwrap();
4485
4486 forward_order_updates(
4487 [Ok(OrderUpdate::OrderStatus(OrderStatus {
4488 order_id: 7,
4489 status: kind.clone(),
4490 ..OrderStatus::default()
4491 }))],
4492 &sink,
4493 0,
4494 &ContractRegistry::new(),
4495 &order_ids,
4496 &PendingCancels::new(),
4497 &ExecutionBuffer::new(),
4498 );
4499
4500 assert!(rx.try_recv().is_ok(), "{kind:?}: the status is forwarded");
4501 assert_eq!(
4502 order_ids.get_client_id(7).is_some(),
4503 entry_kept,
4504 "{kind:?}: entry"
4505 );
4506 assert_eq!(order_ids.get_ib_id(&cid).is_some(), id_held, "{kind:?}: id");
4507 }
4508 }
4509
4510 /// Another API client's status under a tracked order's IB id is not this client's order's:
4511 /// IB numbers orders per client. It is dropped, and the tracked order keeps its entry.
4512 #[test]
4513 fn another_api_clients_status_does_not_reach_a_tracked_order() {
4514 use ibapi::orders::OrderStatusKind;
4515
4516 let (tx, mut rx) = mpsc::unbounded_channel();
4517 let sink = recovery::EventSink::new(
4518 tx,
4519 new_dedup_cache(),
4520 KnownLiveOrders::shared(ExchangeId::Ibkr),
4521 );
4522 let order_ids = OrderIdMap::new();
4523 let cid = ClientOrderId::new("cid-7");
4524 order_ids
4525 .register(
4526 cid.clone(),
4527 7,
4528 OrderContext {
4529 instrument: InstrumentNameExchange::new("AAPL"),
4530 side: Side::Buy,
4531 price: Some(Decimal::from(100)),
4532 quantity: Decimal::ONE,
4533 kind: OrderKind::Limit,
4534 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
4535 },
4536 )
4537 .unwrap();
4538
4539 forward_order_updates(
4540 [Ok(OrderUpdate::OrderStatus(OrderStatus {
4541 order_id: 7,
4542 client_id: 905,
4543 status: OrderStatusKind::Cancelled,
4544 ..OrderStatus::default()
4545 }))],
4546 &sink,
4547 903,
4548 &ContractRegistry::new(),
4549 &order_ids,
4550 &PendingCancels::new(),
4551 &ExecutionBuffer::new(),
4552 );
4553
4554 assert!(
4555 matches!(
4556 rx.try_recv().map(|event| event.kind),
4557 Ok(AccountEventKind::StreamTerminated(_))
4558 ),
4559 "nothing forwarded before the stream ended"
4560 );
4561 assert_eq!(order_ids.get_ib_id(&cid), Some(7));
4562 assert!(order_ids.contains(7));
4563 }
4564
4565 /// A status IB re-sends for a filled order, after its id names a later order, is dropped
4566 /// rather than read as the later order's, which it would end. The later order's own status is
4567 /// forwarded.
4568 #[test]
4569 fn a_resent_status_for_an_ended_order_does_not_reach_a_reused_id() {
4570 use ibapi::orders::OrderStatusKind;
4571
4572 let (tx, mut rx) = mpsc::unbounded_channel();
4573 let sink = recovery::EventSink::new(
4574 tx,
4575 new_dedup_cache(),
4576 KnownLiveOrders::shared(ExchangeId::Ibkr),
4577 );
4578 let order_ids = OrderIdMap::new();
4579 let cid = ClientOrderId::new("reused");
4580 let ctx = || OrderContext {
4581 instrument: InstrumentNameExchange::new("AAPL"),
4582 side: Side::Buy,
4583 price: Some(Decimal::from(100)),
4584 quantity: Decimal::ONE,
4585 kind: OrderKind::Limit,
4586 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
4587 };
4588 let status = |order_id, status| {
4589 Ok(OrderUpdate::OrderStatus(OrderStatus {
4590 order_id,
4591 status,
4592 ..OrderStatus::default()
4593 }))
4594 };
4595 order_ids.register(cid.clone(), 1, ctx()).unwrap();
4596 // One stream, which ends with the updates: the first order fills, its id is registered
4597 // again for a second order, then IB re-sends the first order's fill.
4598 let updates = [status(1, OrderStatusKind::Filled)]
4599 .into_iter()
4600 .chain(std::iter::once_with(|| {
4601 order_ids.register(cid.clone(), 2, ctx()).unwrap();
4602 status(1, OrderStatusKind::Filled)
4603 }))
4604 .chain([status(2, OrderStatusKind::Submitted)]);
4605 forward_order_updates(
4606 updates,
4607 &sink,
4608 0,
4609 &ContractRegistry::new(),
4610 &order_ids,
4611 &PendingCancels::new(),
4612 &ExecutionBuffer::new(),
4613 );
4614
4615 let Ok(first) = rx.try_recv() else {
4616 panic!("the first order's fill is forwarded");
4617 };
4618 assert!(
4619 matches!(first.kind, AccountEventKind::OrderSnapshot(Snapshot(ref order)) if !matches!(order.state, OrderState::Active(_))),
4620 "{first:?}"
4621 );
4622 let Ok(event) = rx.try_recv() else {
4623 panic!("the later order's status is forwarded");
4624 };
4625 let AccountEventKind::OrderSnapshot(Snapshot(order)) = event.kind else {
4626 panic!("an order snapshot, got {event:?}");
4627 };
4628 assert!(
4629 matches!(order.state, OrderState::Active(_)),
4630 "it is the later, working order's: {order:?}"
4631 );
4632 assert!(
4633 matches!(
4634 rx.try_recv().map(|event| event.kind),
4635 Ok(AccountEventKind::StreamTerminated(_))
4636 ),
4637 "and the re-sent fill was dropped: nothing else before the stream ended"
4638 );
4639 assert_eq!(order_ids.get_ib_id(&cid), Some(2));
4640 }
4641
4642 /// An execution and its correction, each completed by its commission report, reach the
4643 /// stream as a trade and an amendment of it, never as two trades.
4644 #[test]
4645 fn a_corrected_execution_is_reported_as_an_amendment() {
4646 use crate::trade::TradeAmendmentKind;
4647 use ibapi::{
4648 contracts::Contract,
4649 orders::{CommissionReport, Execution, ExecutionData},
4650 };
4651
4652 let contracts = ContractRegistry::new();
4653 contracts
4654 .register(
4655 InstrumentNameExchange::new("AAPL"),
4656 Contract {
4657 contract_id: 265598,
4658 ..Contract::default()
4659 },
4660 )
4661 .unwrap();
4662 let order_ids = OrderIdMap::new();
4663 order_ids
4664 .register(
4665 ClientOrderId::new("cid-7"),
4666 7,
4667 OrderContext {
4668 instrument: InstrumentNameExchange::new("AAPL"),
4669 side: Side::Buy,
4670 price: Some(Decimal::from(100)),
4671 quantity: Decimal::ONE,
4672 kind: OrderKind::Limit,
4673 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
4674 },
4675 )
4676 .unwrap();
4677 let execution = |exec_id: &str, price: f64| {
4678 Ok(OrderUpdate::ExecutionData(ExecutionData {
4679 // TWS tags an execution happening now with -1.
4680 request_id: -1,
4681 contract: Contract {
4682 contract_id: 265598,
4683 ..Contract::default()
4684 },
4685 execution: Execution {
4686 order_id: 7,
4687 execution_id: exec_id.to_string(),
4688 time: "20261005 14:00:00 UTC".to_string(),
4689 shares: 1.0,
4690 price,
4691 cumulative_quantity: 1.0,
4692 ..Execution::default()
4693 },
4694 }))
4695 };
4696 let commission = |exec_id: &str| {
4697 Ok(OrderUpdate::CommissionReport(CommissionReport {
4698 execution_id: exec_id.to_string(),
4699 commission: 1.0,
4700 currency: "USD".to_string(),
4701 ..CommissionReport::default()
4702 }))
4703 };
4704
4705 let (tx, mut rx) = mpsc::unbounded_channel();
4706 let sink = recovery::EventSink::new(
4707 tx,
4708 new_dedup_cache(),
4709 KnownLiveOrders::shared(ExchangeId::Ibkr),
4710 );
4711 forward_order_updates(
4712 [
4713 execution("0000e0d5.5f8b1c2a.01.01", 100.0),
4714 commission("0000e0d5.5f8b1c2a.01.01"),
4715 execution("0000e0d5.5f8b1c2a.01.02", 100.5),
4716 commission("0000e0d5.5f8b1c2a.01.02"),
4717 ],
4718 &sink,
4719 0,
4720 &contracts,
4721 &order_ids,
4722 &PendingCancels::new(),
4723 &ExecutionBuffer::new(),
4724 );
4725
4726 let AccountEventKind::Trade(original) = rx.try_recv().unwrap().kind else {
4727 panic!("expected the original as a trade");
4728 };
4729 assert_eq!(original.id, TradeId::new("0000e0d5.5f8b1c2a.01.01"));
4730 let AccountEventKind::TradeAmended(amendment) = rx.try_recv().unwrap().kind else {
4731 panic!("expected the correction as an amendment");
4732 };
4733 assert_eq!(amendment.original, Some(original.id));
4734 // The IB order id, which the order's `Open` state carries, not the client order id.
4735 assert_eq!(amendment.order_id, OrderId::new("7"));
4736 let TradeAmendmentKind::Corrected { replacement } = amendment.kind else {
4737 panic!("expected a resolved correction, got {:?}", amendment.kind);
4738 };
4739 assert_eq!(replacement.id, TradeId::new("0000e0d5.5f8b1c2a.01.02"));
4740 assert_eq!(replacement.price, Decimal::new(1005, 1));
4741 assert_eq!(replacement.fees.fees, Decimal::ONE);
4742 assert!(matches!(
4743 rx.try_recv().unwrap().kind,
4744 AccountEventKind::StreamTerminated(_)
4745 ));
4746 }
4747
4748 #[test]
4749 fn a_shutdown_terminates_the_stream_as_one() {
4750 let (tx, mut rx) = mpsc::unbounded_channel();
4751 let sink = recovery::EventSink::new(
4752 tx,
4753 new_dedup_cache(),
4754 KnownLiveOrders::shared(ExchangeId::Ibkr),
4755 );
4756
4757 let mut updates = unforwarded(1);
4758 updates.push(Err(ibapi::Error::Shutdown));
4759 updates.extend(unforwarded(1));
4760
4761 assert_eq!(run(&sink, updates), 2, "nothing is read past the shutdown");
4762 assert!(matches!(
4763 rx.try_recv().unwrap().kind,
4764 AccountEventKind::StreamTerminated(StreamTerminationReason::Error(ref reason))
4765 if reason == recovery::CLIENT_SHUT_DOWN
4766 ));
4767 assert!(
4768 rx.try_recv().is_err(),
4769 "the stream ends with its termination"
4770 );
4771 }
4772
4773 #[test]
4774 fn open_stream_reads_past_unforwarded_updates() {
4775 let (tx, mut rx) = mpsc::unbounded_channel();
4776 let sink = recovery::EventSink::new(
4777 tx,
4778 new_dedup_cache(),
4779 KnownLiveOrders::shared(ExchangeId::Ibkr),
4780 );
4781
4782 assert_eq!(run(&sink, unforwarded(3)), 3);
4783 // The subscription ended, so the stream says so.
4784 assert!(matches!(
4785 rx.try_recv().unwrap().kind,
4786 AccountEventKind::StreamTerminated(StreamTerminationReason::Error(ref reason))
4787 if reason == "IBKR order-update stream ended"
4788 ));
4789 }
4790
4791 mod listings {
4792 use super::*;
4793 use ibapi::{
4794 contracts::Contract,
4795 orders::{
4796 CommissionReport, Execution, ExecutionData, Executions, OrderData,
4797 OrderState as IbOrderState, OrderStatusKind, Orders,
4798 },
4799 subscriptions::SubscriptionItem,
4800 };
4801 use rust_decimal_macros::dec;
4802 use std::time::Duration;
4803
4804 const API_CLIENT: i32 = 903;
4805 const CON_ID: i32 = 265598;
4806
4807 fn contracts() -> ContractRegistry {
4808 let contracts = ContractRegistry::new();
4809 contracts
4810 .register(
4811 InstrumentNameExchange::new("AAPL"),
4812 Contract {
4813 contract_id: CON_ID,
4814 ..Contract::default()
4815 },
4816 )
4817 .unwrap();
4818 contracts
4819 }
4820
4821 /// A `next_timeout` that hands out `items`, then answers as IB does at the end of a
4822 /// listing, at once; or, with `stalls`, only once the wait has run out.
4823 fn subscription<T>(
4824 items: Vec<Result<SubscriptionItem<T>, ibapi::Error>>,
4825 stalls: bool,
4826 ) -> impl FnMut(Duration) -> Option<Result<SubscriptionItem<T>, ibapi::Error>> {
4827 let mut items = items.into_iter();
4828 move |timeout| {
4829 items.next().or_else(|| {
4830 if stalls {
4831 std::thread::sleep(timeout);
4832 }
4833 None
4834 })
4835 }
4836 }
4837
4838 fn read(
4839 items: Vec<Result<SubscriptionItem<u32>, ibapi::Error>>,
4840 stalls: bool,
4841 stop_at: Option<u32>,
4842 ) -> Result<Vec<u32>, UnindexedClientError> {
4843 read_while(items, stalls, stop_at, true)
4844 }
4845
4846 fn read_while(
4847 items: Vec<Result<SubscriptionItem<u32>, ibapi::Error>>,
4848 stalls: bool,
4849 stop_at: Option<u32>,
4850 connected: bool,
4851 ) -> Result<Vec<u32>, UnindexedClientError> {
4852 let mut read = Vec::new();
4853 read_listing(
4854 "test",
4855 Duration::from_millis(40),
4856 || connected,
4857 subscription(items, stalls),
4858 |item| {
4859 read.push(item);
4860 if Some(item) == stop_at {
4861 ControlFlow::Break(())
4862 } else {
4863 ControlFlow::Continue(())
4864 }
4865 },
4866 )?;
4867 Ok(read)
4868 }
4869
4870 #[test]
4871 fn a_listing_read_ends_at_the_end_of_the_listing() {
4872 let items = || vec![Ok(SubscriptionItem::Data(1)), Ok(SubscriptionItem::Data(2))];
4873 assert_eq!(read(items(), false, None).unwrap(), [1, 2]);
4874 assert_eq!(
4875 read(items(), false, Some(1)).unwrap(),
4876 [1],
4877 "or where it breaks"
4878 );
4879 }
4880
4881 /// What a stalled or failed read holds is not the whole listing, so it is not returned.
4882 #[test]
4883 fn a_stalled_or_failed_listing_read_is_an_error() {
4884 let stalled = read(vec![Ok(SubscriptionItem::Data(1))], true, None);
4885 assert!(
4886 matches!(&stalled, Err(UnindexedClientError::Internal(m)) if m.contains("sent nothing")),
4887 "{stalled:?}"
4888 );
4889 let failed = read(
4890 vec![
4891 Ok(SubscriptionItem::Data(1)),
4892 Err(ibapi::Error::ConnectionReset),
4893 ],
4894 false,
4895 None,
4896 );
4897 assert!(
4898 matches!(failed, Err(UnindexedClientError::Internal(_))),
4899 "{failed:?}"
4900 );
4901 }
4902
4903 /// `ibapi` answers early when it drops a subscription's channel as well as at the end
4904 /// marker, so a listing that stops while the client is disconnected is not its end.
4905 #[test]
4906 fn a_listing_that_stops_while_disconnected_is_an_error() {
4907 let read = read_while(vec![Ok(SubscriptionItem::Data(1))], false, None, false);
4908 assert!(
4909 matches!(&read, Err(UnindexedClientError::Internal(m)) if m.contains("connection dropped")),
4910 "{read:?}"
4911 );
4912 }
4913
4914 fn contract(contract_id: i32) -> Contract {
4915 Contract {
4916 contract_id,
4917 ..Contract::default()
4918 }
4919 }
4920
4921 fn ib_notice(code: i32) -> ListingError {
4922 ListingError::Ibapi(ibapi::Error::Notice(ibapi::Notice {
4923 request_id: Some(9),
4924 code,
4925 message: "notice".to_string(),
4926 error_time: None,
4927 advanced_order_reject_json: String::new(),
4928 }))
4929 }
4930
4931 /// A description resolves only when IB lists exactly one contract for it.
4932 #[test]
4933 fn a_contract_description_resolves_only_to_a_single_match() {
4934 assert_eq!(
4935 classify_contract_details(Ok(()), vec![contract(1)]),
4936 Ok(contract(1))
4937 );
4938 assert_eq!(
4939 classify_contract_details(Ok(()), Vec::new()),
4940 Err(ResolveContractError::NoMatch)
4941 );
4942 assert_eq!(
4943 classify_contract_details(Ok(()), vec![contract(1), contract(2)]),
4944 Err(ResolveContractError::Ambiguous {
4945 matches: vec![contract(1), contract(2)]
4946 })
4947 );
4948 }
4949
4950 /// IB answers a description that matches nothing with error 200, and any other error
4951 /// is a refusal. A failure, even after some matches arrived, fails the resolution: the
4952 /// matches read are not all there are.
4953 #[test]
4954 fn a_failed_contract_details_read_says_whether_a_retry_can_help() {
4955 let classify = |failure| classify_contract_details(Err(failure), vec![contract(1)]);
4956
4957 assert_eq!(
4958 classify(ib_notice(NO_SECURITY_DEFINITION_CODE)),
4959 Err(ResolveContractError::NoMatch)
4960 );
4961 assert_eq!(
4962 classify(ib_notice(321)),
4963 Err(ResolveContractError::Refused {
4964 code: 321,
4965 message: "notice".to_string()
4966 })
4967 );
4968
4969 let transient = [
4970 (
4971 ListingError::Dropped,
4972 ConnectivityError::Socket(ListingError::Dropped.to_string()),
4973 ),
4974 (
4975 ListingError::Stalled(Duration::from_secs(10)),
4976 ConnectivityError::Timeout,
4977 ),
4978 (
4979 ListingError::Ibapi(ibapi::Error::ConnectionReset),
4980 ConnectivityError::Socket(ibapi::Error::ConnectionReset.to_string()),
4981 ),
4982 ];
4983 for (failure, connectivity) in transient {
4984 let classified = classify(failure);
4985 assert_eq!(
4986 classified,
4987 Err(ResolveContractError::Connectivity(connectivity))
4988 );
4989 assert!(classified.unwrap_err().is_transient());
4990 }
4991
4992 // ibapi has given up reconnecting, or the client is shut down: retrying cannot help.
4993 for permanent in [ibapi::Error::ConnectionFailed, ibapi::Error::Shutdown] {
4994 let classified = classify(ListingError::Ibapi(permanent));
4995 assert!(
4996 matches!(&classified, Err(ResolveContractError::Failed(_))),
4997 "{classified:?}"
4998 );
4999 assert!(!classified.unwrap_err().is_transient());
5000 }
5001 assert!(
5002 !ResolveContractError::Refused {
5003 code: 321,
5004 message: String::new()
5005 }
5006 .is_transient()
5007 );
5008 assert!(!ResolveContractError::NoMatch.is_transient());
5009 }
5010
5011 /// The account summary stays live after its listing, so only its `End` item ends the
5012 /// read; one that stops before it is not a whole balance set.
5013 #[test]
5014 fn an_account_summary_read_needs_its_end_item() {
5015 use ibapi::accounts::{AccountSummary, AccountSummaryResult};
5016
5017 let summary = || {
5018 Ok(SubscriptionItem::Data(AccountSummaryResult::Summary(
5019 AccountSummary {
5020 account: "DU1".to_string(),
5021 tag: "TotalCashValue".to_string(),
5022 value: "100".to_string(),
5023 currency: "USD".to_string(),
5024 },
5025 )))
5026 };
5027 let read = |items| {
5028 read_account_summary(
5029 Duration::from_millis(40),
5030 || true,
5031 subscription(items, false),
5032 )
5033 };
5034
5035 let balances = read(vec![
5036 summary(),
5037 Ok(SubscriptionItem::Data(AccountSummaryResult::End)),
5038 ])
5039 .unwrap()
5040 .to_balances();
5041 assert_eq!(balances.len(), 1, "{balances:?}");
5042 let cut_short = read(vec![summary()]);
5043 assert!(
5044 matches!(cut_short, Err(UnindexedClientError::Internal(_))),
5045 "{cut_short:?}"
5046 );
5047 }
5048
5049 fn listed(api_client_id: i32, order_id: i32, order: ibapi::orders::Order) -> Orders {
5050 Orders::OrderData(OrderData {
5051 order_id,
5052 contract: Contract {
5053 contract_id: CON_ID,
5054 ..Contract::default()
5055 },
5056 order: ibapi::orders::Order {
5057 client_id: api_client_id,
5058 order_id,
5059 ..order
5060 },
5061 order_state: IbOrderState {
5062 status: OrderStatusKind::Submitted,
5063 ..IbOrderState::default()
5064 },
5065 })
5066 }
5067
5068 fn status(
5069 api_client_id: i32,
5070 order_id: i32,
5071 status: OrderStatusKind,
5072 filled: f64,
5073 ) -> Orders {
5074 Orders::OrderStatus(OrderStatus {
5075 order_id,
5076 client_id: api_client_id,
5077 status,
5078 filled,
5079 ..OrderStatus::default()
5080 })
5081 }
5082
5083 fn stop_order() -> ibapi::orders::Order {
5084 ibapi::orders::Order {
5085 action: ibapi::orders::Action::Sell,
5086 order_type: "STP".to_owned(),
5087 total_quantity: 3.0,
5088 aux_price: Some(140.5),
5089 tif: ibapi::orders::TimeInForce::Day,
5090 ..ibapi::orders::Order::default()
5091 }
5092 }
5093
5094 /// This client's open orders, by IB order id: a tracked one as it was placed, under its
5095 /// client order id; another read back from IB's listing, under its IB order id. Each with
5096 /// the fill its status reports.
5097 #[test]
5098 fn open_orders_are_this_api_clients_with_their_fills() {
5099 let order_ids = OrderIdMap::new();
5100 order_ids
5101 .register(
5102 ClientOrderId::new("tracked"),
5103 2,
5104 OrderContext {
5105 instrument: InstrumentNameExchange::new("AAPL"),
5106 side: Side::Buy,
5107 price: Some(dec!(150)),
5108 quantity: dec!(10),
5109 kind: OrderKind::Limit,
5110 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
5111 },
5112 )
5113 .unwrap();
5114
5115 let listed_orders = open_orders_from_listing(
5116 [
5117 listed(API_CLIENT, 5, stop_order()),
5118 status(API_CLIENT, 5, OrderStatusKind::PreSubmitted, 0.0),
5119 // IB lists the tracked order with whatever it holds; what it was placed with
5120 // stands.
5121 listed(API_CLIENT, 2, ibapi::orders::Order::default()),
5122 status(API_CLIENT, 2, OrderStatusKind::Submitted, 4.0),
5123 // Another API client's order under the tracked order's id.
5124 listed(904, 2, stop_order()),
5125 status(904, 2, OrderStatusKind::Submitted, 9.0),
5126 ],
5127 API_CLIENT,
5128 &contracts(),
5129 &order_ids,
5130 );
5131
5132 assert_eq!(listed_orders.len(), 2, "{listed_orders:?}");
5133 // Only the tracked order is named as placed: the other has no order reference.
5134 let exact: Vec<_> = listed_orders
5135 .iter()
5136 .map(ListedOpenOrder::is_exact)
5137 .collect();
5138 assert_eq!(exact, [true, false]);
5139 let orders: Vec<_> = listed_orders
5140 .into_iter()
5141 .map(|listed| listed.order.unwrap())
5142 .collect();
5143 let (tracked, untracked) = (&orders[0], &orders[1]);
5144 assert_eq!(tracked.key.cid, ClientOrderId::new("tracked"));
5145 assert_eq!(tracked.kind, OrderKind::Limit);
5146 assert_eq!(tracked.price, Some(dec!(150)));
5147 assert_eq!(tracked.quantity, dec!(10));
5148 assert_eq!(tracked.state.id, VenueOrderId::Assigned(OrderId::new("2")));
5149 assert_eq!(tracked.state.filled_quantity, dec!(4));
5150
5151 assert_eq!(untracked.key.cid, ClientOrderId::new("5"));
5152 assert_eq!(
5153 untracked.key.instrument,
5154 InstrumentNameExchange::new("AAPL")
5155 );
5156 assert_eq!(untracked.side, Side::Sell);
5157 assert_eq!(untracked.quantity, dec!(3));
5158 assert_eq!(
5159 untracked.kind,
5160 OrderKind::Stop {
5161 trigger_price: dec!(140.5)
5162 }
5163 );
5164 assert_eq!(untracked.time_in_force, TimeInForce::GoodUntilEndOfDay);
5165 assert_eq!(
5166 untracked.state.id,
5167 VenueOrderId::Assigned(OrderId::new("5"))
5168 );
5169 assert_eq!(untracked.state.filled_quantity, Decimal::ZERO);
5170 }
5171
5172 /// An order that has just ended, and one in a contract not registered, are not listed;
5173 /// one whose shape this client never sends is listed without an order, and not exactly.
5174 #[test]
5175 fn open_orders_leave_out_what_cannot_be_reported_open() {
5176 let unregistered = Orders::OrderData(OrderData {
5177 order_id: 3,
5178 contract: Contract {
5179 contract_id: 1,
5180 ..Contract::default()
5181 },
5182 order: ibapi::orders::Order {
5183 client_id: API_CLIENT,
5184 ..stop_order()
5185 },
5186 order_state: IbOrderState::default(),
5187 });
5188 let unreadable = ibapi::orders::Order {
5189 order_type: "REL".to_owned(),
5190 ..stop_order()
5191 };
5192
5193 let orders = open_orders_from_listing(
5194 [
5195 listed(API_CLIENT, 1, stop_order()),
5196 status(API_CLIENT, 1, OrderStatusKind::Cancelled, 0.0),
5197 unregistered,
5198 listed(API_CLIENT, 4, unreadable),
5199 ],
5200 API_CLIENT,
5201 &contracts(),
5202 &OrderIdMap::new(),
5203 );
5204 assert_eq!(orders.len(), 1, "{orders:?}");
5205 assert!(orders[0].order.is_none());
5206 assert!(!orders[0].is_exact());
5207 }
5208
5209 fn referenced(order_ref: &str) -> ibapi::orders::Order {
5210 ibapi::orders::Order {
5211 order_ref: order_ref.to_owned(),
5212 ..stop_order()
5213 }
5214 }
5215
5216 /// An order this client does not track, such as one placed before a restart, is named by
5217 /// the client order id its order reference carries, and tracked from then on.
5218 #[test]
5219 fn an_untracked_order_is_named_by_its_reference_and_adopted() {
5220 let order_ids = OrderIdMap::new();
5221 let listed = open_orders_from_listing(
5222 [
5223 listed(API_CLIENT, 7, referenced("before-restart")),
5224 status(API_CLIENT, 7, OrderStatusKind::Submitted, 1.0),
5225 ],
5226 API_CLIENT,
5227 &contracts(),
5228 &order_ids,
5229 );
5230
5231 assert_eq!(listed.len(), 1, "{listed:?}");
5232 assert!(listed[0].is_exact());
5233 let order = listed[0].order.as_ref().unwrap();
5234 assert_eq!(order.key.cid, ClientOrderId::new("before-restart"));
5235 assert_eq!(order.state.filled_quantity, dec!(1));
5236 assert_eq!(
5237 order_ids.get_ib_id(&ClientOrderId::new("before-restart")),
5238 Some(7)
5239 );
5240 let (_, ctx) = order_ids.get_client_id_and_context(7).unwrap();
5241 assert_eq!(ctx.quantity, dec!(3));
5242 }
5243
5244 /// A tracked order under the listed order's IB order id but another client order id is an
5245 /// earlier order whose id IB reused: the tracked one keeps its mapping, and the listed
5246 /// order, which cannot be tracked under its reference, is listed under its IB order id and
5247 /// not exactly.
5248 #[test]
5249 fn a_reused_ib_order_id_does_not_name_the_listed_order() {
5250 let order_ids = OrderIdMap::new();
5251 order_ids
5252 .register(
5253 ClientOrderId::new("earlier"),
5254 7,
5255 OrderContext {
5256 instrument: InstrumentNameExchange::new("AAPL"),
5257 side: Side::Buy,
5258 price: Some(dec!(150)),
5259 quantity: dec!(10),
5260 kind: OrderKind::Limit,
5261 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
5262 },
5263 )
5264 .unwrap();
5265
5266 let listed = open_orders_from_listing(
5267 [
5268 listed(API_CLIENT, 7, referenced("later")),
5269 status(API_CLIENT, 7, OrderStatusKind::Submitted, 0.0),
5270 ],
5271 API_CLIENT,
5272 &contracts(),
5273 &order_ids,
5274 );
5275
5276 let order = listed[0].order.as_ref().unwrap();
5277 assert_eq!(order.key.cid, ClientOrderId::new("7"));
5278 assert!(!listed[0].is_exact());
5279 assert_eq!(
5280 order.kind,
5281 OrderKind::Stop {
5282 trigger_price: dec!(140.5)
5283 }
5284 );
5285 assert_eq!(order_ids.get_ib_id(&ClientOrderId::new("earlier")), Some(7));
5286 assert_eq!(order_ids.get_ib_id(&ClientOrderId::new("later")), None);
5287 }
5288
5289 /// The order check's listing names each of this client's orders by its reference, or else
5290 /// its tracked id, keeps one that has just ended, and adopts nothing.
5291 #[test]
5292 fn listed_cids_name_this_clients_orders_without_adopting_them() {
5293 let order_ids = OrderIdMap::new();
5294 order_ids
5295 .register(
5296 ClientOrderId::new("tracked"),
5297 2,
5298 OrderContext {
5299 instrument: InstrumentNameExchange::new("AAPL"),
5300 side: Side::Buy,
5301 price: Some(dec!(150)),
5302 quantity: dec!(10),
5303 kind: OrderKind::Limit,
5304 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
5305 },
5306 )
5307 .unwrap();
5308
5309 let cids = listed_cids(
5310 [
5311 listed(API_CLIENT, 1, referenced("ended")),
5312 status(API_CLIENT, 1, OrderStatusKind::Cancelled, 0.0),
5313 listed(API_CLIENT, 2, stop_order()),
5314 listed(API_CLIENT, 3, stop_order()),
5315 listed(904, 4, referenced("other-client")),
5316 ],
5317 API_CLIENT,
5318 &order_ids,
5319 );
5320
5321 assert_eq!(
5322 cids,
5323 fnv::FnvHashSet::from_iter([
5324 ClientOrderId::new("ended"),
5325 ClientOrderId::new("tracked")
5326 ])
5327 );
5328 assert_eq!(order_ids.get_ib_id(&ClientOrderId::new("ended")), None);
5329 }
5330
5331 /// A snapshot's instrument has complete orders only when each was read back exactly, and
5332 /// an instrument without a listed order has none open only if the listing was read.
5333 #[test]
5334 fn snapshot_orders_are_complete_only_when_each_is_exact() {
5335 let msft = InstrumentNameExchange::new("MSFT");
5336 let contracts = contracts();
5337 contracts
5338 .register(
5339 msft.clone(),
5340 Contract {
5341 contract_id: 272093,
5342 ..Contract::default()
5343 },
5344 )
5345 .unwrap();
5346 let in_msft = |order_id, order| {
5347 let Orders::OrderData(mut data) = listed(API_CLIENT, order_id, order) else {
5348 unreachable!()
5349 };
5350 data.contract.contract_id = 272093;
5351 Orders::OrderData(data)
5352 };
5353 let listed_orders = open_orders_from_listing(
5354 [
5355 listed(API_CLIENT, 1, referenced("a")),
5356 status(API_CLIENT, 1, OrderStatusKind::Submitted, 0.0),
5357 // Entered in TWS: no reference, so named by its IB order id.
5358 in_msft(2, stop_order()),
5359 status(API_CLIENT, 2, OrderStatusKind::Submitted, 0.0),
5360 in_msft(3, referenced("b")),
5361 status(API_CLIENT, 3, OrderStatusKind::Submitted, 0.0),
5362 ],
5363 API_CLIENT,
5364 &contracts,
5365 &OrderIdMap::new(),
5366 );
5367
5368 let mut orders = SnapshotOrders::new(Some(listed_orders));
5369 let (aapl_orders, aapl_complete) = orders.take(&InstrumentNameExchange::new("AAPL"));
5370 assert_eq!(aapl_orders.len(), 1);
5371 assert!(aapl_complete);
5372 let (msft_orders, msft_complete) = orders.take(&msft);
5373 assert_eq!(msft_orders.len(), 2);
5374 assert!(!msft_complete);
5375 assert_eq!(
5376 orders.take(&InstrumentNameExchange::new("TSLA")),
5377 (vec![], true)
5378 );
5379
5380 let mut unread = SnapshotOrders::new(None);
5381 assert_eq!(unread.take(&msft), (vec![], false));
5382 }
5383
5384 fn executed(api_client_id: i32, order_id: i32, exec_id: &str, time: &str) -> Executions {
5385 Executions::ExecutionData(ExecutionData {
5386 request_id: 9001,
5387 contract: Contract {
5388 contract_id: CON_ID,
5389 ..Contract::default()
5390 },
5391 execution: Execution {
5392 order_id,
5393 client_id: api_client_id,
5394 execution_id: exec_id.to_string(),
5395 time: time.to_string(),
5396 shares: 1.0,
5397 price: 100.0,
5398 cumulative_quantity: 1.0,
5399 ..Execution::default()
5400 },
5401 })
5402 }
5403
5404 fn commission(exec_id: &str) -> Executions {
5405 Executions::CommissionReport(CommissionReport {
5406 execution_id: exec_id.to_string(),
5407 commission: 1.25,
5408 currency: "USD".to_string(),
5409 ..CommissionReport::default()
5410 })
5411 }
5412
5413 /// The trades of a read: this API client's executions in the span, each under its IB
5414 /// order id, with the fee of its commission report, or an unknown fee without one.
5415 #[test]
5416 fn trades_carry_their_ib_order_id_and_commission() {
5417 let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
5418 let trades = trades_from_executions(
5419 [
5420 executed(API_CLIENT, 7, "paired", "20261007 10:00:02 UTC"),
5421 commission("paired"),
5422 // Earlier than the paired one, and returned first: in time order.
5423 executed(API_CLIENT, 7, "unpaired", "20261007 10:00:01 UTC"),
5424 executed(API_CLIENT, 7, "too-early", "20261007 09:59:59 UTC"),
5425 commission("too-early"),
5426 executed(905, 7, "other-client", "20261007 10:00:03 UTC"),
5427 commission("other-client"),
5428 ],
5429 API_CLIENT,
5430 &contracts(),
5431 at("2026-10-07T10:00:00Z")..=at("2026-10-07T11:00:00Z"),
5432 None,
5433 );
5434
5435 let read: Vec<_> = trades
5436 .iter()
5437 .map(|trade| {
5438 (
5439 trade.id.0.as_str(),
5440 trade.order_id.0.as_str(),
5441 trade.fees.asset.as_ref(),
5442 trade.fees.fees,
5443 )
5444 })
5445 .collect();
5446 assert_eq!(
5447 read,
5448 [
5449 ("unpaired", "7", execution::UNKNOWN_FEE_ASSET, Decimal::ZERO),
5450 ("paired", "7", "USD", dec!(1.25)),
5451 ]
5452 );
5453
5454 let filtered = trades_from_executions(
5455 [
5456 executed(API_CLIENT, 7, "paired", "20261007 10:00:00 UTC"),
5457 commission("paired"),
5458 ],
5459 API_CLIENT,
5460 &contracts(),
5461 at("2026-10-07T10:00:00Z")..=at("2026-10-07T11:00:00Z"),
5462 Some(&HashSet::from([InstrumentNameExchange::new("MSFT")])),
5463 );
5464 assert!(filtered.is_empty(), "{filtered:?}");
5465 }
5466 }
5467}