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}