Skip to main content

rustrade_data/exchange/ibkr/
mod.rs

1//! Interactive Brokers market data connector.
2//!
3//! Provides streaming market data from IB TWS/Gateway via the `ibapi` crate.
4//!
5//! # Testing Status
6//!
7//! **NOT TESTED in CI.** IBKR has not confirmed permission to use credentials
8//! for CI, and requires IB Gateway/TWS running locally.
9//!
10//! **Tested locally (free subscriptions via IBKR Pro):**
11//! - Tier 0: Connection, contract resolution
12//! - Tier 1: Historical bars/ticks, L1 quotes, Greeks calculator, option chains
13//!
14//! **NOT tested locally (paid subscriptions):**
15//! - Tier 2: L2 Market Depth — exchange-specific fees
16//! - Tier 3: OPRA US Options — paid subscription
17//!
18//! Tests are organized by subscription tier (see `ibkr_integration.rs`).
19//!
20//! # Connection
21//!
22//! Requires TWS or IB Gateway running locally with API enabled:
23//!
24//! | Application | Live Port | Paper Port |
25//! |-------------|-----------|------------|
26//! | TWS         | 7496      | 7497       |
27//! | IB Gateway  | 4001      | 4002       |
28//!
29//! Enable API in TWS/Gateway: Configure → API → Settings → Enable ActiveX and Socket Clients.
30//!
31//! # Architecture
32//!
33//! Unlike WebSocket-based exchanges, IB uses TCP sockets with a blocking API.
34//! This module implements [`Stream`](futures::Stream) directly rather than using the [`Connector`]
35//! trait designed for WebSocket exchanges.
36//!
37//! # Supported Data Types
38//!
39//! - **Quotes** ([`OrderBookL1`]): Best bid/ask via `market_data()` subscription
40//! - **Depth** ([`OrderBookEvent`]): L2 order book via `market_depth()` subscription
41//! - **Trades** ([`PublicTrade`]): Tick-by-tick trades via `tick_by_tick_all_last()` subscription
42//! - **Historical** ([`Candle`]): OHLCV bars via `historical::IbkrHistoricalData`
43//!
44//! # Limitations
45//!
46//! - **Market depth limit**: IB allows max 3 concurrent depth subscriptions (error 309)
47//! - **Trade side**: IB doesn't provide trade side; defaults to `Side::Buy`
48//! - **Blocking API**: Uses `std::thread::spawn` for long-lived subscriptions
49//! - **No auto-reconnect**: Caller responsibility per library philosophy
50//!
51//! # See Also
52//!
53//! - [IB API Documentation](https://www.interactivebrokers.com/campus/ibkr-api-page/trader-workstation-api/)
54//! - `rustrade_execution::client::ibkr` for order execution
55//!
56//! [`Connector`]: crate::exchange::Connector
57//! [`OrderBookL1`]: crate::subscription::book::OrderBookL1
58//! [`OrderBookEvent`]: crate::subscription::book::OrderBookEvent
59//! [`PublicTrade`]: crate::subscription::trade::PublicTrade
60//! [`Candle`]: crate::subscription::candle::Candle
61
62pub mod depth;
63// `flex` is private: its public surface is re-exported flat below (matching the `massive`
64// reconciliation module), so callers use a single `ibkr::IbkrFlex*` path rather than the
65// internal submodule layout.
66mod flex;
67pub mod greeks;
68pub mod historical;
69pub mod options;
70pub mod quotes;
71pub mod subscription;
72pub mod trades;
73
74pub use flex::{
75    FlexPollPolicy, IbkrFlexClient, IbkrFlexConfig, IbkrFlexCorporateAction, IbkrFlexError,
76    IbkrReorgType, parse_corporate_actions,
77};
78
79use crate::{
80    error::DataError,
81    event::{DataKind, MarketEvent},
82};
83use chrono::Utc;
84use depth::DepthAggregator;
85use greeks::GreeksAggregator;
86use ibapi::{
87    client::blocking::Client, contracts::SecurityType, market_data::realtime::MarketDepths,
88    subscriptions::SubscriptionItem,
89};
90use quotes::QuoteAggregator;
91use rust_decimal::Decimal;
92use rustrade_instrument::{exchange::ExchangeId, ibkr::ContractRegistry};
93use serde::{Deserialize, Serialize};
94use std::{
95    panic::{AssertUnwindSafe, catch_unwind},
96    pin::Pin,
97    sync::{
98        Arc,
99        atomic::{AtomicBool, Ordering},
100    },
101    task::{Context, Poll},
102};
103use subscription::{IbkrSubscription, IbkrSubscriptionKind};
104use tokio::sync::mpsc;
105use tracing::{debug, error, info, warn};
106
107/// Configuration for the IBKR market data stream.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct IbkrStreamConfig {
110    /// TWS/Gateway host (e.g., "127.0.0.1")
111    pub host: String,
112    /// TWS/Gateway port (7496=TWS live, 7497=TWS paper, 4001=GW live, 4002=GW paper)
113    pub port: u16,
114    /// Client ID (must be unique per connection).
115    ///
116    /// **Important**: The default value (100) is a placeholder. If you have multiple
117    /// IB connections (e.g., both market data and execution), each must use a different
118    /// client_id or IB will reject the second connection.
119    pub client_id: i32,
120}
121
122impl Default for IbkrStreamConfig {
123    fn default() -> Self {
124        Self {
125            host: "127.0.0.1".to_string(),
126            port: 4002,
127            client_id: 100,
128        }
129    }
130}
131
132/// Market data stream from Interactive Brokers.
133///
134/// Wraps an `mpsc::UnboundedReceiver` that receives events from background
135/// worker threads running ibapi subscriptions.
136///
137/// # Thread Architecture
138///
139/// - One worker thread is spawned per subscription during `init()`
140/// - Worker threads are detached (their `JoinHandle`s are not stored)
141/// - Worker threads terminate when:
142///   - The channel closes (this stream is dropped)
143///   - The IB subscription ends (disconnect or server-side cancel)
144/// - The stream ends once every worker has exited. A worker ended by an error first sends it as
145///   a terminal `DataError::Socket`, except when [`disconnect`](Self::disconnect) ended it.
146///
147/// # Memory Usage
148///
149/// The internal channel is **unbounded**. If the consumer processes events
150/// slower than IB produces them, memory usage will grow without limit. This
151/// design prioritizes data completeness over memory safety — a slow consumer
152/// causes heap growth rather than dropped events. Callers should ensure
153/// adequate processing capacity.
154///
155/// # Panic Handling
156///
157/// Worker threads are wrapped in `catch_unwind`. If a worker panics, a terminal
158/// `DataError::Socket` is sent to the stream describing the panic. The channel
159/// remains open for other subscriptions. No automatic recovery or reconnection.
160#[derive(Debug)]
161pub struct IbkrMarketStream<K> {
162    rx: mpsc::UnboundedReceiver<Result<MarketEvent<K, DataKind>, DataError>>,
163    client: Arc<Client>,
164    /// Set by [`disconnect`](Self::disconnect) before it closes the connection, so that a worker
165    /// can tell the caller's own close from a shutdown `ibapi` or TWS/Gateway caused: `ibapi`
166    /// reports both alike, as `Error::Shutdown` to a running subscription and as
167    /// `Error::ConnectionReset` to one still being sent. Once it is set, any error that ends a
168    /// worker is taken for the caller's close, even one `ibapi` raised on its own just before.
169    closing: Arc<AtomicBool>,
170}
171
172impl<K> futures::Stream for IbkrMarketStream<K> {
173    type Item = Result<MarketEvent<K, DataKind>, DataError>;
174
175    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
176        Pin::new(&mut self.rx).poll_recv(cx)
177    }
178}
179
180impl<K> IbkrMarketStream<K>
181where
182    K: Clone + Send + 'static,
183{
184    /// Initialize a market data stream with the given subscriptions.
185    ///
186    /// # Arguments
187    ///
188    /// * `config` - Connection configuration
189    /// * `contracts` - Contract registry for instrument resolution. Subscriptions look their
190    ///   contracts up by name only, and IB resolves each contract description on request, so
191    ///   contracts registered with
192    ///   [`register_by_name_only`](rustrade_instrument::ibkr::ContractRegistry::register_by_name_only)
193    ///   need not be resolved first.
194    /// * `subscriptions` - List of subscriptions to activate
195    ///
196    /// # Errors
197    ///
198    /// Returns error if:
199    /// - Connection to IB fails
200    /// - No subscriptions could be activated (all contracts missing from registry)
201    pub fn init(
202        config: IbkrStreamConfig,
203        contracts: Arc<ContractRegistry>,
204        subscriptions: Vec<IbkrSubscription<K>>,
205    ) -> Result<Self, DataError> {
206        let url = format!("{}:{}", config.host, config.port);
207        info!(%url, client_id = config.client_id, "Connecting to IB for market data");
208
209        let client = Client::connect(&url, config.client_id)
210            .map_err(|e| DataError::Socket(format!("IB connect: {e}")))?;
211
212        let client = Arc::new(client);
213        let closing = Arc::new(AtomicBool::new(false));
214        let (tx, rx) = mpsc::unbounded_channel();
215
216        let mut active_subscriptions = 0;
217
218        // Spawn a worker thread for each subscription
219        for sub in subscriptions {
220            let contract = match contracts.get_contract(&sub.instrument) {
221                Some(c) => c,
222                None => {
223                    warn!(
224                        instrument = %sub.instrument,
225                        "Contract not found in registry, skipping subscription"
226                    );
227                    continue;
228                }
229            };
230
231            let spawn_result = match sub.kind {
232                IbkrSubscriptionKind::Quotes => Self::run_quotes_subscription(
233                    client.clone(),
234                    contract,
235                    sub.key.clone(),
236                    tx.clone(),
237                    closing.clone(),
238                ),
239                IbkrSubscriptionKind::Depth { rows } => Self::run_depth_subscription(
240                    client.clone(),
241                    contract,
242                    sub.key.clone(),
243                    rows,
244                    tx.clone(),
245                    closing.clone(),
246                ),
247                IbkrSubscriptionKind::Trades => Self::run_trades_subscription(
248                    client.clone(),
249                    contract,
250                    sub.key.clone(),
251                    tx.clone(),
252                    closing.clone(),
253                ),
254                IbkrSubscriptionKind::OptionGreeks => Self::run_option_greeks_subscription(
255                    client.clone(),
256                    contract,
257                    sub.key.clone(),
258                    tx.clone(),
259                    closing.clone(),
260                ),
261            };
262
263            match spawn_result {
264                Ok(()) => active_subscriptions += 1,
265                Err(e) => {
266                    warn!(instrument = %sub.instrument, error = %e, "Failed to spawn subscription worker");
267                }
268            }
269        }
270
271        if active_subscriptions == 0 {
272            return Err(DataError::Socket(
273                "No subscriptions activated (check logs for per-subscription errors)".to_string(),
274            ));
275        }
276
277        Ok(Self {
278            rx,
279            client,
280            closing,
281        })
282    }
283
284    fn run_quotes_subscription(
285        client: Arc<Client>,
286        contract: ibapi::contracts::Contract,
287        key: K,
288        tx: mpsc::UnboundedSender<Result<MarketEvent<K, DataKind>, DataError>>,
289        closing: Arc<AtomicBool>,
290    ) -> Result<(), DataError> {
291        let symbol = contract.symbol.to_string();
292        let symbol_clone = symbol.clone();
293        let tx_panic = tx.clone();
294
295        std::thread::Builder::new()
296            .name(format!("ibkr-quotes-{symbol}"))
297            .spawn(move || {
298                // Panic safety: parking_lot mutexes do not poison on panic, so shared state
299                // (ContractRegistry, etc.) remains usable. On panic the thread exits,
300                // tx is dropped, and the stream closes — caller observes EOF.
301                let result = catch_unwind(AssertUnwindSafe(|| {
302                    debug!(symbol = %symbol, "Starting quotes subscription");
303
304                    let sub = match client.market_data(&contract).subscribe() {
305                        Ok(s) => s,
306                        Err(e) => {
307                            report_worker_error(&closing, &tx, "quotes subscription", &symbol, e);
308                            return;
309                        }
310                    };
311
312                    let mut aggregator = QuoteAggregator::new();
313
314                    // ibapi 3.x: `iter_data()` yields `Result<TickTypes, Error>`,
315                    // filtering subscription-level notices. Surface errors as a
316                    // terminal event (observable failures over silent ones).
317                    for tick in sub.iter_data() {
318                        let tick = match tick {
319                            Ok(t) => t,
320                            Err(e) => {
321                                report_worker_error(
322                                    &closing,
323                                    &tx,
324                                    "quotes subscription",
325                                    &symbol,
326                                    e,
327                                );
328                                break;
329                            }
330                        };
331                        let now = Utc::now();
332                        if let Some(l1) = aggregator.update(&tick, now) {
333                            let event = MarketEvent {
334                                time_exchange: l1.last_update_time,
335                                time_received: now,
336                                exchange: ExchangeId::Ibkr,
337                                instrument: key.clone(),
338                                kind: DataKind::OrderBookL1(l1),
339                            };
340
341                            if tx.send(Ok(event)).is_err() {
342                                break;
343                            }
344                        }
345                    }
346
347                    debug!(symbol = %symbol, "Quotes subscription ended");
348                }));
349
350                if let Err(panic_info) = result {
351                    let msg = panic_info
352                        .downcast_ref::<&str>()
353                        .map(|s| s.to_string())
354                        .or_else(|| panic_info.downcast_ref::<String>().cloned())
355                        .unwrap_or_else(|| "unknown panic".to_string());
356                    error!(symbol = %symbol_clone, "Quotes worker panicked: {msg}");
357                    let _ = tx_panic.send(Err(DataError::Socket(format!(
358                        "quotes subscription {symbol_clone} panicked: {msg}"
359                    ))));
360                }
361            })
362            .map_err(|e| DataError::Socket(format!("Failed to spawn quotes thread: {e}")))?;
363        Ok(())
364    }
365
366    fn run_depth_subscription(
367        client: Arc<Client>,
368        contract: ibapi::contracts::Contract,
369        key: K,
370        rows: i32,
371        tx: mpsc::UnboundedSender<Result<MarketEvent<K, DataKind>, DataError>>,
372        closing: Arc<AtomicBool>,
373    ) -> Result<(), DataError> {
374        let symbol = contract.symbol.to_string();
375        let symbol_clone = symbol.clone();
376        let tx_panic = tx.clone();
377
378        std::thread::Builder::new()
379            .name(format!("ibkr-depth-{symbol}"))
380            .spawn(move || {
381                // Panic safety: parking_lot mutexes do not poison on panic, so shared state
382                // (ContractRegistry, etc.) remains usable. On panic the thread exits,
383                // tx is dropped, and the stream closes — caller observes EOF.
384                let result = catch_unwind(AssertUnwindSafe(|| {
385                    debug!(symbol = %symbol, rows, "Starting depth subscription");
386
387                    // Default SmartDepth::No: we aggregate into a simple anonymous book
388                    // without tracking market maker attribution from MarketDepthL2 events.
389                    let sub = match client.market_depth(&contract, rows).subscribe() {
390                        Ok(s) => s,
391                        Err(e) => {
392                            report_worker_error(&closing, &tx, "depth subscription", &symbol, e);
393                            return;
394                        }
395                    };
396
397                    let mut aggregator = DepthAggregator::new();
398
399                    // `iter()`, not `iter_data()`, so that the subscription's notices are
400                    // logged below. The venue reset (317) is not one of them: `ibapi`
401                    // delivers it as `MarketDepths::Reset` data.
402                    for item in sub.iter() {
403                        let item = match item {
404                            Ok(i) => i,
405                            Err(e) => {
406                                report_worker_error(
407                                    &closing,
408                                    &tx,
409                                    "depth subscription",
410                                    &symbol,
411                                    e,
412                                );
413                                break;
414                            }
415                        };
416
417                        let depth = match item {
418                            SubscriptionItem::Data(d) => {
419                                if matches!(d, MarketDepths::Reset) {
420                                    // `aggregator.update` drops the local book and returns
421                                    // the emptied one, which is forwarded below.
422                                    warn!(
423                                        symbol = %symbol,
424                                        "Market depth RESET by venue; dropping local book"
425                                    );
426                                }
427                                d
428                            }
429                            // A notice is non-fatal and carries no depth
430                            // payload. `iter_data()` used to log these at `warn!`; keep
431                            // that, so switching iterators loses no observability.
432                            SubscriptionItem::Notice(notice) => {
433                                warn!(
434                                    symbol = %symbol,
435                                    code = notice.code,
436                                    message = %notice.message,
437                                    "Depth subscription notice"
438                                );
439                                continue;
440                            }
441                        };
442                        // IB's MarketDepth events have no timestamp. Stamp `now`
443                        // BEFORE aggregating so the book's `time_received` (threaded
444                        // into the OrderBook) matches the MarketEvent envelope's.
445                        // Consumers should NOT interpret the MarketEvent's
446                        // time_exchange as a real venue timestamp for depth events.
447                        let now = Utc::now();
448                        if let Some(book_event) = aggregator.update(&depth, now) {
449                            let event = MarketEvent {
450                                time_exchange: now,
451                                time_received: now,
452                                exchange: ExchangeId::Ibkr,
453                                instrument: key.clone(),
454                                kind: DataKind::OrderBook(book_event),
455                            };
456
457                            if tx.send(Ok(event)).is_err() {
458                                break;
459                            }
460                        }
461                    }
462
463                    debug!(symbol = %symbol, "Depth subscription ended");
464                }));
465
466                if let Err(panic_info) = result {
467                    let msg = panic_info
468                        .downcast_ref::<&str>()
469                        .map(|s| s.to_string())
470                        .or_else(|| panic_info.downcast_ref::<String>().cloned())
471                        .unwrap_or_else(|| "unknown panic".to_string());
472                    error!(symbol = %symbol_clone, "Depth worker panicked: {msg}");
473                    let _ = tx_panic.send(Err(DataError::Socket(format!(
474                        "depth subscription {symbol_clone} panicked: {msg}"
475                    ))));
476                }
477            })
478            .map_err(|e| DataError::Socket(format!("Failed to spawn depth thread: {e}")))?;
479        Ok(())
480    }
481
482    fn run_trades_subscription(
483        client: Arc<Client>,
484        contract: ibapi::contracts::Contract,
485        key: K,
486        tx: mpsc::UnboundedSender<Result<MarketEvent<K, DataKind>, DataError>>,
487        closing: Arc<AtomicBool>,
488    ) -> Result<(), DataError> {
489        let symbol = contract.symbol.to_string();
490        let symbol_clone = symbol.clone();
491        let tx_panic = tx.clone();
492
493        std::thread::Builder::new()
494            .name(format!("ibkr-trades-{symbol}"))
495            .spawn(move || {
496                // Panic safety: parking_lot mutexes do not poison on panic, so shared state
497                // (ContractRegistry, etc.) remains usable. On panic the thread exits,
498                // tx is dropped, and the stream closes — caller observes EOF.
499                let result = catch_unwind(AssertUnwindSafe(|| {
500                    debug!(symbol = %symbol, "Starting trades subscription");
501
502                    let sub = match client.tick_by_tick(&contract, 0).all_last() {
503                        Ok(s) => s,
504                        Err(e) => {
505                            report_worker_error(&closing, &tx, "trades subscription", &symbol, e);
506                            return;
507                        }
508                    };
509
510                    for trade in sub.iter_data() {
511                        let trade = match trade {
512                            Ok(t) => t,
513                            Err(e) => {
514                                report_worker_error(
515                                    &closing,
516                                    &tx,
517                                    "trades subscription",
518                                    &symbol,
519                                    e,
520                                );
521                                break;
522                            }
523                        };
524                        let now = Utc::now();
525                        let public_trade = match trades::from_ib_trade(&trade) {
526                            Some(t) => t,
527                            None => continue, // Skip invalid trades (NaN/Inf price or size)
528                        };
529                        let time_exchange = trades::parse_trade_time(&trade, now);
530
531                        let event = MarketEvent {
532                            time_exchange,
533                            time_received: now,
534                            exchange: ExchangeId::Ibkr,
535                            instrument: key.clone(),
536                            kind: DataKind::Trade(public_trade),
537                        };
538
539                        if tx.send(Ok(event)).is_err() {
540                            break;
541                        }
542                    }
543
544                    debug!(symbol = %symbol, "Trades subscription ended");
545                }));
546
547                if let Err(panic_info) = result {
548                    let msg = panic_info
549                        .downcast_ref::<&str>()
550                        .map(|s| s.to_string())
551                        .or_else(|| panic_info.downcast_ref::<String>().cloned())
552                        .unwrap_or_else(|| "unknown panic".to_string());
553                    error!(symbol = %symbol_clone, "Trades worker panicked: {msg}");
554                    let _ = tx_panic.send(Err(DataError::Socket(format!(
555                        "trades subscription {symbol_clone} panicked: {msg}"
556                    ))));
557                }
558            })
559            .map_err(|e| DataError::Socket(format!("Failed to spawn trades thread: {e}")))?;
560        Ok(())
561    }
562
563    fn run_option_greeks_subscription(
564        client: Arc<Client>,
565        contract: ibapi::contracts::Contract,
566        key: K,
567        tx: mpsc::UnboundedSender<Result<MarketEvent<K, DataKind>, DataError>>,
568        closing: Arc<AtomicBool>,
569    ) -> Result<(), DataError> {
570        if contract.security_type != SecurityType::Option {
571            return Err(DataError::Socket(format!(
572                "option Greeks subscription requires SecurityType::Option, got {:?} for {}",
573                contract.security_type, contract.symbol
574            )));
575        }
576
577        let symbol = contract.symbol.to_string();
578        let symbol_clone = symbol.clone();
579        let tx_panic = tx.clone();
580
581        std::thread::Builder::new()
582            .name(format!("ibkr-greeks-{symbol}"))
583            .spawn(move || {
584                // Panic safety: parking_lot mutexes do not poison on panic, so shared state
585                // (ContractRegistry, etc.) remains usable. On panic the thread exits,
586                // tx is dropped, and the stream closes — caller observes EOF.
587                let result = catch_unwind(AssertUnwindSafe(|| {
588                    debug!(symbol = %symbol, "Starting option Greeks subscription");
589
590                    let sub = match client.market_data(&contract).subscribe() {
591                        Ok(s) => s,
592                        Err(e) => {
593                            report_worker_error(
594                                &closing,
595                                &tx,
596                                "option Greeks subscription",
597                                &symbol,
598                                e,
599                            );
600                            return;
601                        }
602                    };
603
604                    let aggregator = GreeksAggregator::new();
605
606                    for tick in sub.iter_data() {
607                        let tick = match tick {
608                            Ok(t) => t,
609                            Err(e) => {
610                                report_worker_error(
611                                    &closing,
612                                    &tx,
613                                    "option Greeks subscription",
614                                    &symbol,
615                                    e,
616                                );
617                                break;
618                            }
619                        };
620                        if let Some(greeks) = aggregator.update(&tick) {
621                            let now = Utc::now();
622                            let event = MarketEvent {
623                                time_exchange: now,
624                                time_received: now,
625                                exchange: ExchangeId::Ibkr,
626                                instrument: key.clone(),
627                                kind: DataKind::OptionGreeks(greeks),
628                            };
629
630                            if tx.send(Ok(event)).is_err() {
631                                break;
632                            }
633                        }
634                    }
635
636                    debug!(symbol = %symbol, "Option Greeks subscription ended");
637                }));
638
639                if let Err(panic_info) = result {
640                    let msg = panic_info
641                        .downcast_ref::<&str>()
642                        .map(|s| s.to_string())
643                        .or_else(|| panic_info.downcast_ref::<String>().cloned())
644                        .unwrap_or_else(|| "unknown panic".to_string());
645                    error!(symbol = %symbol_clone, "Option Greeks worker panicked: {msg}");
646                    let _ = tx_panic.send(Err(DataError::Socket(format!(
647                        "option Greeks subscription {symbol_clone} panicked: {msg}"
648                    ))));
649                }
650            })
651            .map_err(|e| DataError::Socket(format!("Failed to spawn option Greeks thread: {e}")))?;
652        Ok(())
653    }
654}
655
656impl<K> IbkrMarketStream<K> {
657    /// Disconnect from IB Gateway.
658    ///
659    /// Closes the connection to IB Gateway, which ends every worker's subscription at once. The
660    /// stream then ends (`None`) once every worker has exited, with no error item for the close:
661    /// a close the caller asked for is not a failure. A shutdown `ibapi` or TWS/Gateway causes
662    /// instead, such as reconnection given up or the API session ended, still reaches the stream
663    /// as one terminal [`DataError::Socket`] per subscription; one that comes just before this
664    /// call, but reaches a worker only after it, ends that worker quietly too. IB Gateway frees the
665    /// client ID once it has processed the close, which can take a moment. Dropping the stream
666    /// disconnects too.
667    ///
668    /// This is idempotent — calling it multiple times is safe.
669    pub fn disconnect(&self) {
670        debug!("Disconnecting IbkrMarketStream");
671        self.closing.store(true, Ordering::Release);
672        self.client.disconnect();
673    }
674}
675
676impl<K> Drop for IbkrMarketStream<K> {
677    fn drop(&mut self) {
678        debug!("Dropping IbkrMarketStream, disconnecting client");
679        self.disconnect();
680    }
681}
682
683/// End a worker on `error`: log it, and send it to the stream as the worker's terminal
684/// [`DataError::Socket`], `"{stage} {symbol}: {error}"`.
685///
686/// Once `closing` is set, the stream's own [`disconnect`](IbkrMarketStream::disconnect) has
687/// begun, and any error is logged at debug and nothing is sent: `ibapi` reports that close as
688/// more than one error, depending on how far the worker got, and the caller is ending the
689/// stream anyway.
690fn report_worker_error<T>(
691    closing: &AtomicBool,
692    tx: &mpsc::UnboundedSender<Result<T, DataError>>,
693    stage: &str,
694    symbol: &str,
695    error: ibapi::Error,
696) {
697    if closing.load(Ordering::Acquire) {
698        debug!(symbol, error = %error, "{stage} ended by disconnect");
699        return;
700    }
701    error!(symbol, error = %error, "{stage} failed");
702    let _ = tx.send(Err(DataError::Socket(format!("{stage} {symbol}: {error}"))));
703}
704
705/// Convert f64 to Decimal, returning None for invalid values.
706///
707/// Returns `None` for NaN, Infinity, and values that cannot be represented
708/// as Decimal (e.g., IB's DBL_MAX sentinel for "not available").
709///
710/// Note: f64→Decimal conversion introduces representation error (e.g., 0.1_f64
711/// becomes 0.1000000000000000055511151231257827021181583404541015625). This is
712/// unavoidable with IB's f64-based API.
713pub(crate) fn decimal_from_f64(value: f64) -> Option<Decimal> {
714    if !value.is_finite() {
715        return None;
716    }
717    Decimal::try_from(value).ok()
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    fn worker_error_sent(closing: bool, error: ibapi::Error) -> Option<String> {
725        let (tx, mut rx) = mpsc::unbounded_channel::<Result<(), DataError>>();
726        report_worker_error(
727            &AtomicBool::new(closing),
728            &tx,
729            "quotes subscription",
730            "AAPL",
731            error,
732        );
733        match rx.try_recv() {
734            Ok(Err(DataError::Socket(msg))) => Some(msg),
735            Ok(other) => panic!("unexpected item: {other:?}"),
736            Err(_) => None,
737        }
738    }
739
740    #[test]
741    fn a_shutdown_the_caller_did_not_ask_for_reaches_the_stream() {
742        assert_eq!(
743            worker_error_sent(false, ibapi::Error::Shutdown),
744            Some(format!(
745                "quotes subscription AAPL: {}",
746                ibapi::Error::Shutdown
747            ))
748        );
749    }
750
751    #[test]
752    fn a_connection_reset_the_caller_did_not_ask_for_reaches_the_stream() {
753        assert!(worker_error_sent(false, ibapi::Error::ConnectionReset).is_some());
754    }
755
756    #[test]
757    fn the_callers_own_disconnect_ends_a_running_worker_without_an_error_item() {
758        assert_eq!(worker_error_sent(true, ibapi::Error::Shutdown), None);
759    }
760
761    #[test]
762    fn the_callers_own_disconnect_ends_a_subscribing_worker_without_an_error_item() {
763        // `ibapi` refuses a subscription sent after the close began with `ConnectionReset`.
764        assert_eq!(worker_error_sent(true, ibapi::Error::ConnectionReset), None);
765    }
766
767    #[test]
768    fn decimal_from_f64_handles_special_values() {
769        // Normal values
770        assert!(decimal_from_f64(100.0).is_some());
771        assert!(decimal_from_f64(-50.5).is_some());
772        assert!(decimal_from_f64(0.0).is_some());
773
774        // NaN and Infinity
775        assert!(decimal_from_f64(f64::NAN).is_none());
776        assert!(decimal_from_f64(f64::INFINITY).is_none());
777        assert!(decimal_from_f64(f64::NEG_INFINITY).is_none());
778
779        // f64::MAX is too large for Decimal
780        assert!(
781            decimal_from_f64(f64::MAX).is_none(),
782            "f64::MAX should not convert to Decimal"
783        );
784    }
785}