Skip to main content

rustrade_execution/client/ibkr/
mod.rs

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