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}