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::Arc,
98    task::{Context, Poll},
99};
100use subscription::{IbkrSubscription, IbkrSubscriptionKind};
101use tokio::sync::mpsc;
102use tracing::{debug, error, info, warn};
103
104/// Configuration for the IBKR market data stream.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct IbkrStreamConfig {
107    /// TWS/Gateway host (e.g., "127.0.0.1")
108    pub host: String,
109    /// TWS/Gateway port (7496=TWS live, 7497=TWS paper, 4001=GW live, 4002=GW paper)
110    pub port: u16,
111    /// Client ID (must be unique per connection).
112    ///
113    /// **Important**: The default value (100) is a placeholder. If you have multiple
114    /// IB connections (e.g., both market data and execution), each must use a different
115    /// client_id or IB will reject the second connection.
116    pub client_id: i32,
117}
118
119impl Default for IbkrStreamConfig {
120    fn default() -> Self {
121        Self {
122            host: "127.0.0.1".to_string(),
123            port: 4002,
124            client_id: 100,
125        }
126    }
127}
128
129/// Market data stream from Interactive Brokers.
130///
131/// Wraps an `mpsc::UnboundedReceiver` that receives events from background
132/// worker threads running ibapi subscriptions.
133///
134/// # Thread Architecture
135///
136/// - One worker thread is spawned per subscription during `init()`
137/// - Worker threads are detached (their `JoinHandle`s are not stored)
138/// - Worker threads terminate when:
139///   - The channel closes (this stream is dropped)
140///   - The IB subscription ends (disconnect or server-side cancel)
141///
142/// # Memory Usage
143///
144/// The internal channel is **unbounded**. If the consumer processes events
145/// slower than IB produces them, memory usage will grow without limit. This
146/// design prioritizes data completeness over memory safety — a slow consumer
147/// causes heap growth rather than dropped events. Callers should ensure
148/// adequate processing capacity.
149///
150/// # Panic Handling
151///
152/// Worker threads are wrapped in `catch_unwind`. If a worker panics, a terminal
153/// `DataError::Socket` is sent to the stream describing the panic. The channel
154/// remains open for other subscriptions. No automatic recovery or reconnection.
155#[derive(Debug)]
156pub struct IbkrMarketStream<K> {
157    rx: mpsc::UnboundedReceiver<Result<MarketEvent<K, DataKind>, DataError>>,
158    client: Arc<Client>,
159}
160
161impl<K> futures::Stream for IbkrMarketStream<K> {
162    type Item = Result<MarketEvent<K, DataKind>, DataError>;
163
164    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
165        Pin::new(&mut self.rx).poll_recv(cx)
166    }
167}
168
169impl<K> IbkrMarketStream<K>
170where
171    K: Clone + Send + 'static,
172{
173    /// Initialize a market data stream with the given subscriptions.
174    ///
175    /// # Arguments
176    ///
177    /// * `config` - Connection configuration
178    /// * `contracts` - Contract registry for instrument resolution. Subscriptions look their
179    ///   contracts up by name only, and IB resolves each contract description on request, so
180    ///   contracts registered with
181    ///   [`register_by_name_only`](rustrade_instrument::ibkr::ContractRegistry::register_by_name_only)
182    ///   need not be resolved first.
183    /// * `subscriptions` - List of subscriptions to activate
184    ///
185    /// # Errors
186    ///
187    /// Returns error if:
188    /// - Connection to IB fails
189    /// - No subscriptions could be activated (all contracts missing from registry)
190    pub fn init(
191        config: IbkrStreamConfig,
192        contracts: Arc<ContractRegistry>,
193        subscriptions: Vec<IbkrSubscription<K>>,
194    ) -> Result<Self, DataError> {
195        let url = format!("{}:{}", config.host, config.port);
196        info!(%url, client_id = config.client_id, "Connecting to IB for market data");
197
198        let client = Client::connect(&url, config.client_id)
199            .map_err(|e| DataError::Socket(format!("IB connect: {e}")))?;
200
201        let client = Arc::new(client);
202        let (tx, rx) = mpsc::unbounded_channel();
203
204        let mut active_subscriptions = 0;
205
206        // Spawn a worker thread for each subscription
207        for sub in subscriptions {
208            let contract = match contracts.get_contract(&sub.instrument) {
209                Some(c) => c,
210                None => {
211                    warn!(
212                        instrument = %sub.instrument,
213                        "Contract not found in registry, skipping subscription"
214                    );
215                    continue;
216                }
217            };
218
219            let spawn_result = match sub.kind {
220                IbkrSubscriptionKind::Quotes => Self::run_quotes_subscription(
221                    client.clone(),
222                    contract,
223                    sub.key.clone(),
224                    tx.clone(),
225                ),
226                IbkrSubscriptionKind::Depth { rows } => Self::run_depth_subscription(
227                    client.clone(),
228                    contract,
229                    sub.key.clone(),
230                    rows,
231                    tx.clone(),
232                ),
233                IbkrSubscriptionKind::Trades => Self::run_trades_subscription(
234                    client.clone(),
235                    contract,
236                    sub.key.clone(),
237                    tx.clone(),
238                ),
239                IbkrSubscriptionKind::OptionGreeks => Self::run_option_greeks_subscription(
240                    client.clone(),
241                    contract,
242                    sub.key.clone(),
243                    tx.clone(),
244                ),
245            };
246
247            match spawn_result {
248                Ok(()) => active_subscriptions += 1,
249                Err(e) => {
250                    warn!(instrument = %sub.instrument, error = %e, "Failed to spawn subscription worker");
251                }
252            }
253        }
254
255        if active_subscriptions == 0 {
256            return Err(DataError::Socket(
257                "No subscriptions activated (check logs for per-subscription errors)".to_string(),
258            ));
259        }
260
261        Ok(Self { rx, client })
262    }
263
264    /// Disconnect from IB Gateway.
265    ///
266    /// Signals the client to shut down, which will cause worker threads to exit
267    /// when they next attempt an IB operation. This releases the client ID for reuse.
268    ///
269    /// Call this before dropping to ensure IB Gateway releases the connection promptly.
270    /// Worker threads will terminate when they observe the disconnected state.
271    ///
272    /// This is idempotent — calling it multiple times is safe.
273    pub fn disconnect(&self) {
274        debug!("Disconnecting IbkrMarketStream");
275        self.client.disconnect();
276    }
277
278    fn run_quotes_subscription(
279        client: Arc<Client>,
280        contract: ibapi::contracts::Contract,
281        key: K,
282        tx: mpsc::UnboundedSender<Result<MarketEvent<K, DataKind>, DataError>>,
283    ) -> Result<(), DataError> {
284        let symbol = contract.symbol.to_string();
285        let symbol_clone = symbol.clone();
286        let tx_panic = tx.clone();
287
288        std::thread::Builder::new()
289            .name(format!("ibkr-quotes-{symbol}"))
290            .spawn(move || {
291                // Panic safety: parking_lot mutexes do not poison on panic, so shared state
292                // (ContractRegistry, etc.) remains usable. On panic the thread exits,
293                // tx is dropped, and the stream closes — caller observes EOF.
294                let result = catch_unwind(AssertUnwindSafe(|| {
295                    debug!(symbol = %symbol, "Starting quotes subscription");
296
297                    let sub = match client.market_data(&contract).subscribe() {
298                        Ok(s) => s,
299                        Err(e) => {
300                            error!(symbol = %symbol, error = %e, "Failed to subscribe to quotes");
301                            let _ = tx.send(Err(DataError::Socket(format!(
302                                "quotes subscription {symbol}: {e}"
303                            ))));
304                            return;
305                        }
306                    };
307
308                    let mut aggregator = QuoteAggregator::new();
309
310                    // ibapi 3.x: `iter_data()` yields `Result<TickTypes, Error>`,
311                    // filtering subscription-level notices. Surface errors as a
312                    // terminal event (observable failures over silent ones).
313                    for tick in sub.iter_data() {
314                        let tick = match tick {
315                            Ok(t) => t,
316                            Err(e) => {
317                                error!(symbol = %symbol, error = %e, "Quotes subscription error");
318                                let _ = tx.send(Err(DataError::Socket(format!(
319                                    "quotes subscription {symbol}: {e}"
320                                ))));
321                                break;
322                            }
323                        };
324                        let now = Utc::now();
325                        if let Some(l1) = aggregator.update(&tick, now) {
326                            let event = MarketEvent {
327                                time_exchange: l1.last_update_time,
328                                time_received: now,
329                                exchange: ExchangeId::Ibkr,
330                                instrument: key.clone(),
331                                kind: DataKind::OrderBookL1(l1),
332                            };
333
334                            if tx.send(Ok(event)).is_err() {
335                                break;
336                            }
337                        }
338                    }
339
340                    debug!(symbol = %symbol, "Quotes subscription ended");
341                }));
342
343                if let Err(panic_info) = result {
344                    let msg = panic_info
345                        .downcast_ref::<&str>()
346                        .map(|s| s.to_string())
347                        .or_else(|| panic_info.downcast_ref::<String>().cloned())
348                        .unwrap_or_else(|| "unknown panic".to_string());
349                    error!(symbol = %symbol_clone, "Quotes worker panicked: {msg}");
350                    let _ = tx_panic.send(Err(DataError::Socket(format!(
351                        "quotes subscription {symbol_clone} panicked: {msg}"
352                    ))));
353                }
354            })
355            .map_err(|e| DataError::Socket(format!("Failed to spawn quotes thread: {e}")))?;
356        Ok(())
357    }
358
359    fn run_depth_subscription(
360        client: Arc<Client>,
361        contract: ibapi::contracts::Contract,
362        key: K,
363        rows: i32,
364        tx: mpsc::UnboundedSender<Result<MarketEvent<K, DataKind>, DataError>>,
365    ) -> Result<(), DataError> {
366        let symbol = contract.symbol.to_string();
367        let symbol_clone = symbol.clone();
368        let tx_panic = tx.clone();
369
370        std::thread::Builder::new()
371            .name(format!("ibkr-depth-{symbol}"))
372            .spawn(move || {
373                // Panic safety: parking_lot mutexes do not poison on panic, so shared state
374                // (ContractRegistry, etc.) remains usable. On panic the thread exits,
375                // tx is dropped, and the stream closes — caller observes EOF.
376                let result = catch_unwind(AssertUnwindSafe(|| {
377                    debug!(symbol = %symbol, rows, "Starting depth subscription");
378
379                    // Default SmartDepth::No: we aggregate into a simple anonymous book
380                    // without tracking market maker attribution from MarketDepthL2 events.
381                    let sub = match client.market_depth(&contract, rows).subscribe() {
382                        Ok(s) => s,
383                        Err(e) => {
384                            error!(symbol = %symbol, error = %e, "Failed to subscribe to depth");
385                            let _ = tx.send(Err(DataError::Socket(format!(
386                                "depth subscription {symbol}: {e}"
387                            ))));
388                            return;
389                        }
390                    };
391
392                    let mut aggregator = DepthAggregator::new();
393
394                    // `iter()`, not `iter_data()`, so that the subscription's notices are
395                    // logged below. The venue reset (317) is not one of them: `ibapi`
396                    // delivers it as `MarketDepths::Reset` data.
397                    for item in sub.iter() {
398                        let item = match item {
399                            Ok(i) => i,
400                            Err(e) => {
401                                error!(symbol = %symbol, error = %e, "Depth subscription error");
402                                let _ = tx.send(Err(DataError::Socket(format!(
403                                    "depth subscription {symbol}: {e}"
404                                ))));
405                                break;
406                            }
407                        };
408
409                        let depth = match item {
410                            SubscriptionItem::Data(d) => {
411                                if matches!(d, MarketDepths::Reset) {
412                                    // `aggregator.update` drops the local book and returns
413                                    // the emptied one, which is forwarded below.
414                                    warn!(
415                                        symbol = %symbol,
416                                        "Market depth RESET by venue; dropping local book"
417                                    );
418                                }
419                                d
420                            }
421                            // A notice is non-fatal and carries no depth
422                            // payload. `iter_data()` used to log these at `warn!`; keep
423                            // that, so switching iterators loses no observability.
424                            SubscriptionItem::Notice(notice) => {
425                                warn!(
426                                    symbol = %symbol,
427                                    code = notice.code,
428                                    message = %notice.message,
429                                    "Depth subscription notice"
430                                );
431                                continue;
432                            }
433                        };
434                        // IB's MarketDepth events have no timestamp. Stamp `now`
435                        // BEFORE aggregating so the book's `time_received` (threaded
436                        // into the OrderBook) matches the MarketEvent envelope's.
437                        // Consumers should NOT interpret the MarketEvent's
438                        // time_exchange as a real venue timestamp for depth events.
439                        let now = Utc::now();
440                        if let Some(book_event) = aggregator.update(&depth, now) {
441                            let event = MarketEvent {
442                                time_exchange: now,
443                                time_received: now,
444                                exchange: ExchangeId::Ibkr,
445                                instrument: key.clone(),
446                                kind: DataKind::OrderBook(book_event),
447                            };
448
449                            if tx.send(Ok(event)).is_err() {
450                                break;
451                            }
452                        }
453                    }
454
455                    debug!(symbol = %symbol, "Depth subscription ended");
456                }));
457
458                if let Err(panic_info) = result {
459                    let msg = panic_info
460                        .downcast_ref::<&str>()
461                        .map(|s| s.to_string())
462                        .or_else(|| panic_info.downcast_ref::<String>().cloned())
463                        .unwrap_or_else(|| "unknown panic".to_string());
464                    error!(symbol = %symbol_clone, "Depth worker panicked: {msg}");
465                    let _ = tx_panic.send(Err(DataError::Socket(format!(
466                        "depth subscription {symbol_clone} panicked: {msg}"
467                    ))));
468                }
469            })
470            .map_err(|e| DataError::Socket(format!("Failed to spawn depth thread: {e}")))?;
471        Ok(())
472    }
473
474    fn run_trades_subscription(
475        client: Arc<Client>,
476        contract: ibapi::contracts::Contract,
477        key: K,
478        tx: mpsc::UnboundedSender<Result<MarketEvent<K, DataKind>, DataError>>,
479    ) -> Result<(), DataError> {
480        let symbol = contract.symbol.to_string();
481        let symbol_clone = symbol.clone();
482        let tx_panic = tx.clone();
483
484        std::thread::Builder::new()
485            .name(format!("ibkr-trades-{symbol}"))
486            .spawn(move || {
487                // Panic safety: parking_lot mutexes do not poison on panic, so shared state
488                // (ContractRegistry, etc.) remains usable. On panic the thread exits,
489                // tx is dropped, and the stream closes — caller observes EOF.
490                let result = catch_unwind(AssertUnwindSafe(|| {
491                    debug!(symbol = %symbol, "Starting trades subscription");
492
493                    let sub = match client.tick_by_tick(&contract, 0).all_last() {
494                        Ok(s) => s,
495                        Err(e) => {
496                            error!(symbol = %symbol, error = %e, "Failed to subscribe to trades");
497                            let _ = tx.send(Err(DataError::Socket(format!(
498                                "trades subscription {symbol}: {e}"
499                            ))));
500                            return;
501                        }
502                    };
503
504                    for trade in sub.iter_data() {
505                        let trade = match trade {
506                            Ok(t) => t,
507                            Err(e) => {
508                                error!(symbol = %symbol, error = %e, "Trades subscription error");
509                                let _ = tx.send(Err(DataError::Socket(format!(
510                                    "trades subscription {symbol}: {e}"
511                                ))));
512                                break;
513                            }
514                        };
515                        let now = Utc::now();
516                        let public_trade = match trades::from_ib_trade(&trade) {
517                            Some(t) => t,
518                            None => continue, // Skip invalid trades (NaN/Inf price or size)
519                        };
520                        let time_exchange = trades::parse_trade_time(&trade, now);
521
522                        let event = MarketEvent {
523                            time_exchange,
524                            time_received: now,
525                            exchange: ExchangeId::Ibkr,
526                            instrument: key.clone(),
527                            kind: DataKind::Trade(public_trade),
528                        };
529
530                        if tx.send(Ok(event)).is_err() {
531                            break;
532                        }
533                    }
534
535                    debug!(symbol = %symbol, "Trades subscription ended");
536                }));
537
538                if let Err(panic_info) = result {
539                    let msg = panic_info
540                        .downcast_ref::<&str>()
541                        .map(|s| s.to_string())
542                        .or_else(|| panic_info.downcast_ref::<String>().cloned())
543                        .unwrap_or_else(|| "unknown panic".to_string());
544                    error!(symbol = %symbol_clone, "Trades worker panicked: {msg}");
545                    let _ = tx_panic.send(Err(DataError::Socket(format!(
546                        "trades subscription {symbol_clone} panicked: {msg}"
547                    ))));
548                }
549            })
550            .map_err(|e| DataError::Socket(format!("Failed to spawn trades thread: {e}")))?;
551        Ok(())
552    }
553
554    fn run_option_greeks_subscription(
555        client: Arc<Client>,
556        contract: ibapi::contracts::Contract,
557        key: K,
558        tx: mpsc::UnboundedSender<Result<MarketEvent<K, DataKind>, DataError>>,
559    ) -> Result<(), DataError> {
560        if contract.security_type != SecurityType::Option {
561            return Err(DataError::Socket(format!(
562                "option Greeks subscription requires SecurityType::Option, got {:?} for {}",
563                contract.security_type, contract.symbol
564            )));
565        }
566
567        let symbol = contract.symbol.to_string();
568        let symbol_clone = symbol.clone();
569        let tx_panic = tx.clone();
570
571        std::thread::Builder::new()
572            .name(format!("ibkr-greeks-{symbol}"))
573            .spawn(move || {
574                // Panic safety: parking_lot mutexes do not poison on panic, so shared state
575                // (ContractRegistry, etc.) remains usable. On panic the thread exits,
576                // tx is dropped, and the stream closes — caller observes EOF.
577                let result = catch_unwind(AssertUnwindSafe(|| {
578                    debug!(symbol = %symbol, "Starting option Greeks subscription");
579
580                    let sub = match client.market_data(&contract).subscribe() {
581                        Ok(s) => s,
582                        Err(e) => {
583                            error!(symbol = %symbol, error = %e, "Failed to subscribe to option Greeks");
584                            let _ = tx.send(Err(DataError::Socket(format!(
585                                "option Greeks subscription {symbol}: {e}"
586                            ))));
587                            return;
588                        }
589                    };
590
591                    let aggregator = GreeksAggregator::new();
592
593                    for tick in sub.iter_data() {
594                        let tick = match tick {
595                            Ok(t) => t,
596                            Err(e) => {
597                                error!(symbol = %symbol, error = %e, "Option Greeks subscription error");
598                                let _ = tx.send(Err(DataError::Socket(format!(
599                                    "option Greeks subscription {symbol}: {e}"
600                                ))));
601                                break;
602                            }
603                        };
604                        if let Some(greeks) = aggregator.update(&tick) {
605                            let now = Utc::now();
606                            let event = MarketEvent {
607                                time_exchange: now,
608                                time_received: now,
609                                exchange: ExchangeId::Ibkr,
610                                instrument: key.clone(),
611                                kind: DataKind::OptionGreeks(greeks),
612                            };
613
614                            if tx.send(Ok(event)).is_err() {
615                                break;
616                            }
617                        }
618                    }
619
620                    debug!(symbol = %symbol, "Option Greeks subscription ended");
621                }));
622
623                if let Err(panic_info) = result {
624                    let msg = panic_info
625                        .downcast_ref::<&str>()
626                        .map(|s| s.to_string())
627                        .or_else(|| panic_info.downcast_ref::<String>().cloned())
628                        .unwrap_or_else(|| "unknown panic".to_string());
629                    error!(symbol = %symbol_clone, "Option Greeks worker panicked: {msg}");
630                    let _ = tx_panic.send(Err(DataError::Socket(format!(
631                        "option Greeks subscription {symbol_clone} panicked: {msg}"
632                    ))));
633                }
634            })
635            .map_err(|e| DataError::Socket(format!("Failed to spawn option Greeks thread: {e}")))?;
636        Ok(())
637    }
638}
639
640impl<K> Drop for IbkrMarketStream<K> {
641    fn drop(&mut self) {
642        debug!("Dropping IbkrMarketStream, disconnecting client");
643        self.client.disconnect();
644    }
645}
646
647/// Convert f64 to Decimal, returning None for invalid values.
648///
649/// Returns `None` for NaN, Infinity, and values that cannot be represented
650/// as Decimal (e.g., IB's DBL_MAX sentinel for "not available").
651///
652/// Note: f64→Decimal conversion introduces representation error (e.g., 0.1_f64
653/// becomes 0.1000000000000000055511151231257827021181583404541015625). This is
654/// unavoidable with IB's f64-based API.
655pub(crate) fn decimal_from_f64(value: f64) -> Option<Decimal> {
656    if !value.is_finite() {
657        return None;
658    }
659    Decimal::try_from(value).ok()
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn decimal_from_f64_handles_special_values() {
668        // Normal values
669        assert!(decimal_from_f64(100.0).is_some());
670        assert!(decimal_from_f64(-50.5).is_some());
671        assert!(decimal_from_f64(0.0).is_some());
672
673        // NaN and Infinity
674        assert!(decimal_from_f64(f64::NAN).is_none());
675        assert!(decimal_from_f64(f64::INFINITY).is_none());
676        assert!(decimal_from_f64(f64::NEG_INFINITY).is_none());
677
678        // f64::MAX is too large for Decimal
679        assert!(
680            decimal_from_f64(f64::MAX).is_none(),
681            "f64::MAX should not convert to Decimal"
682        );
683    }
684}