Skip to main content

rustrade_data/exchange/ibkr/
historical.rs

1//! Historical data fetcher for Interactive Brokers.
2//!
3//! Provides one-shot historical bar data via `IbkrHistoricalData::fetch_candles()`
4//! and tick-level data via `fetch_historical_ticks()` / `fetch_historical_bid_ask()`.
5//! This is a separate utility API, not part of the streaming [`IbkrMarketStream`].
6//!
7//! # Historical Bars Example
8//!
9//! ```ignore
10//! use rustrade_data::exchange::ibkr::historical::{IbkrHistoricalData, HistoricalRequest};
11//! use ibapi::market_data::historical::{BarSize, WhatToShow, ToDuration};
12//!
13//! let client = IbkrHistoricalData::connect("127.0.0.1:4002", 101)?;
14//! let contract = ibapi::contracts::Contract::stock("AAPL").build();
15//!
16//! let request = HistoricalRequest {
17//!     contract,
18//!     end_date: None, // Current time
19//!     duration: 30.days(),
20//!     bar_size: BarSize::Day,
21//!     what_to_show: WhatToShow::Trades,
22//!     regular_trading_hours_only: true,
23//! };
24//!
25//! let candles = client.fetch_candles(request).await?;
26//! ```
27//!
28//! # Historical Ticks Example
29//!
30//! ```ignore
31//! use rustrade_data::exchange::ibkr::historical::{IbkrHistoricalData, HistoricalTickRequest};
32//! use time::macros::datetime;
33//!
34//! let client = IbkrHistoricalData::connect("127.0.0.1:4002", 102)?;
35//! let contract = ibapi::contracts::Contract::stock("AAPL").build();
36//!
37//! let request = HistoricalTickRequest {
38//!     contract,
39//!     start: Some(datetime!(2024-01-15 14:30 UTC)),
40//!     end: None,
41//!     number_of_ticks: 100,
42//!     regular_trading_hours_only: true,
43//! };
44//!
45//! let fetched = client.fetch_historical_ticks(request).await?;
46//! let trades = fetched.ticks; // `fetched.truncation_error` flags a confirmed short read
47//! ```
48//!
49//! # Why Vec Instead of Stream
50//!
51//! The historical tick methods return a `Vec<T>` (wrapped in [`HistoricalTicks`])
52//! rather than `Stream<Item = T>` because ibapi's underlying API is batch-oriented:
53//! you request up to 1000 ticks and IB sends them all in one response. Wrapping a
54//! fully-received batch in a Stream would add complexity without benefit. For large
55//! date ranges requiring multiple requests, consider implementing pagination at the
56//! caller level.
57//!
58//! [`IbkrMarketStream`]: super::IbkrMarketStream
59
60use super::options::{OptionChainEntry, OptionGreeks};
61use crate::{
62    books::Level,
63    error::DataError,
64    subscription::{
65        book::OrderBookL1,
66        candle::{Candle, IntervalStep, close_time_from_open},
67        trade::PublicTrade,
68    },
69};
70use chrono::{DateTime, Duration as ChronoDuration, Utc};
71use ibapi::{
72    client::blocking::Client,
73    contracts::{Contract, SecurityType},
74    market_data::{
75        IgnoreSize, TradingHours,
76        historical::{BarSize, Duration, TickBidAsk, TickLast, WhatToShow},
77    },
78};
79use rust_decimal::Decimal;
80use smol_str::format_smolstr;
81use std::{
82    hash::{Hash, Hasher},
83    sync::Arc,
84};
85use time::OffsetDateTime;
86use tracing::{debug, info, warn};
87
88/// Re-export commonly used types from ibapi for caller convenience.
89///
90/// Note: Importing this trait couples your code to ibapi's API surface.
91/// This is intentional — the trait provides `.days()`, `.months()` etc.
92/// syntax needed for `HistoricalRequest::duration`.
93pub use ibapi::market_data::historical::ToDuration;
94
95/// Historical data fetcher for Interactive Brokers.
96///
97/// Wraps an IB client connection for fetching historical OHLCV bars and
98/// tick-level data (trades, bid/ask quotes). Unlike [`IbkrMarketStream`],
99/// this is a one-shot request/response API.
100///
101/// [`IbkrMarketStream`]: super::IbkrMarketStream
102#[derive(Debug)]
103pub struct IbkrHistoricalData {
104    client: Arc<Client>,
105}
106
107impl IbkrHistoricalData {
108    /// Connect to TWS/Gateway for historical data requests.
109    ///
110    /// # Arguments
111    ///
112    /// * `url` - Host and port (e.g., "127.0.0.1:4002" for Gateway paper)
113    /// * `client_id` - Unique client ID (must differ from other connections)
114    ///
115    /// # Errors
116    ///
117    /// Returns error if connection fails.
118    pub fn connect(url: &str, client_id: i32) -> Result<Self, DataError> {
119        info!(%url, client_id, "Connecting to IB for historical data");
120
121        let client = Client::connect(url, client_id)
122            .map_err(|e| DataError::Socket(format!("IB connect: {e}")))?;
123
124        Ok(Self {
125            client: Arc::new(client),
126        })
127    }
128
129    /// Create from an existing client connection.
130    ///
131    /// # Shared Connection Hazards
132    ///
133    /// IB multiplexes all requests over a single TCP connection per [`Client`].
134    /// Sharing this `Arc<Client>` with an active streaming subscription (quotes,
135    /// trades, depth via [`IbkrMarketStream`]) will contend on the same wire.
136    /// Concurrent historical requests may also trigger IB pacing violations
137    /// (error 162: "Historical data request pacing violation").
138    ///
139    /// For production use, prefer a dedicated [`Client`] connection (separate
140    /// `client_id`) for historical data fetching.
141    ///
142    /// [`IbkrMarketStream`]: super::IbkrMarketStream
143    pub fn from_client(client: Arc<Client>) -> Self {
144        Self { client }
145    }
146
147    /// Disconnect from IB Gateway.
148    ///
149    /// Signals the ibapi client to shut down and releases the client ID for reuse.
150    /// When constructed via [`Self::connect`], `Drop` already calls this automatically
151    /// once this is the sole owner of the `Arc<Client>` — explicit calls are typically
152    /// unnecessary.
153    ///
154    /// When constructed via [`Self::from_client`] with a shared `Arc<Client>`, calling
155    /// `disconnect()` **terminates the connection for all owners** (other
156    /// [`IbkrHistoricalData`] instances or external holders of the same `Arc`).
157    ///
158    /// This is idempotent — calling it multiple times is safe.
159    pub fn disconnect(&self) {
160        debug!("Disconnecting IbkrHistoricalData");
161        self.client.disconnect();
162    }
163
164    /// Fetch historical candles for the given request.
165    ///
166    /// # Arguments
167    ///
168    /// * `request` - Historical data request parameters
169    ///
170    /// # Returns
171    ///
172    /// Vector of candles in chronological order (oldest first).
173    ///
174    /// # Errors
175    ///
176    /// Returns `DataError::Socket` if:
177    /// - IB rejects the request (invalid contract, pacing violation, no data permission)
178    /// - Network error during request
179    ///
180    /// Note: `DataError::Socket` is used for all IB API errors because `ibapi`
181    /// errors are unstructured strings. Callers cannot distinguish transient
182    /// network errors from permanent API rejections.
183    ///
184    /// # Notes
185    ///
186    /// - IB rate-limits historical data requests (pacing violations)
187    /// - Some data requires paid market data subscriptions
188    /// - Volume and trade count are only available for `WhatToShow::Trades`
189    /// - Each candle's `close_time` is the exclusive end-of-period boundary
190    ///   `bar_open + interval`, **not** the bar's own start (IB returns the start)
191    ///   and **not** the exchange session close (the library has no session
192    ///   calendar). For daily/weekly/monthly bars this is the **next** UTC period
193    ///   start: a Jan 15 daily bar → `close_time = Jan 16 00:00 UTC`; a January
194    ///   monthly bar → `Feb 1 00:00 UTC` (calendar arithmetic). The bar's own date
195    ///   is `close_time − interval`, so do **not** compare daily bars by
196    ///   `close_time.date()` — it shifts forward by one interval. See
197    ///   [`Candle::close_time`](crate::subscription::candle::Candle).
198    pub async fn fetch_candles(
199        &self,
200        request: HistoricalRequest,
201    ) -> Result<Vec<Candle>, DataError> {
202        let client = self.client.clone();
203        let symbol = request.contract.symbol.clone();
204
205        debug!(
206            symbol = %symbol,
207            bar_size = ?request.bar_size,
208            duration = ?request.duration,
209            "Fetching historical data"
210        );
211
212        let candles = tokio::task::spawn_blocking(move || {
213            let trading_hours = if request.regular_trading_hours_only {
214                TradingHours::Regular
215            } else {
216                TradingHours::Extended
217            };
218
219            // ibapi 3.x: historical bars are requested via a builder. `ending` is
220            // optional — omitting it anchors the window at the current time, matching
221            // the previous `end_date: None` behavior.
222            let mut builder = client
223                .historical_data(&request.contract, request.bar_size)
224                .what_to_show(request.what_to_show)
225                .duration(request.duration)
226                .trading_hours(trading_hours);
227            if let Some(end_date) = request.end_date {
228                builder = builder.ending(end_date);
229            }
230            let historical_data = builder
231                .fetch()
232                .map_err(|e| DataError::Socket(format!("historical data: {e}")))?;
233
234            let mut candles = Vec::with_capacity(historical_data.bars.len());
235            for bar in &historical_data.bars {
236                candles.push(bar_to_candle(bar, request.bar_size)?);
237            }
238
239            Ok::<_, DataError>(candles)
240        })
241        .await
242        .map_err(|e| {
243            if e.is_panic() {
244                DataError::Socket(format!("historical_data task panicked: {e}"))
245            } else {
246                DataError::Socket(format!("historical_data task cancelled: {e}"))
247            }
248        })??;
249
250        debug!(symbol = %symbol, count = candles.len(), "Received historical bars");
251
252        Ok(candles)
253    }
254
255    /// Fetch historical trade ticks for the given request.
256    ///
257    /// Returns individual trade executions (time & sales data) as [`PublicTrade`]s.
258    ///
259    /// # Arguments
260    ///
261    /// * `request` - Historical tick request parameters
262    ///
263    /// # Returns
264    ///
265    /// A [`HistoricalTicks`] carrying the trades in chronological order plus a
266    /// `truncation_error` flag. Invalid ticks (non-finite prices) are filtered
267    /// out with a warning.
268    ///
269    /// # Errors
270    ///
271    /// Returns `DataError::Socket` if IB rejects the request or network error occurs.
272    ///
273    /// # Notes
274    ///
275    /// - Maximum 1000 ticks per request (IB limit)
276    /// - Trade side is not available from IB historical data
277    /// - For larger ranges, paginate using last tick's timestamp as new `start`
278    /// - Tick IDs are unique within a single returned batch. Across separate
279    ///   calls (e.g. pagination), IDs are not guaranteed unique — ticks with
280    ///   identical `(timestamp, price, size)` may collide across batches.
281    ///   IB timestamps have only 1-second resolution and IB provides no
282    ///   native unique identifier.
283    /// - A mid-stream IB API error is surfaced two ways: ibapi 3.1+ yields it as
284    ///   an `Err` on the tick iterator, so this method logs a `warn!`, ends
285    ///   iteration, and sets [`HistoricalTicks::truncation_error`] on the
286    ///   returned value (the ticks received before the error are still returned).
287    ///   Separately, this method also emits a `warn!` when the number of raw ticks
288    ///   received is less than `request.number_of_ticks`. Because a short batch is
289    ///   also a normal end-of-data signal, treat that warning as a flag for
290    ///   investigation (or a prompt to paginate), **not** as proof of an error;
291    ///   `truncation_error.is_some()` is the authoritative signal for a confirmed error.
292    pub async fn fetch_historical_ticks(
293        &self,
294        request: HistoricalTickRequest,
295    ) -> Result<HistoricalTicks<PublicTrade>, DataError> {
296        if request.start.is_none() && request.end.is_none() {
297            return Err(DataError::Socket(
298                "HistoricalTickRequest: at least one of `start` or `end` must be set".into(),
299            ));
300        }
301
302        let client = self.client.clone();
303        let symbol = request.contract.symbol.clone();
304
305        debug!(
306            symbol = %symbol,
307            number_of_ticks = request.number_of_ticks,
308            "Fetching historical trade ticks"
309        );
310
311        let fetched = tokio::task::spawn_blocking(move || {
312            let trading_hours = if request.regular_trading_hours_only {
313                TradingHours::Regular
314            } else {
315                TradingHours::Extended
316            };
317
318            // ibapi 3.x: historical ticks use a builder; the tick type is selected
319            // by the terminal method (`.trade()` here). At least one of
320            // `starting`/`ending` is required (validated above).
321            let mut builder = client
322                .historical_ticks(&request.contract, request.number_of_ticks)
323                .trading_hours(trading_hours);
324            if let Some(start) = request.start {
325                builder = builder.starting(start);
326            }
327            if let Some(end) = request.end {
328                builder = builder.ending(end);
329            }
330            let subscription = builder
331                .trade()
332                .map_err(|e| DataError::Socket(format!("historical_ticks_trade: {e}")))?;
333
334            // ibapi 3.1+ wraps tick items in a `SubscriptionItem` envelope and
335            // surfaces mid-stream IB errors as `Err` on the iterator (3.0.1 dropped
336            // them silently). `iter_data()` filters non-fatal notices (ibapi logs
337            // them at `warn!`) and yields `Result<TickLast, Error>`; `collect_ticks`
338            // drains it, stops at the first `Err`, and records the reason.
339            let fetched = collect_ticks(
340                subscription.iter_data(),
341                request.contract.symbol.0.as_str(),
342                request.number_of_ticks,
343                tick_last_to_public_trade,
344                |e| {
345                    warn!(
346                        symbol = %request.contract.symbol.0,
347                        error = %e,
348                        "Historical trade tick stream ended with an IB error; \
349                         results may be truncated"
350                    );
351                },
352            );
353            Ok::<_, DataError>(fetched)
354        })
355        .await
356        .map_err(|e| {
357            if e.is_panic() {
358                DataError::Socket(format!("historical_ticks_trade task panicked: {e}"))
359            } else {
360                DataError::Socket(format!("historical_ticks_trade task cancelled: {e}"))
361            }
362        })??;
363
364        debug!(
365            symbol = %symbol,
366            count = fetched.ticks.len(),
367            truncation_error = ?fetched.truncation_error,
368            "Received historical trade ticks"
369        );
370
371        Ok(fetched)
372    }
373
374    /// Fetch historical bid/ask ticks for the given request.
375    ///
376    /// Returns historical best bid/ask quotes as [`OrderBookL1`] snapshots.
377    ///
378    /// # Arguments
379    ///
380    /// * `request` - Historical tick request parameters
381    /// * `ignore_size` - If true, return ticks even when size is zero
382    ///
383    /// # Returns
384    ///
385    /// A [`HistoricalTicks`] carrying the L1 quotes in chronological order plus a
386    /// `truncation_error` flag. Invalid ticks (non-finite prices) are filtered
387    /// out with a warning.
388    ///
389    /// # Errors
390    ///
391    /// Returns `DataError::Socket` if IB rejects the request or network error occurs.
392    ///
393    /// # Notes
394    ///
395    /// - Maximum 1000 ticks per request (IB limit)
396    /// - For larger ranges, paginate using last tick's timestamp as new `start`
397    /// - A mid-stream IB API error is surfaced two ways: ibapi 3.1+ yields it as
398    ///   an `Err` on the tick iterator, so this method logs a `warn!`, ends
399    ///   iteration, and sets [`HistoricalTicks::truncation_error`] on the
400    ///   returned value (the ticks received before the error are still returned).
401    ///   Separately, this method also emits a `warn!` when the number of raw ticks
402    ///   received is less than `request.number_of_ticks`. Because a short batch is
403    ///   also a normal end-of-data signal, treat that warning as a flag for
404    ///   investigation (or a prompt to paginate), **not** as proof of an error;
405    ///   `truncation_error.is_some()` is the authoritative signal for a confirmed error.
406    pub async fn fetch_historical_bid_ask(
407        &self,
408        request: HistoricalTickRequest,
409        ignore_size: bool,
410    ) -> Result<HistoricalTicks<OrderBookL1>, DataError> {
411        if request.start.is_none() && request.end.is_none() {
412            return Err(DataError::Socket(
413                "HistoricalTickRequest: at least one of `start` or `end` must be set".into(),
414            ));
415        }
416
417        let client = self.client.clone();
418        let symbol = request.contract.symbol.clone();
419
420        debug!(
421            symbol = %symbol,
422            number_of_ticks = request.number_of_ticks,
423            ignore_size,
424            "Fetching historical bid/ask ticks"
425        );
426
427        let fetched = tokio::task::spawn_blocking(move || {
428            let trading_hours = if request.regular_trading_hours_only {
429                TradingHours::Regular
430            } else {
431                TradingHours::Extended
432            };
433
434            // ibapi 3.x: same builder as trades, with `.bid_ask(IgnoreSize)` as the
435            // terminal. At least one of `starting`/`ending` is required (validated above).
436            let mut builder = client
437                .historical_ticks(&request.contract, request.number_of_ticks)
438                .trading_hours(trading_hours);
439            if let Some(start) = request.start {
440                builder = builder.starting(start);
441            }
442            if let Some(end) = request.end {
443                builder = builder.ending(end);
444            }
445            let ignore_size = if ignore_size {
446                IgnoreSize::Yes
447            } else {
448                IgnoreSize::No
449            };
450            let subscription = builder
451                .bid_ask(ignore_size)
452                .map_err(|e| DataError::Socket(format!("historical_ticks_bid_ask: {e}")))?;
453
454            // ibapi 3.1+ wraps tick items in a `SubscriptionItem` envelope and
455            // surfaces mid-stream IB errors as `Err` on the iterator (3.0.1 dropped
456            // them silently). `iter_data()` filters non-fatal notices (ibapi logs
457            // them at `warn!`) and yields `Result<TickBidAsk, Error>`; `collect_ticks`
458            // drains it, stops at the first `Err`, and records the reason. The
459            // bid/ask decode needs no sequence index, so `seq` is ignored.
460            let fetched = collect_ticks(
461                subscription.iter_data(),
462                request.contract.symbol.0.as_str(),
463                request.number_of_ticks,
464                |tick, _seq| tick_bid_ask_to_order_book_l1(tick),
465                |e| {
466                    warn!(
467                        symbol = %request.contract.symbol.0,
468                        error = %e,
469                        "Historical bid/ask tick stream ended with an IB error; \
470                         results may be truncated"
471                    );
472                },
473            );
474            Ok::<_, DataError>(fetched)
475        })
476        .await
477        .map_err(|e| {
478            if e.is_panic() {
479                DataError::Socket(format!("historical_ticks_bid_ask task panicked: {e}"))
480            } else {
481                DataError::Socket(format!("historical_ticks_bid_ask task cancelled: {e}"))
482            }
483        })??;
484
485        debug!(
486            symbol = %symbol,
487            count = fetched.ticks.len(),
488            truncation_error = ?fetched.truncation_error,
489            "Received historical bid/ask ticks"
490        );
491
492        Ok(fetched)
493    }
494
495    // ========================================================================
496    // Option Greeks Calculators
497    // ========================================================================
498
499    /// Calculate theoretical option Greeks given volatility and underlying price.
500    ///
501    /// This is a **calculator** — you provide the volatility and underlying price,
502    /// and IB computes the theoretical Greeks. This does NOT fetch market data.
503    ///
504    /// For real-time Greeks based on live market prices, use the streaming API
505    /// with `TickTypes::OptionComputation`.
506    ///
507    /// # Arguments
508    ///
509    /// * `contract` - Option contract (must be SecurityType::Option)
510    /// * `volatility` - Implied volatility to use (e.g., 0.25 for 25%)
511    /// * `underlying_price` - Underlying price to use for calculation
512    ///
513    /// # Returns
514    ///
515    /// [`OptionGreeks`] containing computed delta, gamma, theta, vega, and
516    /// theoretical option price.
517    ///
518    /// # Errors
519    ///
520    /// Returns `DataError::Socket` if IB rejects the request (invalid contract,
521    /// missing subscription, etc.).
522    ///
523    /// # Example
524    ///
525    /// ```ignore
526    /// let client = IbkrHistoricalData::connect("127.0.0.1:4002", 102)?;
527    /// let option = Contract::call("AAPL").strike(150.0).expires_on(2024, 12, 20).build();
528    ///
529    /// let greeks = client.calculate_theoretical_greeks(&option, 0.25, 148.0).await?;
530    /// if let Some(delta) = greeks.delta {
531    ///     println!("Delta: {:.3}", delta);
532    /// }
533    /// ```
534    pub async fn calculate_theoretical_greeks(
535        &self,
536        contract: &Contract,
537        volatility: f64,
538        underlying_price: f64,
539    ) -> Result<OptionGreeks, DataError> {
540        let client = self.client.clone();
541        let contract = contract.clone();
542        let symbol = contract.symbol.to_string();
543
544        debug!(
545            symbol = %symbol,
546            volatility,
547            underlying_price,
548            "Calculating theoretical option Greeks"
549        );
550
551        let greeks = tokio::task::spawn_blocking(move || {
552            let computation = client
553                .calculate_option_price(&contract, volatility, underlying_price)
554                .map_err(|e| DataError::Socket(format!("calculate_option_price: {e}")))?;
555
556            Ok::<_, DataError>(OptionGreeks::from_ib(&computation))
557        })
558        .await
559        .map_err(|e| {
560            if e.is_panic() {
561                DataError::Socket(format!("calculate_option_price task panicked: {e}"))
562            } else {
563                DataError::Socket(format!("calculate_option_price task cancelled: {e}"))
564            }
565        })??;
566
567        debug!(
568            symbol = %symbol,
569            delta = ?greeks.delta,
570            gamma = ?greeks.gamma,
571            "Calculated option Greeks"
572        );
573
574        Ok(greeks)
575    }
576
577    /// Calculate implied volatility from option price and underlying price.
578    ///
579    /// This is a **calculator** — you provide the option and underlying prices,
580    /// and IB computes the implied volatility using its options model.
581    ///
582    /// # Arguments
583    ///
584    /// * `contract` - Option contract (must be SecurityType::Option)
585    /// * `option_price` - Current or hypothetical option price
586    /// * `underlying_price` - Current or hypothetical underlying price
587    ///
588    /// # Returns
589    ///
590    /// Implied volatility as a decimal (e.g., 0.25 for 25% IV).
591    /// Also returns other Greeks computed at this IV level.
592    ///
593    /// # Errors
594    ///
595    /// Returns `DataError::Socket` if IB rejects the request or if IV cannot
596    /// be computed (e.g., option price is below intrinsic value).
597    ///
598    /// # Example
599    ///
600    /// ```ignore
601    /// let client = IbkrHistoricalData::connect("127.0.0.1:4002", 102)?;
602    /// let option = Contract::call("AAPL").strike(150.0).expires_on(2024, 12, 20).build();
603    ///
604    /// let greeks = client.calculate_implied_volatility(&option, 7.50, 155.0).await?;
605    /// if let Some(iv) = greeks.implied_volatility {
606    ///     println!("Implied Volatility: {:.1}%", iv * 100.0);
607    /// }
608    /// ```
609    pub async fn calculate_implied_volatility(
610        &self,
611        contract: &Contract,
612        option_price: f64,
613        underlying_price: f64,
614    ) -> Result<OptionGreeks, DataError> {
615        let client = self.client.clone();
616        let contract = contract.clone();
617        let symbol = contract.symbol.to_string();
618
619        debug!(
620            symbol = %symbol,
621            option_price,
622            underlying_price,
623            "Calculating implied volatility"
624        );
625
626        let greeks = tokio::task::spawn_blocking(move || {
627            let computation = client
628                .calculate_implied_volatility(&contract, option_price, underlying_price)
629                .map_err(|e| DataError::Socket(format!("calculate_implied_volatility: {e}")))?;
630
631            Ok::<_, DataError>(OptionGreeks::from_ib(&computation))
632        })
633        .await
634        .map_err(|e| {
635            if e.is_panic() {
636                DataError::Socket(format!("calculate_implied_volatility task panicked: {e}"))
637            } else {
638                DataError::Socket(format!("calculate_implied_volatility task cancelled: {e}"))
639            }
640        })??;
641
642        debug!(
643            symbol = %symbol,
644            iv = ?greeks.implied_volatility,
645            "Calculated implied volatility"
646        );
647
648        Ok(greeks)
649    }
650
651    /// Fetch option chain metadata for an underlying security.
652    ///
653    /// Returns available expiration dates and strike prices for options on
654    /// the specified underlying. Does NOT return Greeks or prices — use
655    /// market data subscriptions for that.
656    ///
657    /// # Arguments
658    ///
659    /// * `symbol` - Underlying symbol (e.g., "AAPL")
660    /// * `exchange` - TWS's `fut_fop_exchange` filter, which narrows the chain
661    ///   to a *futures* options exchange. Pass `None` for every underlying that
662    ///   is not a future: TWS then returns one entry per listing exchange.
663    ///   Naming a routing exchange — `Some("SMART")` included — filters the
664    ///   result to zero rows. Only pass `Some(..)` for a futures underlying,
665    ///   e.g. `Some("CME")`.
666    /// * `security_type` - Type of underlying (typically `SecurityType::Stock`)
667    /// * `contract_id` - IB contract ID of the underlying. Must be a valid
668    ///   conId; IBKR's `reqSecDefOptParams` rejects `0` with
669    ///   `[321] Invalid contract id`. Resolve it first via contract details.
670    ///
671    /// # Returns
672    ///
673    /// An [`OptionChainResult`] carrying one [`OptionChainEntry`] per
674    /// exchange/trading-class combination plus a
675    /// [`truncation_error`](OptionChainResult::truncation_error) flag.
676    ///
677    /// # Errors
678    ///
679    /// Returns `DataError::Socket` if IB rejects the request up front.
680    ///
681    /// # Notes
682    ///
683    /// A mid-stream IB error (pacing/permission error, decode failure,
684    /// disconnect) does **not** discard the entries already decoded: each entry
685    /// is built from one complete IB message, so every received entry is valid
686    /// in isolation. This method logs a `warn!`, ends enumeration, and sets
687    /// [`OptionChainResult::truncation_error`] on the returned value — the
688    /// same contract as [`fetch_historical_ticks`](Self::fetch_historical_ticks).
689    /// Check `truncation_error.is_none()` before treating the entry list as the
690    /// complete chain catalog.
691    ///
692    /// # Example
693    ///
694    /// ```ignore
695    /// let client = IbkrHistoricalData::connect("127.0.0.1:4002", 102)?;
696    ///
697    /// // 265598 is AAPL's underlying conId; resolve via contract details for other symbols.
698    /// // `None` returns every listing exchange's option parameters.
699    /// let chains = client
700    ///     .fetch_option_chain("AAPL", None, SecurityType::Stock, 265598)
701    ///     .await?;
702    /// if let Some(reason) = &chains.truncation_error {
703    ///     eprintln!("chain enumeration was cut short: {reason}");
704    /// }
705    /// for chain in chains {
706    ///     println!("Exchange: {}, Expirations: {:?}", chain.exchange, chain.expirations);
707    /// }
708    /// ```
709    pub async fn fetch_option_chain(
710        &self,
711        symbol: &str,
712        exchange: Option<&str>,
713        security_type: SecurityType,
714        contract_id: i32,
715    ) -> Result<OptionChainResult, DataError> {
716        let client = self.client.clone();
717        let symbol = symbol.to_string();
718        let exchange = exchange.map(str::to_string);
719
720        debug!(
721            symbol = %symbol,
722            exchange = ?exchange,
723            "Fetching option chain"
724        );
725
726        let chains = tokio::task::spawn_blocking(move || {
727            // ibapi 4.0: `option_chain` returns a builder and `exchange` is a
728            // setter rather than a positional argument. An unset exchange is
729            // now omitted from the wire entirely instead of being sent as an
730            // empty string; TWS treats the two identically.
731            let builder = client.option_chain(&symbol, security_type, contract_id);
732            let builder = match exchange.as_deref() {
733                Some(exchange) => builder.exchange(exchange),
734                None => builder,
735            };
736            let subscription = builder
737                .subscribe()
738                .map_err(|e| DataError::Socket(format!("option_chain: {e}")))?;
739
740            // ibapi: `iter_data()` yields `Result<OptionChain, Error>`,
741            // filtering subscription-level notices, so an `Err` here is a
742            // genuine mid-stream failure. Option chains use the same
743            // Subscription/StreamDecoder machinery as historical ticks (N
744            // complete per-exchange messages + an end sentinel swallowed as
745            // `EndOfStream`), so mirror the tick methods: keep the entries
746            // already decoded — each is a complete, valid message — record the
747            // reason, and stop, instead of discarding them with a fail-fast Err.
748            let (entries, _received, truncation_error) = drain_until_error(
749                subscription.iter_data(),
750                16,
751                |chain, _seq| Some(OptionChainEntry::from_ib(chain)),
752                |e| {
753                    warn!(
754                        symbol = %symbol,
755                        error = %e,
756                        "Option chain enumeration ended with an IB error; \
757                         entries may be incomplete"
758                    );
759                },
760            );
761
762            debug!(
763                symbol = %symbol,
764                count = entries.len(),
765                truncation_error = ?truncation_error,
766                "Received option chain entries"
767            );
768
769            Ok::<_, DataError>(OptionChainResult {
770                entries,
771                truncation_error,
772            })
773        })
774        .await
775        .map_err(|e| {
776            if e.is_panic() {
777                DataError::Socket(format!("option_chain task panicked: {e}"))
778            } else {
779                DataError::Socket(format!("option_chain task cancelled: {e}"))
780            }
781        })??;
782
783        Ok(chains)
784    }
785}
786
787impl Drop for IbkrHistoricalData {
788    fn drop(&mut self) {
789        // Only disconnect when we are the sole owner of the Arc<Client>.
790        // `from_client(Arc<Client>)` lets callers share the client; disconnecting
791        // here would terminate the connection for other owners too.
792        //
793        // `Arc::strong_count` is approximate under concurrent clone/drop on other
794        // threads. A spurious skip is harmless (ibapi's own `Client::Drop` will
795        // eventually run when the last Arc drops); a spurious disconnect is also
796        // benign because `disconnect()` is idempotent.
797        if Arc::strong_count(&self.client) == 1 {
798            debug!("Dropping IbkrHistoricalData (sole owner), disconnecting client");
799            self.client.disconnect();
800        }
801    }
802}
803
804/// Parameters for a historical data request.
805#[derive(Debug, Clone)]
806pub struct HistoricalRequest {
807    /// IB contract to fetch data for.
808    pub contract: Contract,
809
810    /// End date/time for the data range.
811    ///
812    /// Pass `None` to use the current time.
813    ///
814    /// Requires the `time` crate for constructing `OffsetDateTime` values.
815    pub end_date: Option<OffsetDateTime>,
816
817    /// Duration of data to fetch (e.g., `30.days()`, `1.years()`).
818    ///
819    /// Use the [`ToDuration`] trait for convenient construction.
820    pub duration: Duration,
821
822    /// Bar size/resolution (e.g., `BarSize::Day`, `BarSize::Hour`).
823    pub bar_size: BarSize,
824
825    /// What prices to use for bar construction.
826    ///
827    /// - `Trades`: Trade prices (includes volume and count)
828    /// - `MidPoint`: Bid/ask midpoint
829    /// - `Bid`/`Ask`: Bid or ask prices only
830    pub what_to_show: WhatToShow,
831
832    /// If true, only include data from regular trading hours.
833    ///
834    /// When false, includes pre-market and after-hours data.
835    pub regular_trading_hours_only: bool,
836}
837
838impl HistoricalRequest {
839    /// Create a request for daily trade bars.
840    ///
841    /// Convenience constructor for the most common use case.
842    pub fn daily_trades(contract: Contract, days: i32) -> Self {
843        Self {
844            contract,
845            end_date: None,
846            duration: days.days(),
847            bar_size: BarSize::Day,
848            what_to_show: WhatToShow::Trades,
849            regular_trading_hours_only: true,
850        }
851    }
852}
853
854/// Parameters for a historical tick data request.
855///
856/// Unlike [`HistoricalRequest`] which fetches OHLCV bars, this requests
857/// individual tick-level data (trades or bid/ask quotes).
858///
859/// # Time Range
860///
861/// Either `start` or `end` must be specified (not both `None`).
862/// IB returns ticks forward from `start` or backward from `end`.
863///
864/// # Limits
865///
866/// IB limits requests to 1000 ticks maximum. For larger ranges,
867/// make multiple requests using the last tick's timestamp as the
868/// new `start` time.
869#[derive(Debug, Clone)]
870pub struct HistoricalTickRequest {
871    /// IB contract to fetch tick data for.
872    pub contract: Contract,
873
874    /// Start time for the tick range (fetch forward from here).
875    ///
876    /// Either `start` or `end` must be specified.
877    pub start: Option<OffsetDateTime>,
878
879    /// End time for the tick range (fetch backward to here).
880    ///
881    /// Either `start` or `end` must be specified.
882    pub end: Option<OffsetDateTime>,
883
884    /// Maximum number of ticks to return (max 1000).
885    pub number_of_ticks: i32,
886
887    /// If true, only include ticks from regular trading hours.
888    pub regular_trading_hours_only: bool,
889}
890
891impl HistoricalTickRequest {
892    /// Create a request for recent ticks ending now.
893    ///
894    /// Fetches the most recent `count` ticks (up to 1000) ending at current time.
895    pub fn recent(contract: Contract, count: i32) -> Self {
896        Self {
897            contract,
898            start: None,
899            end: Some(OffsetDateTime::now_utc()),
900            number_of_ticks: count.min(1000),
901            regular_trading_hours_only: true,
902        }
903    }
904}
905
906/// Outcome of a historical tick fetch.
907///
908/// Wraps the collected ticks together with whether the stream was cut short by a
909/// mid-stream IB error. A short `ticks` count on its own is ambiguous — it is
910/// also the normal end-of-data signal — so
911/// [`truncation_error`](Self::truncation_error) disambiguates the *confirmed*
912/// error case, letting callers react programmatically (retry, alert, paginate)
913/// instead of parsing logs.
914#[non_exhaustive]
915#[derive(Debug, Clone, PartialEq)]
916pub struct HistoricalTicks<T> {
917    /// Ticks collected in chronological order, up to the point the stream ended.
918    ///
919    /// Invalid ticks (non-finite prices) are filtered out, so this may be shorter
920    /// than the number of raw ticks IB sent.
921    pub ticks: Vec<T>,
922
923    /// `Some(reason)` if a mid-stream IB error ended the stream early — `reason`
924    /// is the formatted `ibapi::Error` — meaning [`ticks`](Self::ticks) may be
925    /// incomplete. `None` on a clean end-of-data, which can still return fewer
926    /// ticks than requested when that much history simply isn't available.
927    ///
928    /// The reason is a formatted string rather than a typed error, matching this
929    /// module's existing `DataError::Socket(String)` convention: `ibapi::Error`
930    /// is `#[non_exhaustive]` and not meaningfully sub-classifiable for this use
931    /// case, so a typed wrapper would create false precision and a second point
932    /// that must be kept in sync with `ibapi`'s version. Do not match on the
933    /// string contents — treat it as human-facing detail and use `.is_some()`
934    /// for the programmatic "was it truncated?" check.
935    pub truncation_error: Option<String>,
936}
937
938/// Consumes the wrapper and iterates its [`ticks`](HistoricalTicks::ticks),
939/// discarding [`truncation_error`](HistoricalTicks::truncation_error). Lets
940/// callers that only need the data write `for tick in fetched { .. }`, matching
941/// the pre-`HistoricalTicks` `Vec<T>` ergonomics; inspect the flag before
942/// iterating when truncation matters.
943impl<T> IntoIterator for HistoricalTicks<T> {
944    type Item = T;
945    type IntoIter = std::vec::IntoIter<T>;
946
947    fn into_iter(self) -> Self::IntoIter {
948        self.ticks.into_iter()
949    }
950}
951
952/// Outcome of an option chain fetch.
953///
954/// Wraps the collected [`OptionChainEntry`]s together with whether enumeration
955/// was cut short by a mid-stream IB error. An empty `entries` on its own is
956/// ambiguous — a wrong `exchange` filter also legitimately returns zero rows —
957/// so [`truncation_error`](Self::truncation_error) disambiguates the
958/// *confirmed* error case, letting callers react programmatically (retry,
959/// alert) instead of parsing logs.
960#[non_exhaustive]
961#[derive(Debug, Clone, PartialEq)]
962pub struct OptionChainResult {
963    /// Entries received before enumeration ended, one per
964    /// (exchange, trading class) combination.
965    ///
966    /// Each entry is decoded from one complete IB message, so every entry
967    /// present is valid in isolation even when the enumeration was truncated.
968    pub entries: Vec<OptionChainEntry>,
969
970    /// `Some(reason)` if a mid-stream IB error ended enumeration early —
971    /// `reason` is the formatted `ibapi::Error` — meaning
972    /// [`entries`](Self::entries) may be incomplete. `None` on a clean
973    /// end-of-enumeration; an empty `entries` with `None` means the underlying
974    /// genuinely has no listed options for the requested `exchange` filter,
975    /// not an error.
976    ///
977    /// The reason is a formatted string rather than a typed error for the same
978    /// reasons as [`HistoricalTicks::truncation_error`]: do not match on the
979    /// string contents — treat it as human-facing detail and use `.is_some()`
980    /// as the programmatic "was this truncated?" check.
981    pub truncation_error: Option<String>,
982}
983
984/// Consumes the wrapper and iterates its [`entries`](OptionChainResult::entries),
985/// discarding [`truncation_error`](OptionChainResult::truncation_error). Lets
986/// callers that only need the data write `for chain in chains { .. }`, matching
987/// the pre-`OptionChainResult` `Vec<OptionChainEntry>` ergonomics; inspect the
988/// flag before iterating when truncation matters.
989impl IntoIterator for OptionChainResult {
990    type Item = OptionChainEntry;
991    type IntoIter = std::vec::IntoIter<OptionChainEntry>;
992
993    fn into_iter(self) -> Self::IntoIter {
994        self.entries.into_iter()
995    }
996}
997
998/// Drain a subscription iterator, stopping at the first `Err` yielded by
999/// `iter`. Shared by `collect_ticks` and `fetch_option_chain` — both consume
1000/// the same `Subscription`/`iter_data()` machinery, whose `Err` is a genuine
1001/// mid-stream failure, not a warning-level notice (`iter_data()` already
1002/// filters non-fatal notices).
1003///
1004/// `map(&raw, seq)` decodes/validates a raw item; `seq` is the zero-based
1005/// index among *raw* items received so far (gaps from rejected items are not
1006/// reflected in `seq`, matching the numbering `tick_last_to_public_trade` uses
1007/// for ID generation). Returning `None` from `map` silently drops an invalid
1008/// item — a data-quality filter that does **not** count as truncation.
1009///
1010/// `on_error(&err)` runs exactly once, when `iter` yields `Err`, so callers
1011/// can log a stream-specific message before this function records the
1012/// formatted reason and stops.
1013///
1014/// Returns `(items, received, truncation_error)`, where `received` counts
1015/// *raw* items pulled from `iter` before `map`'s filtering — so it reflects
1016/// wire truncation rather than data-quality drops.
1017fn drain_until_error<Raw, Out>(
1018    iter: impl Iterator<Item = Result<Raw, ibapi::Error>>,
1019    capacity: usize,
1020    mut map: impl FnMut(&Raw, usize) -> Option<Out>,
1021    on_error: impl FnOnce(&ibapi::Error),
1022) -> (Vec<Out>, usize, Option<String>) {
1023    let mut items = Vec::with_capacity(capacity);
1024    let mut received = 0usize;
1025    let mut truncation_error = None;
1026
1027    for (seq, item) in iter.enumerate() {
1028        let raw = match item {
1029            Ok(raw) => raw,
1030            Err(e) => {
1031                on_error(&e);
1032                truncation_error = Some(e.to_string());
1033                break;
1034            }
1035        };
1036        received += 1;
1037        if let Some(out) = map(&raw, seq) {
1038            items.push(out);
1039        }
1040    }
1041
1042    (items, received, truncation_error)
1043}
1044
1045/// Drain a tick iterator into a `HistoricalTicks<Out>` via
1046/// [`drain_until_error`], layering the tick-specific short-fetch heuristic on
1047/// top: the generic warning (`warn_if_short_tick_fetch`) fires only on a clean
1048/// end-of-data — never alongside a confirmed truncation — so call sites no
1049/// longer need to guard against a double warning themselves.
1050fn collect_ticks<Raw, Out>(
1051    iter: impl Iterator<Item = Result<Raw, ibapi::Error>>,
1052    symbol: &str,
1053    requested: i32,
1054    map: impl FnMut(&Raw, usize) -> Option<Out>,
1055    on_error: impl FnOnce(&ibapi::Error),
1056) -> HistoricalTicks<Out> {
1057    // Pre-size to the requested count (IB caps this at 1000); a mid-stream error
1058    // or short end-of-data only ever leaves this over-allocated, never under.
1059    let capacity = usize::try_from(requested).unwrap_or(0);
1060    let (ticks, received, truncation_error) = drain_until_error(iter, capacity, map, on_error);
1061
1062    // Only run the ambiguous short-batch heuristic when the stream ended
1063    // gracefully; a confirmed truncation is already recorded and logged above.
1064    if truncation_error.is_none() {
1065        warn_if_short_tick_fetch(symbol, received, requested);
1066    }
1067
1068    HistoricalTicks {
1069        ticks,
1070        truncation_error,
1071    }
1072}
1073
1074/// Warn when a historical tick fetch returned fewer raw ticks than requested.
1075///
1076/// With ibapi 3.1+, an explicit mid-stream IB error is surfaced as an `Err` on
1077/// the tick iterator and logged separately by the fetch loop. This warning
1078/// therefore covers the *residual* ambiguous case: the stream ended gracefully
1079/// (end-of-data) with fewer than `requested` ticks, which is indistinguishable
1080/// through the public API from a genuine end-of-data within the requested range.
1081///
1082/// This is therefore a **best-effort, flag-for-investigation** signal, **not** a
1083/// precise error indicator: it fires on every legitimately short batch (e.g. the
1084/// available history ends before `requested` ticks) as well as on truncation.
1085/// Callers needing completeness guarantees should treat it as a prompt to retry
1086/// or paginate, not as proof of an error.
1087///
1088/// `received` is the count of *raw* ticks pulled from the subscription, before
1089/// non-finite-price filtering — so this reflects wire truncation rather than
1090/// data-quality drops (which are warned per-tick elsewhere).
1091fn warn_if_short_tick_fetch(symbol: &str, received: usize, requested: i32) {
1092    let requested = usize::try_from(requested).unwrap_or(0);
1093    if received < requested {
1094        warn!(
1095            symbol = %symbol,
1096            received,
1097            requested,
1098            "Historical tick fetch returned fewer ticks than requested; this is \
1099             usually a legitimate end-of-data boundary (any mid-stream IB error is \
1100             logged separately). Investigate or paginate if completeness is required."
1101        );
1102    }
1103}
1104
1105/// Convert an ibapi `TickLast` (historical trade) to a rustrade `PublicTrade`.
1106///
1107/// # Arguments
1108///
1109/// * `tick` - The IB tick data
1110/// * `seq` - Sequence index within the batch (for unique ID generation)
1111///
1112/// # Side Field
1113///
1114/// IB historical tick data does not include trade side (buyer/seller initiated).
1115/// The `side` field is set to `None`.
1116///
1117/// # Returns
1118///
1119/// Returns `None` if the price is non-finite, or if the tick carries no usable
1120/// size (invalid data from IB — see the note on sizes below).
1121///
1122/// # Sizes are optional as of ibapi 4.0
1123///
1124/// `TickLast::size` is `Option<f64>`, not `i32`. `None` means TWS sent no value
1125/// at all — the field was absent, empty, or one of TWS's "unset" sentinels —
1126/// which is distinct from a real `Some(0.0)`. [`PublicTrade`] has no encoding
1127/// for "size unknown", and substituting zero would reintroduce exactly the
1128/// silent data loss the `Option<f64>` change fixed, so such a tick is dropped
1129/// with a `warn!` rather than fabricated. Fractional sizes (crypto, fractional
1130/// shares) now survive: the old `i32` decode truncated `0.5` to `0`.
1131fn tick_last_to_public_trade(tick: &TickLast, seq: usize) -> Option<PublicTrade> {
1132    if !tick.price.is_finite() {
1133        warn!(
1134            price = tick.price,
1135            "Historical tick has non-finite price, skipping"
1136        );
1137        return None;
1138    }
1139
1140    let Some(size) = tick.size.filter(|size| size.is_finite()) else {
1141        warn!(
1142            size = ?tick.size,
1143            "Historical tick has no usable size, skipping"
1144        );
1145        return None;
1146    };
1147
1148    let price = Decimal::try_from(tick.price).ok()?;
1149    let amount = Decimal::try_from(size).ok()?;
1150
1151    Some(PublicTrade {
1152        id: generate_tick_id(tick.timestamp, tick.price, tick.size, seq),
1153        price,
1154        amount,
1155        side: None,
1156    })
1157}
1158
1159/// Parse timestamp from a historical tick, preserving sub-second precision.
1160///
1161/// Returns `None` if the Unix timestamp is out of `DateTime<Utc>` range.
1162/// Callers should drop the tick rather than substitute a fabricated time
1163/// (which would corrupt chronological ordering).
1164fn parse_tick_timestamp(timestamp: OffsetDateTime) -> Option<DateTime<Utc>> {
1165    let unix_secs = timestamp.unix_timestamp();
1166    let dt = DateTime::from_timestamp(unix_secs, timestamp.nanosecond());
1167    if dt.is_none() {
1168        warn!(
1169            unix_timestamp = unix_secs,
1170            "Invalid tick timestamp from IB, skipping tick"
1171        );
1172    }
1173    dt
1174}
1175
1176/// Generate a unique ID for a historical tick.
1177///
1178/// IB doesn't provide tick IDs, so we generate one from a hash of
1179/// time + price + size + sequence index. The sequence index ensures
1180/// uniqueness within a batch when multiple trades have identical
1181/// (timestamp, price, size) — common since IB timestamps have only
1182/// 1-second resolution.
1183///
1184/// `size` is hashed through [`f64::to_bits`], as `price` already was, because
1185/// `f64` is not [`Hash`]. Hashing the `Option` rather than an unwrapped value
1186/// keeps "TWS sent no size" distinct from a real zero. Note that this makes the
1187/// generated id a function of the ibapi wire type: the same logical tick hashed
1188/// as `i32` under ibapi 3.x and as `Option<f64>` here produces different ids.
1189fn generate_tick_id(
1190    timestamp: OffsetDateTime,
1191    price: f64,
1192    size: Option<f64>,
1193    seq: usize,
1194) -> smol_str::SmolStr {
1195    let mut hasher = fnv::FnvHasher::default();
1196    timestamp.unix_timestamp_nanos().hash(&mut hasher);
1197    price.to_bits().hash(&mut hasher);
1198    size.map(f64::to_bits).hash(&mut hasher);
1199    seq.hash(&mut hasher);
1200    format_smolstr!("{:016x}", hasher.finish())
1201}
1202
1203/// Convert an ibapi `TickBidAsk` to a rustrade `OrderBookL1`.
1204///
1205/// # Returns
1206///
1207/// Returns `None` if any price is non-finite, or if either side carries no
1208/// usable size (invalid data from IB).
1209///
1210/// As of ibapi 4.0 `size_bid`/`size_ask` are `Option<f64>`; see
1211/// [`tick_last_to_public_trade`] for why an absent size drops the tick rather
1212/// than decoding as zero. Both sides are required because [`Level`] has no
1213/// encoding for a known price at an unknown size, and a substituted zero would
1214/// misrepresent the book.
1215fn tick_bid_ask_to_order_book_l1(tick: &TickBidAsk) -> Option<OrderBookL1> {
1216    if !tick.price_bid.is_finite() || !tick.price_ask.is_finite() {
1217        warn!(
1218            bid = tick.price_bid,
1219            ask = tick.price_ask,
1220            "Historical tick has non-finite price, skipping"
1221        );
1222        return None;
1223    }
1224
1225    let (Some(size_bid), Some(size_ask)) = (
1226        tick.size_bid.filter(|size| size.is_finite()),
1227        tick.size_ask.filter(|size| size.is_finite()),
1228    ) else {
1229        warn!(
1230            size_bid = ?tick.size_bid,
1231            size_ask = ?tick.size_ask,
1232            "Historical tick has no usable size, skipping"
1233        );
1234        return None;
1235    };
1236
1237    let bid_price = Decimal::try_from(tick.price_bid).ok()?;
1238    let ask_price = Decimal::try_from(tick.price_ask).ok()?;
1239    let bid_amount = Decimal::try_from(size_bid).ok()?;
1240    let ask_amount = Decimal::try_from(size_ask).ok()?;
1241
1242    Some(OrderBookL1 {
1243        last_update_time: parse_tick_timestamp(tick.timestamp)?,
1244        best_bid: Some(Level::new(bid_price, bid_amount)),
1245        best_ask: Some(Level::new(ask_price, ask_amount)),
1246    })
1247}
1248
1249/// Map an ibapi [`BarSize`] to the shared [`IntervalStep`] used to compute a
1250/// candle's exclusive `close_time` boundary.
1251///
1252/// Every bar size is a fixed-length step except `Month`, which is a calendar
1253/// month (variable length) and must use [`IntervalStep::Months`]. A `Day`/`Week`
1254/// step is fixed but can still cross a month boundary (e.g. a Jan 31 daily bar →
1255/// `Feb 1 00:00 UTC`).
1256fn bar_size_to_step(bar_size: BarSize) -> IntervalStep {
1257    match bar_size {
1258        BarSize::Sec => IntervalStep::Fixed(ChronoDuration::seconds(1)),
1259        BarSize::Sec5 => IntervalStep::Fixed(ChronoDuration::seconds(5)),
1260        BarSize::Sec10 => IntervalStep::Fixed(ChronoDuration::seconds(10)),
1261        BarSize::Sec15 => IntervalStep::Fixed(ChronoDuration::seconds(15)),
1262        BarSize::Sec30 => IntervalStep::Fixed(ChronoDuration::seconds(30)),
1263        BarSize::Min => IntervalStep::Fixed(ChronoDuration::minutes(1)),
1264        BarSize::Min2 => IntervalStep::Fixed(ChronoDuration::minutes(2)),
1265        BarSize::Min3 => IntervalStep::Fixed(ChronoDuration::minutes(3)),
1266        BarSize::Min4 => IntervalStep::Fixed(ChronoDuration::minutes(4)),
1267        BarSize::Min5 => IntervalStep::Fixed(ChronoDuration::minutes(5)),
1268        BarSize::Min10 => IntervalStep::Fixed(ChronoDuration::minutes(10)),
1269        BarSize::Min15 => IntervalStep::Fixed(ChronoDuration::minutes(15)),
1270        BarSize::Min20 => IntervalStep::Fixed(ChronoDuration::minutes(20)),
1271        BarSize::Min30 => IntervalStep::Fixed(ChronoDuration::minutes(30)),
1272        BarSize::Hour => IntervalStep::Fixed(ChronoDuration::hours(1)),
1273        BarSize::Hour2 => IntervalStep::Fixed(ChronoDuration::hours(2)),
1274        BarSize::Hour3 => IntervalStep::Fixed(ChronoDuration::hours(3)),
1275        BarSize::Hour4 => IntervalStep::Fixed(ChronoDuration::hours(4)),
1276        BarSize::Hour8 => IntervalStep::Fixed(ChronoDuration::hours(8)),
1277        BarSize::Day => IntervalStep::Fixed(ChronoDuration::days(1)),
1278        BarSize::Week => IntervalStep::Fixed(ChronoDuration::weeks(1)),
1279        BarSize::Month => IntervalStep::Months(1),
1280    }
1281}
1282
1283/// Convert an ibapi `Bar` to a rustrade [`Candle`].
1284///
1285/// `Bar.date` is the bar's **open** instant; the returned [`Candle::close_time`]
1286/// is the exclusive end-of-period boundary `open + interval`, computed via the
1287/// shared [`close_time_from_open`] helper from the requested `bar_size`.
1288///
1289/// Consequences of the boundary contract for IBKR consumers:
1290/// - **Daily/weekly/monthly** bars carry a calendar `Date` (anchored to midnight
1291///   UTC). The boundary is the **next** UTC period start, **not** the exchange
1292///   session close — e.g. a Jan 15 daily bar → `close_time = Jan 16 00:00 UTC`,
1293///   and a monthly Jan bar → `Feb 1 00:00 UTC` (calendar arithmetic, not
1294///   `+ Duration`). The bar's own date is therefore `close_time − interval`;
1295///   `close_time.date()` no longer equals the bar's date, so any
1296///   `group_by(close_time.date())` shifts by one interval. Do **not** compare
1297///   daily bars by `close_time.date()`.
1298/// - **Intraday** bars carry a full `OffsetDateTime`; the boundary is `open + interval`.
1299///
1300/// Note: `Bar::wap` (volume-weighted average price) is not mapped — rustrade's
1301/// `Candle` has no VWAP field.
1302///
1303/// # Errors
1304///
1305/// Returns `Err(DataError::Socket(...))` if the open timestamp or the computed
1306/// `close_time` falls outside the representable [`DateTime<Utc>`] range, or if any
1307/// price/volume value cannot be converted to `Decimal` (e.g. NaN/Infinity).
1308fn bar_to_candle(
1309    bar: &ibapi::market_data::historical::Bar,
1310    bar_size: BarSize,
1311) -> Result<Candle, DataError> {
1312    use ibapi::market_data::historical::BarTimestamp;
1313
1314    // `Bar.date` is the bar's OPEN instant: daily/weekly/monthly bars carry a
1315    // calendar `Date` (anchored here to midnight UTC), intraday bars a full
1316    // `OffsetDateTime`.
1317    let (secs, nanos) = match bar.date {
1318        BarTimestamp::DateTime(dt) => (dt.unix_timestamp(), dt.nanosecond()),
1319        BarTimestamp::Date(d) => (d.midnight().assume_utc().unix_timestamp(), 0),
1320    };
1321    let open_time = DateTime::from_timestamp(secs, nanos).ok_or_else(|| {
1322        DataError::Socket(format!("IB timestamp {secs} out of DateTime<Utc> range"))
1323    })?;
1324
1325    // close_time is the exclusive end-of-period boundary (open + interval).
1326    let close_time =
1327        close_time_from_open(open_time, bar_size_to_step(bar_size)).ok_or_else(|| {
1328            DataError::Socket(format!(
1329                "IB candle close_time overflow: open={open_time}, bar_size={bar_size:?}"
1330            ))
1331        })?;
1332
1333    let open =
1334        Decimal::try_from(bar.open).map_err(|e| DataError::Socket(format!("parse open: {e}")))?;
1335    let high =
1336        Decimal::try_from(bar.high).map_err(|e| DataError::Socket(format!("parse high: {e}")))?;
1337    let low =
1338        Decimal::try_from(bar.low).map_err(|e| DataError::Socket(format!("parse low: {e}")))?;
1339    let close =
1340        Decimal::try_from(bar.close).map_err(|e| DataError::Socket(format!("parse close: {e}")))?;
1341    // IB uses `-1` as the "not available" sentinel for both volume and trade
1342    // count (e.g. on MIDPOINT/BID/ASK bars). Map the sentinel to `None` (unknown)
1343    // rather than fabricating a zero — or, worse, a negative volume — that a
1344    // consumer could not distinguish from a genuine value.
1345    let volume = if bar.volume < 0.0 {
1346        None
1347    } else {
1348        Some(
1349            Decimal::try_from(bar.volume)
1350                .map_err(|e| DataError::Socket(format!("parse volume: {e}")))?,
1351        )
1352    };
1353    let trade_count = if bar.count < 0 {
1354        None
1355    } else {
1356        // Non-negative by the guard above.
1357        #[allow(clippy::cast_sign_loss)] // guarded: bar.count >= 0 in this branch
1358        Some(bar.count as u64)
1359    };
1360
1361    Ok(Candle {
1362        close_time,
1363        open,
1364        high,
1365        low,
1366        close,
1367        volume,
1368        trade_count,
1369    })
1370}
1371
1372#[cfg(test)]
1373// Test code may unwrap/expect freely since panics indicate test failure
1374#[allow(clippy::unwrap_used, clippy::expect_used)]
1375mod tests {
1376    use super::*;
1377    use chrono::{Datelike, Timelike};
1378    use time::macros::datetime;
1379
1380    /// Build an ibapi `Bar` at the given `BarTimestamp` with placeholder OHLCV.
1381    fn bar_at(
1382        date: ibapi::market_data::historical::BarTimestamp,
1383    ) -> ibapi::market_data::historical::Bar {
1384        ibapi::market_data::historical::Bar {
1385            date,
1386            open: 150.0,
1387            high: 155.0,
1388            low: 149.0,
1389            close: 153.5,
1390            volume: 1_000_000.0,
1391            wap: 152.0,
1392            count: 50_000,
1393        }
1394    }
1395
1396    #[test]
1397    fn bar_to_candle_converts_all_fields() {
1398        use rust_decimal_macros::dec;
1399
1400        // Intraday hourly bar opening at 16:00 → close_time = open + 1h = 17:00.
1401        let open = datetime!(2024-01-15 16:00 UTC);
1402        let bar = bar_at(open.into());
1403
1404        let candle = bar_to_candle(&bar, BarSize::Hour).unwrap();
1405
1406        assert_eq!(candle.open, dec!(150));
1407        assert_eq!(candle.high, dec!(155));
1408        assert_eq!(candle.low, dec!(149));
1409        assert_eq!(candle.close, dec!(153.5));
1410        assert_eq!(candle.volume, Some(dec!(1_000_000)));
1411        assert_eq!(candle.trade_count, Some(50_000));
1412
1413        // close_time is the exclusive boundary: open + 1 hour.
1414        assert_eq!(
1415            candle.close_time,
1416            DateTime::from_timestamp(open.unix_timestamp(), 0).unwrap() + ChronoDuration::hours(1)
1417        );
1418        assert_eq!(candle.close_time.hour(), 17);
1419    }
1420
1421    #[test]
1422    fn bar_to_candle_maps_unavailable_sentinel_to_none() {
1423        let mut bar = bar_at(datetime!(2024-01-15 16:00 UTC).into());
1424        bar.count = -1; // IB returns -1 for "not available"
1425        bar.volume = -1.0; // same sentinel on volume-less bar types
1426
1427        let candle = bar_to_candle(&bar, BarSize::Hour).unwrap();
1428
1429        // The -1 sentinel is "unknown", reported as `None` — never a fabricated
1430        // zero (trade count) or a nonsensical negative (volume).
1431        assert_eq!(candle.trade_count, None);
1432        assert_eq!(candle.volume, None);
1433    }
1434
1435    #[test]
1436    fn bar_to_candle_four_minute_boundary() {
1437        // `BarSize::Min4` arrived in ibapi 3.3.0. Pin its step here so the arm
1438        // added to `bar_size_to_step` is the 4-minute one and not a copy-paste
1439        // of a neighbouring arm — a wrong step silently mislabels every
1440        // `close_time` for this resolution rather than failing loudly.
1441        let open = datetime!(2024-01-15 16:00 UTC);
1442        let bar = bar_at(open.into());
1443
1444        let candle = bar_to_candle(&bar, BarSize::Min4).unwrap();
1445
1446        assert_eq!(
1447            candle.close_time,
1448            DateTime::from_timestamp(open.unix_timestamp(), 0).unwrap()
1449                + ChronoDuration::minutes(4)
1450        );
1451        assert_eq!(candle.close_time.minute(), 4);
1452    }
1453
1454    #[test]
1455    fn bar_to_candle_daily_boundary_is_next_day() {
1456        use ibapi::market_data::historical::BarTimestamp;
1457        use time::macros::date;
1458
1459        // A Jan 15 daily bar (date-only, midnight UTC) → close_time = Jan 16 00:00 UTC.
1460        let bar = bar_at(BarTimestamp::Date(date!(2024 - 01 - 15)));
1461        let candle = bar_to_candle(&bar, BarSize::Day).unwrap();
1462
1463        assert_eq!(candle.close_time.year(), 2024);
1464        assert_eq!(candle.close_time.month(), 1);
1465        assert_eq!(candle.close_time.day(), 16);
1466        assert_eq!(candle.close_time.hour(), 0);
1467        // close_time.date() no longer equals the bar's own date (it shifted +1 day).
1468        assert_ne!(candle.close_time.day(), 15);
1469    }
1470
1471    #[test]
1472    fn bar_to_candle_daily_boundary_crosses_month() {
1473        use ibapi::market_data::historical::BarTimestamp;
1474        use time::macros::date;
1475
1476        // A Jan 31 daily bar → Feb 1 00:00 UTC via Fixed(1 day).
1477        let bar = bar_at(BarTimestamp::Date(date!(2024 - 01 - 31)));
1478        let candle = bar_to_candle(&bar, BarSize::Day).unwrap();
1479
1480        assert_eq!(candle.close_time.month(), 2);
1481        assert_eq!(candle.close_time.day(), 1);
1482    }
1483
1484    #[test]
1485    fn bar_to_candle_monthly_boundary_is_calendar_month() {
1486        use ibapi::market_data::historical::BarTimestamp;
1487        use time::macros::date;
1488
1489        // A January monthly bar → Feb 1 00:00 UTC via Months(1), NOT +30 days.
1490        let bar = bar_at(BarTimestamp::Date(date!(2024 - 01 - 01)));
1491        let candle = bar_to_candle(&bar, BarSize::Month).unwrap();
1492
1493        assert_eq!(candle.close_time.year(), 2024);
1494        assert_eq!(candle.close_time.month(), 2);
1495        assert_eq!(candle.close_time.day(), 1);
1496        // Overflow / out-of-range boundary is surfaced as DataError, never a
1497        // silent fallback — covered directly by the helper's unit tests
1498        // (`close_time_from_open` returns None → DataError here).
1499    }
1500
1501    #[test]
1502    fn historical_request_daily_trades_builder() {
1503        let contract = Contract::stock("AAPL").build();
1504        let request = HistoricalRequest::daily_trades(contract, 30);
1505
1506        assert_eq!(request.contract.symbol.as_str(), "AAPL");
1507        assert!(request.end_date.is_none());
1508        assert!(matches!(request.bar_size, BarSize::Day));
1509        assert!(matches!(request.what_to_show, WhatToShow::Trades));
1510        assert!(request.regular_trading_hours_only);
1511    }
1512
1513    #[test]
1514    fn historical_tick_request_recent_builder() {
1515        let contract = Contract::stock("AAPL").build();
1516        let request = HistoricalTickRequest::recent(contract, 500);
1517
1518        assert_eq!(request.contract.symbol.as_str(), "AAPL");
1519        assert!(request.start.is_none());
1520        assert!(request.end.is_some()); // IB requires start or end to be set
1521        assert_eq!(request.number_of_ticks, 500);
1522        assert!(request.regular_trading_hours_only);
1523    }
1524
1525    #[test]
1526    fn historical_tick_request_recent_clamps_to_1000() {
1527        let contract = Contract::stock("AAPL").build();
1528        let request = HistoricalTickRequest::recent(contract, 5000);
1529
1530        assert_eq!(request.number_of_ticks, 1000);
1531    }
1532
1533    // ========================================================================
1534    // Historical Tick Conversion Tests
1535    // ========================================================================
1536
1537    fn make_tick_last(unix_time: i64, price: f64, size: Option<f64>) -> TickLast {
1538        use ibapi::market_data::historical::TickAttributeLast;
1539
1540        TickLast {
1541            timestamp: time::OffsetDateTime::from_unix_timestamp(unix_time)
1542                .unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
1543            tick_attribute_last: TickAttributeLast {
1544                past_limit: false,
1545                unreported: false,
1546            },
1547            price,
1548            size,
1549            exchange: String::new(),
1550            special_conditions: String::new(),
1551        }
1552    }
1553
1554    fn make_tick_bid_ask(
1555        unix_time: i64,
1556        bid_price: f64,
1557        bid_size: Option<f64>,
1558        ask_price: f64,
1559        ask_size: Option<f64>,
1560    ) -> TickBidAsk {
1561        use ibapi::market_data::historical::TickAttributeBidAsk;
1562
1563        TickBidAsk {
1564            timestamp: time::OffsetDateTime::from_unix_timestamp(unix_time)
1565                .unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
1566            tick_attribute_bid_ask: TickAttributeBidAsk {
1567                bid_past_low: false,
1568                ask_past_high: false,
1569            },
1570            price_bid: bid_price,
1571            price_ask: ask_price,
1572            size_bid: bid_size,
1573            size_ask: ask_size,
1574        }
1575    }
1576
1577    #[test]
1578    fn tick_last_converts_to_public_trade() {
1579        use rust_decimal_macros::dec;
1580
1581        let tick = make_tick_last(1700000000, 150.25, Some(100.0));
1582        let trade = tick_last_to_public_trade(&tick, 0).unwrap();
1583
1584        assert_eq!(trade.price, dec!(150.25));
1585        assert_eq!(trade.amount, dec!(100));
1586        assert!(trade.side.is_none());
1587        assert!(!trade.id.is_empty());
1588    }
1589
1590    #[test]
1591    fn tick_last_rejects_non_finite_price() {
1592        let tick = make_tick_last(1700000000, f64::NAN, Some(100.0));
1593        assert!(tick_last_to_public_trade(&tick, 0).is_none());
1594
1595        let tick = make_tick_last(1700000000, f64::INFINITY, Some(100.0));
1596        assert!(tick_last_to_public_trade(&tick, 0).is_none());
1597    }
1598
1599    /// The point of ibapi 4.0's `Option<f64>` size: the old `i32` decode
1600    /// truncated a fractional wire size such as `0.5` to `0`, silently losing
1601    /// real data on crypto and fractional-share feeds.
1602    #[test]
1603    fn tick_last_preserves_fractional_size() {
1604        use rust_decimal_macros::dec;
1605
1606        let tick = make_tick_last(1700000000, 150.25, Some(0.5));
1607        let trade = tick_last_to_public_trade(&tick, 0).expect("fractional size is valid");
1608
1609        assert_eq!(trade.amount, dec!(0.5));
1610    }
1611
1612    /// `None` is TWS sending no size at all, which is distinct from a real
1613    /// zero. `PublicTrade` cannot encode "size unknown", so the tick is dropped
1614    /// rather than fabricated as zero.
1615    #[test]
1616    fn tick_last_without_size_is_dropped() {
1617        assert!(tick_last_to_public_trade(&make_tick_last(1700000000, 150.25, None), 0).is_none());
1618    }
1619
1620    /// A non-finite size is as unusable as a non-finite price.
1621    #[test]
1622    fn tick_last_with_non_finite_size_is_dropped() {
1623        let nan = make_tick_last(1700000000, 150.25, Some(f64::NAN));
1624        assert!(tick_last_to_public_trade(&nan, 0).is_none());
1625
1626        let inf = make_tick_last(1700000000, 150.25, Some(f64::INFINITY));
1627        assert!(tick_last_to_public_trade(&inf, 0).is_none());
1628    }
1629
1630    /// `Some(0.0)` is a genuine zero TWS reported, not an absent field, so it
1631    /// must survive where `None` is dropped.
1632    #[test]
1633    fn tick_last_keeps_a_real_zero_size() {
1634        use rust_decimal_macros::dec;
1635
1636        let tick = make_tick_last(1700000000, 150.25, Some(0.0));
1637        let trade = tick_last_to_public_trade(&tick, 0).expect("a reported zero is real data");
1638
1639        assert_eq!(trade.amount, dec!(0));
1640    }
1641
1642    /// The generated id hashes the `Option`, so "no size" and a real zero do
1643    /// not collide.
1644    #[test]
1645    fn tick_id_distinguishes_absent_size_from_zero() {
1646        let absent = make_tick_last(1700000000, 150.25, None);
1647        let zero = make_tick_last(1700000000, 150.25, Some(0.0));
1648
1649        assert_ne!(
1650            generate_tick_id(absent.timestamp, absent.price, absent.size, 0),
1651            generate_tick_id(zero.timestamp, zero.price, zero.size, 0)
1652        );
1653    }
1654
1655    /// Either side missing a size makes the whole L1 snapshot unusable: `Level`
1656    /// has no encoding for a known price at an unknown size.
1657    #[test]
1658    fn tick_bid_ask_without_size_is_dropped() {
1659        let no_bid = make_tick_bid_ask(1700000000, 150.00, None, 150.05, Some(300.0));
1660        assert!(tick_bid_ask_to_order_book_l1(&no_bid).is_none());
1661
1662        let no_ask = make_tick_bid_ask(1700000000, 150.00, Some(500.0), 150.05, None);
1663        assert!(tick_bid_ask_to_order_book_l1(&no_ask).is_none());
1664    }
1665
1666    /// Fractional book sizes survive the same way trade sizes do.
1667    #[test]
1668    fn tick_bid_ask_preserves_fractional_sizes() {
1669        use rust_decimal_macros::dec;
1670
1671        let tick = make_tick_bid_ask(1700000000, 150.00, Some(0.25), 150.05, Some(0.75));
1672        let l1 = tick_bid_ask_to_order_book_l1(&tick).expect("fractional sizes are valid");
1673
1674        assert_eq!(l1.best_bid.expect("bid").amount, dec!(0.25));
1675        assert_eq!(l1.best_ask.expect("ask").amount, dec!(0.75));
1676    }
1677
1678    #[test]
1679    fn tick_last_generates_unique_ids() {
1680        let tick1 = make_tick_last(1700000000, 150.25, Some(100.0));
1681        let tick2 = make_tick_last(1700000001, 150.25, Some(100.0));
1682        let tick3 = make_tick_last(1700000000, 150.26, Some(100.0));
1683
1684        let id1 = generate_tick_id(tick1.timestamp, tick1.price, tick1.size, 0);
1685        let id2 = generate_tick_id(tick2.timestamp, tick2.price, tick2.size, 0);
1686        let id3 = generate_tick_id(tick3.timestamp, tick3.price, tick3.size, 0);
1687
1688        assert_ne!(id1, id2);
1689        assert_ne!(id1, id3);
1690        assert_ne!(id2, id3);
1691    }
1692
1693    #[test]
1694    fn tick_last_same_data_same_seq_same_id() {
1695        let tick1 = make_tick_last(1700000000, 150.25, Some(100.0));
1696        let tick2 = make_tick_last(1700000000, 150.25, Some(100.0));
1697
1698        let id1 = generate_tick_id(tick1.timestamp, tick1.price, tick1.size, 0);
1699        let id2 = generate_tick_id(tick2.timestamp, tick2.price, tick2.size, 0);
1700
1701        assert_eq!(id1, id2);
1702    }
1703
1704    #[test]
1705    fn tick_last_same_data_different_seq_different_id() {
1706        let tick1 = make_tick_last(1700000000, 150.25, Some(100.0));
1707        let tick2 = make_tick_last(1700000000, 150.25, Some(100.0));
1708
1709        let id1 = generate_tick_id(tick1.timestamp, tick1.price, tick1.size, 0);
1710        let id2 = generate_tick_id(tick2.timestamp, tick2.price, tick2.size, 1);
1711
1712        assert_ne!(id1, id2);
1713    }
1714
1715    #[test]
1716    fn tick_bid_ask_converts_to_order_book_l1() {
1717        use rust_decimal_macros::dec;
1718
1719        let tick = make_tick_bid_ask(1700000000, 150.00, Some(500.0), 150.05, Some(300.0));
1720        let l1 = tick_bid_ask_to_order_book_l1(&tick).unwrap();
1721
1722        let bid = l1.best_bid.unwrap();
1723        let ask = l1.best_ask.unwrap();
1724
1725        assert_eq!(bid.price, dec!(150.00));
1726        assert_eq!(bid.amount, dec!(500));
1727        assert_eq!(ask.price, dec!(150.05));
1728        assert_eq!(ask.amount, dec!(300));
1729        assert_eq!(l1.last_update_time.timestamp(), 1700000000);
1730    }
1731
1732    #[test]
1733    fn tick_bid_ask_rejects_non_finite_prices() {
1734        let tick = make_tick_bid_ask(1700000000, f64::NAN, Some(500.0), 150.05, Some(300.0));
1735        assert!(tick_bid_ask_to_order_book_l1(&tick).is_none());
1736
1737        let tick = make_tick_bid_ask(1700000000, 150.00, Some(500.0), f64::INFINITY, Some(300.0));
1738        assert!(tick_bid_ask_to_order_book_l1(&tick).is_none());
1739    }
1740
1741    #[test]
1742    fn parse_tick_timestamp_converts_correctly() {
1743        let ts = time::OffsetDateTime::from_unix_timestamp(1700000000).unwrap();
1744        let dt = parse_tick_timestamp(ts).unwrap();
1745
1746        assert_eq!(dt.timestamp(), 1700000000);
1747        assert_eq!(dt.timestamp_subsec_nanos(), 0);
1748    }
1749
1750    #[test]
1751    fn parse_tick_timestamp_preserves_subsecond_nanos() {
1752        let ts =
1753            time::OffsetDateTime::from_unix_timestamp_nanos(1_700_000_000_123_456_789).unwrap();
1754        let dt = parse_tick_timestamp(ts).unwrap();
1755
1756        assert_eq!(dt.timestamp(), 1700000000);
1757        assert_eq!(dt.timestamp_subsec_nanos(), 123_456_789);
1758    }
1759
1760    // ========================================================================
1761    // Short-fetch completeness mitigation (#122)
1762    // ========================================================================
1763    //
1764    // `warn_if_short_tick_fetch` has no return value to assert on — it only
1765    // emits a `tracing` warning. These tests exercise the threshold/conversion
1766    // logic for panic-freedom and document the intended boundary (fires on
1767    // `received < requested`, silent on `>=`, and the negative-`i32` guard).
1768
1769    #[test]
1770    fn warn_if_short_tick_fetch_threshold_is_panic_free() {
1771        // received < requested → would warn
1772        warn_if_short_tick_fetch("AAPL", 500, 1000);
1773        // received == requested → silent
1774        warn_if_short_tick_fetch("AAPL", 1000, 1000);
1775        // received > requested (defensive; IB caps at requested) → silent
1776        warn_if_short_tick_fetch("AAPL", 1001, 1000);
1777        // zero requested → silent (0 < 0 is false)
1778        warn_if_short_tick_fetch("AAPL", 0, 0);
1779    }
1780
1781    #[test]
1782    fn warn_if_short_tick_fetch_clamps_negative_requested_to_zero() {
1783        // A negative `number_of_ticks` must not panic on the i32→usize cast;
1784        // it clamps to 0, so any non-negative `received` is treated as complete.
1785        warn_if_short_tick_fetch("AAPL", 0, -1);
1786        warn_if_short_tick_fetch("AAPL", 5, i32::MIN);
1787    }
1788
1789    // ========================================================================
1790    // collect_ticks: mid-stream error / truncation handling
1791    // ========================================================================
1792    //
1793    // These exercise the `Err`/truncation branch that the live integration
1794    // tests cannot reach without a real IB Gateway. `ibapi::Error::Simple` is a
1795    // trivially constructible variant, so a fake `vec![Ok, Err, Ok]` iterator
1796    // drives the exact match/break/bookkeeping the fetch methods rely on.
1797
1798    #[test]
1799    fn collect_ticks_stops_and_records_reason_on_mid_stream_error() {
1800        // Two good ticks, then an IB error, then a tick that must never be
1801        // reached because iteration stops at the first `Err`.
1802        let items: Vec<Result<TickLast, ibapi::Error>> = vec![
1803            Ok(make_tick_last(1_700_000_000, 100.0, Some(10.0))),
1804            Ok(make_tick_last(1_700_000_001, 101.0, Some(10.0))),
1805            Err(ibapi::Error::Simple("boom".into())),
1806            Ok(make_tick_last(1_700_000_002, 999.0, Some(10.0))),
1807        ];
1808        let mut on_error_calls = 0;
1809
1810        let fetched = collect_ticks(
1811            items.into_iter(),
1812            "TEST",
1813            10,
1814            tick_last_to_public_trade,
1815            |_e| on_error_calls += 1,
1816        );
1817
1818        assert_eq!(
1819            fetched.ticks.len(),
1820            2,
1821            "ticks after the error must be dropped"
1822        );
1823        assert_eq!(
1824            fetched.truncation_error.as_deref(),
1825            Some("error occurred: boom"),
1826            "the formatted ibapi::Error must be recorded"
1827        );
1828        assert_eq!(on_error_calls, 1, "on_error fires exactly once");
1829    }
1830
1831    #[test]
1832    fn collect_ticks_clean_end_of_data_is_not_truncated() {
1833        let items: Vec<Result<TickLast, ibapi::Error>> = vec![
1834            Ok(make_tick_last(1_700_000_000, 100.0, Some(10.0))),
1835            Ok(make_tick_last(1_700_000_001, 101.0, Some(10.0))),
1836        ];
1837
1838        let fetched = collect_ticks(
1839            items.into_iter(),
1840            "TEST",
1841            2,
1842            tick_last_to_public_trade,
1843            |_e| panic!("on_error must not fire when the stream never yields Err"),
1844        );
1845
1846        assert_eq!(fetched.ticks.len(), 2);
1847        assert_eq!(fetched.truncation_error, None);
1848    }
1849
1850    #[test]
1851    fn collect_ticks_filters_non_finite_without_flagging_truncation() {
1852        // A NaN price is a data-quality drop, not a wire truncation: the tick is
1853        // filtered by the map closure but `truncation_error` stays `None`.
1854        let items: Vec<Result<TickLast, ibapi::Error>> = vec![
1855            Ok(make_tick_last(1_700_000_000, f64::NAN, Some(10.0))),
1856            Ok(make_tick_last(1_700_000_001, 100.0, Some(10.0))),
1857        ];
1858
1859        let fetched = collect_ticks(
1860            items.into_iter(),
1861            "TEST",
1862            2,
1863            tick_last_to_public_trade,
1864            |_e| panic!("no error in this stream"),
1865        );
1866
1867        assert_eq!(
1868            fetched.ticks.len(),
1869            1,
1870            "NaN tick dropped by the map closure"
1871        );
1872        assert_eq!(
1873            fetched.truncation_error, None,
1874            "filtering is not truncation"
1875        );
1876    }
1877
1878    // ========================================================================
1879    // drain_until_error: shared drain loop (ticks + option chains)
1880    // ========================================================================
1881    //
1882    // The `collect_ticks` tests above exercise the helper through the tick
1883    // path; these drive it directly with `OptionChain` items — the
1884    // `fetch_option_chain` usage, whose truncation branch the live
1885    // integration tests cannot reach without a real IB Gateway.
1886
1887    fn make_option_chain(exchange: &str) -> ibapi::contracts::OptionChain {
1888        ibapi::contracts::OptionChain {
1889            underlying_contract_id: 265598,
1890            trading_class: "AAPL".to_string(),
1891            multiplier: "100".to_string(),
1892            exchange: exchange.to_string(),
1893            expirations: vec!["20300118".to_string()],
1894            strikes: vec![100.0, 105.0],
1895        }
1896    }
1897
1898    #[test]
1899    fn drain_until_error_keeps_entries_decoded_before_the_error() {
1900        // Two good chains, then an IB error, then a chain that must never be
1901        // reached because iteration stops at the first `Err`.
1902        let items: Vec<Result<ibapi::contracts::OptionChain, ibapi::Error>> = vec![
1903            Ok(make_option_chain("SMART")),
1904            Ok(make_option_chain("CBOE")),
1905            Err(ibapi::Error::Simple("pacing violation".into())),
1906            Ok(make_option_chain("NASDAQOM")),
1907        ];
1908        let mut on_error_calls = 0;
1909
1910        let (entries, received, truncation_error) = drain_until_error(
1911            items.into_iter(),
1912            16,
1913            |chain, _seq| Some(OptionChainEntry::from_ib(chain)),
1914            |_e| on_error_calls += 1,
1915        );
1916
1917        assert_eq!(
1918            entries
1919                .iter()
1920                .map(|e| e.exchange.as_str())
1921                .collect::<Vec<_>>(),
1922            vec!["SMART", "CBOE"],
1923            "entries decoded before the error are preserved; later ones dropped"
1924        );
1925        assert_eq!(received, 2, "raw items received before the error");
1926        // Substring, not exact match: the "error occurred: " prefix is
1927        // ibapi's thiserror wording, and `truncation_error`'s own contract
1928        // says not to match on the string contents.
1929        assert!(
1930            truncation_error
1931                .as_deref()
1932                .is_some_and(|reason| reason.contains("pacing violation")),
1933            "the formatted ibapi::Error must be recorded, got {truncation_error:?}"
1934        );
1935        assert_eq!(on_error_calls, 1, "on_error fires exactly once");
1936    }
1937
1938    #[test]
1939    fn drain_until_error_clean_end_reports_no_truncation() {
1940        let items: Vec<Result<ibapi::contracts::OptionChain, ibapi::Error>> = vec![
1941            Ok(make_option_chain("SMART")),
1942            Ok(make_option_chain("CBOE")),
1943        ];
1944
1945        let (entries, received, truncation_error) = drain_until_error(
1946            items.into_iter(),
1947            16,
1948            |chain, _seq| Some(OptionChainEntry::from_ib(chain)),
1949            |_e| panic!("on_error must not fire when the stream never yields Err"),
1950        );
1951
1952        assert_eq!(entries.len(), 2);
1953        assert_eq!(received, 2);
1954        assert_eq!(truncation_error, None);
1955    }
1956}