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}