use super::options::{OptionChainEntry, OptionGreeks};
use crate::{
books::Level,
error::DataError,
subscription::{
book::OrderBookL1,
candle::{Candle, IntervalStep, close_time_from_open},
trade::PublicTrade,
},
};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use ibapi::{
client::blocking::Client,
contracts::{Contract, SecurityType},
market_data::{
IgnoreSize, TradingHours,
historical::{BarSize, Duration, TickBidAsk, TickLast, WhatToShow},
},
};
use rust_decimal::Decimal;
use smol_str::format_smolstr;
use std::{
hash::{Hash, Hasher},
sync::Arc,
};
use time::OffsetDateTime;
use tracing::{debug, info, warn};
pub use ibapi::market_data::historical::ToDuration;
#[derive(Debug)]
pub struct IbkrHistoricalData {
client: Arc<Client>,
}
impl IbkrHistoricalData {
pub fn connect(url: &str, client_id: i32) -> Result<Self, DataError> {
info!(%url, client_id, "Connecting to IB for historical data");
let client = Client::connect(url, client_id)
.map_err(|e| DataError::Socket(format!("IB connect: {e}")))?;
Ok(Self {
client: Arc::new(client),
})
}
pub fn from_client(client: Arc<Client>) -> Self {
Self { client }
}
pub fn disconnect(&self) {
debug!("Disconnecting IbkrHistoricalData");
self.client.disconnect();
}
pub async fn fetch_candles(
&self,
request: HistoricalRequest,
) -> Result<Vec<Candle>, DataError> {
let client = self.client.clone();
let symbol = request.contract.symbol.clone();
debug!(
symbol = %symbol,
bar_size = ?request.bar_size,
duration = ?request.duration,
"Fetching historical data"
);
let candles = tokio::task::spawn_blocking(move || {
let trading_hours = if request.regular_trading_hours_only {
TradingHours::Regular
} else {
TradingHours::Extended
};
let mut builder = client
.historical_data(&request.contract, request.bar_size)
.what_to_show(request.what_to_show)
.duration(request.duration)
.trading_hours(trading_hours);
if let Some(end_date) = request.end_date {
builder = builder.ending(end_date);
}
let historical_data = builder
.fetch()
.map_err(|e| DataError::Socket(format!("historical data: {e}")))?;
let mut candles = Vec::with_capacity(historical_data.bars.len());
for bar in &historical_data.bars {
candles.push(bar_to_candle(bar, request.bar_size)?);
}
Ok::<_, DataError>(candles)
})
.await
.map_err(|e| {
if e.is_panic() {
DataError::Socket(format!("historical_data task panicked: {e}"))
} else {
DataError::Socket(format!("historical_data task cancelled: {e}"))
}
})??;
debug!(symbol = %symbol, count = candles.len(), "Received historical bars");
Ok(candles)
}
pub async fn fetch_historical_ticks(
&self,
request: HistoricalTickRequest,
) -> Result<HistoricalTicks<PublicTrade>, DataError> {
if request.start.is_none() && request.end.is_none() {
return Err(DataError::Socket(
"HistoricalTickRequest: at least one of `start` or `end` must be set".into(),
));
}
let client = self.client.clone();
let symbol = request.contract.symbol.clone();
debug!(
symbol = %symbol,
number_of_ticks = request.number_of_ticks,
"Fetching historical trade ticks"
);
let fetched = tokio::task::spawn_blocking(move || {
let trading_hours = if request.regular_trading_hours_only {
TradingHours::Regular
} else {
TradingHours::Extended
};
let mut builder = client
.historical_ticks(&request.contract, request.number_of_ticks)
.trading_hours(trading_hours);
if let Some(start) = request.start {
builder = builder.starting(start);
}
if let Some(end) = request.end {
builder = builder.ending(end);
}
let subscription = builder
.trade()
.map_err(|e| DataError::Socket(format!("historical_ticks_trade: {e}")))?;
let fetched = collect_ticks(
subscription.iter_data(),
request.contract.symbol.0.as_str(),
request.number_of_ticks,
tick_last_to_public_trade,
|e| {
warn!(
symbol = %request.contract.symbol.0,
error = %e,
"Historical trade tick stream ended with an IB error; \
results may be truncated"
);
},
);
Ok::<_, DataError>(fetched)
})
.await
.map_err(|e| {
if e.is_panic() {
DataError::Socket(format!("historical_ticks_trade task panicked: {e}"))
} else {
DataError::Socket(format!("historical_ticks_trade task cancelled: {e}"))
}
})??;
debug!(
symbol = %symbol,
count = fetched.ticks.len(),
truncation_error = ?fetched.truncation_error,
"Received historical trade ticks"
);
Ok(fetched)
}
pub async fn fetch_historical_bid_ask(
&self,
request: HistoricalTickRequest,
ignore_size: bool,
) -> Result<HistoricalTicks<OrderBookL1>, DataError> {
if request.start.is_none() && request.end.is_none() {
return Err(DataError::Socket(
"HistoricalTickRequest: at least one of `start` or `end` must be set".into(),
));
}
let client = self.client.clone();
let symbol = request.contract.symbol.clone();
debug!(
symbol = %symbol,
number_of_ticks = request.number_of_ticks,
ignore_size,
"Fetching historical bid/ask ticks"
);
let fetched = tokio::task::spawn_blocking(move || {
let trading_hours = if request.regular_trading_hours_only {
TradingHours::Regular
} else {
TradingHours::Extended
};
let mut builder = client
.historical_ticks(&request.contract, request.number_of_ticks)
.trading_hours(trading_hours);
if let Some(start) = request.start {
builder = builder.starting(start);
}
if let Some(end) = request.end {
builder = builder.ending(end);
}
let ignore_size = if ignore_size {
IgnoreSize::Yes
} else {
IgnoreSize::No
};
let subscription = builder
.bid_ask(ignore_size)
.map_err(|e| DataError::Socket(format!("historical_ticks_bid_ask: {e}")))?;
let fetched = collect_ticks(
subscription.iter_data(),
request.contract.symbol.0.as_str(),
request.number_of_ticks,
|tick, _seq| tick_bid_ask_to_order_book_l1(tick),
|e| {
warn!(
symbol = %request.contract.symbol.0,
error = %e,
"Historical bid/ask tick stream ended with an IB error; \
results may be truncated"
);
},
);
Ok::<_, DataError>(fetched)
})
.await
.map_err(|e| {
if e.is_panic() {
DataError::Socket(format!("historical_ticks_bid_ask task panicked: {e}"))
} else {
DataError::Socket(format!("historical_ticks_bid_ask task cancelled: {e}"))
}
})??;
debug!(
symbol = %symbol,
count = fetched.ticks.len(),
truncation_error = ?fetched.truncation_error,
"Received historical bid/ask ticks"
);
Ok(fetched)
}
pub async fn calculate_theoretical_greeks(
&self,
contract: &Contract,
volatility: f64,
underlying_price: f64,
) -> Result<OptionGreeks, DataError> {
let client = self.client.clone();
let contract = contract.clone();
let symbol = contract.symbol.to_string();
debug!(
symbol = %symbol,
volatility,
underlying_price,
"Calculating theoretical option Greeks"
);
let greeks = tokio::task::spawn_blocking(move || {
let computation = client
.calculate_option_price(&contract, volatility, underlying_price)
.map_err(|e| DataError::Socket(format!("calculate_option_price: {e}")))?;
Ok::<_, DataError>(OptionGreeks::from_ib(&computation))
})
.await
.map_err(|e| {
if e.is_panic() {
DataError::Socket(format!("calculate_option_price task panicked: {e}"))
} else {
DataError::Socket(format!("calculate_option_price task cancelled: {e}"))
}
})??;
debug!(
symbol = %symbol,
delta = ?greeks.delta,
gamma = ?greeks.gamma,
"Calculated option Greeks"
);
Ok(greeks)
}
pub async fn calculate_implied_volatility(
&self,
contract: &Contract,
option_price: f64,
underlying_price: f64,
) -> Result<OptionGreeks, DataError> {
let client = self.client.clone();
let contract = contract.clone();
let symbol = contract.symbol.to_string();
debug!(
symbol = %symbol,
option_price,
underlying_price,
"Calculating implied volatility"
);
let greeks = tokio::task::spawn_blocking(move || {
let computation = client
.calculate_implied_volatility(&contract, option_price, underlying_price)
.map_err(|e| DataError::Socket(format!("calculate_implied_volatility: {e}")))?;
Ok::<_, DataError>(OptionGreeks::from_ib(&computation))
})
.await
.map_err(|e| {
if e.is_panic() {
DataError::Socket(format!("calculate_implied_volatility task panicked: {e}"))
} else {
DataError::Socket(format!("calculate_implied_volatility task cancelled: {e}"))
}
})??;
debug!(
symbol = %symbol,
iv = ?greeks.implied_volatility,
"Calculated implied volatility"
);
Ok(greeks)
}
pub async fn fetch_option_chain(
&self,
symbol: &str,
exchange: Option<&str>,
security_type: SecurityType,
contract_id: i32,
) -> Result<OptionChainResult, DataError> {
let client = self.client.clone();
let symbol = symbol.to_string();
let exchange = exchange.map(str::to_string);
debug!(
symbol = %symbol,
exchange = ?exchange,
"Fetching option chain"
);
let chains = tokio::task::spawn_blocking(move || {
let builder = client.option_chain(&symbol, security_type, contract_id);
let builder = match exchange.as_deref() {
Some(exchange) => builder.exchange(exchange),
None => builder,
};
let subscription = builder
.subscribe()
.map_err(|e| DataError::Socket(format!("option_chain: {e}")))?;
let (entries, _received, truncation_error) = drain_until_error(
subscription.iter_data(),
16,
|chain, _seq| Some(OptionChainEntry::from_ib(chain)),
|e| {
warn!(
symbol = %symbol,
error = %e,
"Option chain enumeration ended with an IB error; \
entries may be incomplete"
);
},
);
debug!(
symbol = %symbol,
count = entries.len(),
truncation_error = ?truncation_error,
"Received option chain entries"
);
Ok::<_, DataError>(OptionChainResult {
entries,
truncation_error,
})
})
.await
.map_err(|e| {
if e.is_panic() {
DataError::Socket(format!("option_chain task panicked: {e}"))
} else {
DataError::Socket(format!("option_chain task cancelled: {e}"))
}
})??;
Ok(chains)
}
}
impl Drop for IbkrHistoricalData {
fn drop(&mut self) {
if Arc::strong_count(&self.client) == 1 {
debug!("Dropping IbkrHistoricalData (sole owner), disconnecting client");
self.client.disconnect();
}
}
}
#[derive(Debug, Clone)]
pub struct HistoricalRequest {
pub contract: Contract,
pub end_date: Option<OffsetDateTime>,
pub duration: Duration,
pub bar_size: BarSize,
pub what_to_show: WhatToShow,
pub regular_trading_hours_only: bool,
}
impl HistoricalRequest {
pub fn daily_trades(contract: Contract, days: i32) -> Self {
Self {
contract,
end_date: None,
duration: days.days(),
bar_size: BarSize::Day,
what_to_show: WhatToShow::Trades,
regular_trading_hours_only: true,
}
}
}
#[derive(Debug, Clone)]
pub struct HistoricalTickRequest {
pub contract: Contract,
pub start: Option<OffsetDateTime>,
pub end: Option<OffsetDateTime>,
pub number_of_ticks: i32,
pub regular_trading_hours_only: bool,
}
impl HistoricalTickRequest {
pub fn recent(contract: Contract, count: i32) -> Self {
Self {
contract,
start: None,
end: Some(OffsetDateTime::now_utc()),
number_of_ticks: count.min(1000),
regular_trading_hours_only: true,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct HistoricalTicks<T> {
pub ticks: Vec<T>,
pub truncation_error: Option<String>,
}
impl<T> IntoIterator for HistoricalTicks<T> {
type Item = T;
type IntoIter = std::vec::IntoIter<T>;
fn into_iter(self) -> Self::IntoIter {
self.ticks.into_iter()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct OptionChainResult {
pub entries: Vec<OptionChainEntry>,
pub truncation_error: Option<String>,
}
impl IntoIterator for OptionChainResult {
type Item = OptionChainEntry;
type IntoIter = std::vec::IntoIter<OptionChainEntry>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
fn drain_until_error<Raw, Out>(
iter: impl Iterator<Item = Result<Raw, ibapi::Error>>,
capacity: usize,
mut map: impl FnMut(&Raw, usize) -> Option<Out>,
on_error: impl FnOnce(&ibapi::Error),
) -> (Vec<Out>, usize, Option<String>) {
let mut items = Vec::with_capacity(capacity);
let mut received = 0usize;
let mut truncation_error = None;
for (seq, item) in iter.enumerate() {
let raw = match item {
Ok(raw) => raw,
Err(e) => {
on_error(&e);
truncation_error = Some(e.to_string());
break;
}
};
received += 1;
if let Some(out) = map(&raw, seq) {
items.push(out);
}
}
(items, received, truncation_error)
}
fn collect_ticks<Raw, Out>(
iter: impl Iterator<Item = Result<Raw, ibapi::Error>>,
symbol: &str,
requested: i32,
map: impl FnMut(&Raw, usize) -> Option<Out>,
on_error: impl FnOnce(&ibapi::Error),
) -> HistoricalTicks<Out> {
let capacity = usize::try_from(requested).unwrap_or(0);
let (ticks, received, truncation_error) = drain_until_error(iter, capacity, map, on_error);
if truncation_error.is_none() {
warn_if_short_tick_fetch(symbol, received, requested);
}
HistoricalTicks {
ticks,
truncation_error,
}
}
fn warn_if_short_tick_fetch(symbol: &str, received: usize, requested: i32) {
let requested = usize::try_from(requested).unwrap_or(0);
if received < requested {
warn!(
symbol = %symbol,
received,
requested,
"Historical tick fetch returned fewer ticks than requested; this is \
usually a legitimate end-of-data boundary (any mid-stream IB error is \
logged separately). Investigate or paginate if completeness is required."
);
}
}
fn tick_last_to_public_trade(tick: &TickLast, seq: usize) -> Option<PublicTrade> {
if !tick.price.is_finite() {
warn!(
price = tick.price,
"Historical tick has non-finite price, skipping"
);
return None;
}
let Some(size) = tick.size.filter(|size| size.is_finite()) else {
warn!(
size = ?tick.size,
"Historical tick has no usable size, skipping"
);
return None;
};
let price = Decimal::try_from(tick.price).ok()?;
let amount = Decimal::try_from(size).ok()?;
Some(PublicTrade {
id: generate_tick_id(tick.timestamp, tick.price, tick.size, seq),
price,
amount,
side: None,
})
}
fn parse_tick_timestamp(timestamp: OffsetDateTime) -> Option<DateTime<Utc>> {
let unix_secs = timestamp.unix_timestamp();
let dt = DateTime::from_timestamp(unix_secs, timestamp.nanosecond());
if dt.is_none() {
warn!(
unix_timestamp = unix_secs,
"Invalid tick timestamp from IB, skipping tick"
);
}
dt
}
fn generate_tick_id(
timestamp: OffsetDateTime,
price: f64,
size: Option<f64>,
seq: usize,
) -> smol_str::SmolStr {
let mut hasher = fnv::FnvHasher::default();
timestamp.unix_timestamp_nanos().hash(&mut hasher);
price.to_bits().hash(&mut hasher);
size.map(f64::to_bits).hash(&mut hasher);
seq.hash(&mut hasher);
format_smolstr!("{:016x}", hasher.finish())
}
fn tick_bid_ask_to_order_book_l1(tick: &TickBidAsk) -> Option<OrderBookL1> {
if !tick.price_bid.is_finite() || !tick.price_ask.is_finite() {
warn!(
bid = tick.price_bid,
ask = tick.price_ask,
"Historical tick has non-finite price, skipping"
);
return None;
}
let (Some(size_bid), Some(size_ask)) = (
tick.size_bid.filter(|size| size.is_finite()),
tick.size_ask.filter(|size| size.is_finite()),
) else {
warn!(
size_bid = ?tick.size_bid,
size_ask = ?tick.size_ask,
"Historical tick has no usable size, skipping"
);
return None;
};
let bid_price = Decimal::try_from(tick.price_bid).ok()?;
let ask_price = Decimal::try_from(tick.price_ask).ok()?;
let bid_amount = Decimal::try_from(size_bid).ok()?;
let ask_amount = Decimal::try_from(size_ask).ok()?;
Some(OrderBookL1 {
last_update_time: parse_tick_timestamp(tick.timestamp)?,
best_bid: Some(Level::new(bid_price, bid_amount)),
best_ask: Some(Level::new(ask_price, ask_amount)),
})
}
fn bar_size_to_step(bar_size: BarSize) -> IntervalStep {
match bar_size {
BarSize::Sec => IntervalStep::Fixed(ChronoDuration::seconds(1)),
BarSize::Sec5 => IntervalStep::Fixed(ChronoDuration::seconds(5)),
BarSize::Sec10 => IntervalStep::Fixed(ChronoDuration::seconds(10)),
BarSize::Sec15 => IntervalStep::Fixed(ChronoDuration::seconds(15)),
BarSize::Sec30 => IntervalStep::Fixed(ChronoDuration::seconds(30)),
BarSize::Min => IntervalStep::Fixed(ChronoDuration::minutes(1)),
BarSize::Min2 => IntervalStep::Fixed(ChronoDuration::minutes(2)),
BarSize::Min3 => IntervalStep::Fixed(ChronoDuration::minutes(3)),
BarSize::Min4 => IntervalStep::Fixed(ChronoDuration::minutes(4)),
BarSize::Min5 => IntervalStep::Fixed(ChronoDuration::minutes(5)),
BarSize::Min10 => IntervalStep::Fixed(ChronoDuration::minutes(10)),
BarSize::Min15 => IntervalStep::Fixed(ChronoDuration::minutes(15)),
BarSize::Min20 => IntervalStep::Fixed(ChronoDuration::minutes(20)),
BarSize::Min30 => IntervalStep::Fixed(ChronoDuration::minutes(30)),
BarSize::Hour => IntervalStep::Fixed(ChronoDuration::hours(1)),
BarSize::Hour2 => IntervalStep::Fixed(ChronoDuration::hours(2)),
BarSize::Hour3 => IntervalStep::Fixed(ChronoDuration::hours(3)),
BarSize::Hour4 => IntervalStep::Fixed(ChronoDuration::hours(4)),
BarSize::Hour8 => IntervalStep::Fixed(ChronoDuration::hours(8)),
BarSize::Day => IntervalStep::Fixed(ChronoDuration::days(1)),
BarSize::Week => IntervalStep::Fixed(ChronoDuration::weeks(1)),
BarSize::Month => IntervalStep::Months(1),
}
}
fn bar_to_candle(
bar: &ibapi::market_data::historical::Bar,
bar_size: BarSize,
) -> Result<Candle, DataError> {
use ibapi::market_data::historical::BarTimestamp;
let (secs, nanos) = match bar.date {
BarTimestamp::DateTime(dt) => (dt.unix_timestamp(), dt.nanosecond()),
BarTimestamp::Date(d) => (d.midnight().assume_utc().unix_timestamp(), 0),
};
let open_time = DateTime::from_timestamp(secs, nanos).ok_or_else(|| {
DataError::Socket(format!("IB timestamp {secs} out of DateTime<Utc> range"))
})?;
let close_time =
close_time_from_open(open_time, bar_size_to_step(bar_size)).ok_or_else(|| {
DataError::Socket(format!(
"IB candle close_time overflow: open={open_time}, bar_size={bar_size:?}"
))
})?;
let open =
Decimal::try_from(bar.open).map_err(|e| DataError::Socket(format!("parse open: {e}")))?;
let high =
Decimal::try_from(bar.high).map_err(|e| DataError::Socket(format!("parse high: {e}")))?;
let low =
Decimal::try_from(bar.low).map_err(|e| DataError::Socket(format!("parse low: {e}")))?;
let close =
Decimal::try_from(bar.close).map_err(|e| DataError::Socket(format!("parse close: {e}")))?;
let volume = if bar.volume < 0.0 {
None
} else {
Some(
Decimal::try_from(bar.volume)
.map_err(|e| DataError::Socket(format!("parse volume: {e}")))?,
)
};
let trade_count = if bar.count < 0 {
None
} else {
#[allow(clippy::cast_sign_loss)] Some(bar.count as u64)
};
Ok(Candle {
close_time,
open,
high,
low,
close,
volume,
trade_count,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use chrono::{Datelike, Timelike};
use time::macros::datetime;
fn bar_at(
date: ibapi::market_data::historical::BarTimestamp,
) -> ibapi::market_data::historical::Bar {
ibapi::market_data::historical::Bar {
date,
open: 150.0,
high: 155.0,
low: 149.0,
close: 153.5,
volume: 1_000_000.0,
wap: 152.0,
count: 50_000,
}
}
#[test]
fn bar_to_candle_converts_all_fields() {
use rust_decimal_macros::dec;
let open = datetime!(2024-01-15 16:00 UTC);
let bar = bar_at(open.into());
let candle = bar_to_candle(&bar, BarSize::Hour).unwrap();
assert_eq!(candle.open, dec!(150));
assert_eq!(candle.high, dec!(155));
assert_eq!(candle.low, dec!(149));
assert_eq!(candle.close, dec!(153.5));
assert_eq!(candle.volume, Some(dec!(1_000_000)));
assert_eq!(candle.trade_count, Some(50_000));
assert_eq!(
candle.close_time,
DateTime::from_timestamp(open.unix_timestamp(), 0).unwrap() + ChronoDuration::hours(1)
);
assert_eq!(candle.close_time.hour(), 17);
}
#[test]
fn bar_to_candle_maps_unavailable_sentinel_to_none() {
let mut bar = bar_at(datetime!(2024-01-15 16:00 UTC).into());
bar.count = -1; bar.volume = -1.0;
let candle = bar_to_candle(&bar, BarSize::Hour).unwrap();
assert_eq!(candle.trade_count, None);
assert_eq!(candle.volume, None);
}
#[test]
fn bar_to_candle_four_minute_boundary() {
let open = datetime!(2024-01-15 16:00 UTC);
let bar = bar_at(open.into());
let candle = bar_to_candle(&bar, BarSize::Min4).unwrap();
assert_eq!(
candle.close_time,
DateTime::from_timestamp(open.unix_timestamp(), 0).unwrap()
+ ChronoDuration::minutes(4)
);
assert_eq!(candle.close_time.minute(), 4);
}
#[test]
fn bar_to_candle_daily_boundary_is_next_day() {
use ibapi::market_data::historical::BarTimestamp;
use time::macros::date;
let bar = bar_at(BarTimestamp::Date(date!(2024 - 01 - 15)));
let candle = bar_to_candle(&bar, BarSize::Day).unwrap();
assert_eq!(candle.close_time.year(), 2024);
assert_eq!(candle.close_time.month(), 1);
assert_eq!(candle.close_time.day(), 16);
assert_eq!(candle.close_time.hour(), 0);
assert_ne!(candle.close_time.day(), 15);
}
#[test]
fn bar_to_candle_daily_boundary_crosses_month() {
use ibapi::market_data::historical::BarTimestamp;
use time::macros::date;
let bar = bar_at(BarTimestamp::Date(date!(2024 - 01 - 31)));
let candle = bar_to_candle(&bar, BarSize::Day).unwrap();
assert_eq!(candle.close_time.month(), 2);
assert_eq!(candle.close_time.day(), 1);
}
#[test]
fn bar_to_candle_monthly_boundary_is_calendar_month() {
use ibapi::market_data::historical::BarTimestamp;
use time::macros::date;
let bar = bar_at(BarTimestamp::Date(date!(2024 - 01 - 01)));
let candle = bar_to_candle(&bar, BarSize::Month).unwrap();
assert_eq!(candle.close_time.year(), 2024);
assert_eq!(candle.close_time.month(), 2);
assert_eq!(candle.close_time.day(), 1);
}
#[test]
fn historical_request_daily_trades_builder() {
let contract = Contract::stock("AAPL").build();
let request = HistoricalRequest::daily_trades(contract, 30);
assert_eq!(request.contract.symbol.as_str(), "AAPL");
assert!(request.end_date.is_none());
assert!(matches!(request.bar_size, BarSize::Day));
assert!(matches!(request.what_to_show, WhatToShow::Trades));
assert!(request.regular_trading_hours_only);
}
#[test]
fn historical_tick_request_recent_builder() {
let contract = Contract::stock("AAPL").build();
let request = HistoricalTickRequest::recent(contract, 500);
assert_eq!(request.contract.symbol.as_str(), "AAPL");
assert!(request.start.is_none());
assert!(request.end.is_some()); assert_eq!(request.number_of_ticks, 500);
assert!(request.regular_trading_hours_only);
}
#[test]
fn historical_tick_request_recent_clamps_to_1000() {
let contract = Contract::stock("AAPL").build();
let request = HistoricalTickRequest::recent(contract, 5000);
assert_eq!(request.number_of_ticks, 1000);
}
fn make_tick_last(unix_time: i64, price: f64, size: Option<f64>) -> TickLast {
use ibapi::market_data::historical::TickAttributeLast;
TickLast {
timestamp: time::OffsetDateTime::from_unix_timestamp(unix_time)
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
tick_attribute_last: TickAttributeLast {
past_limit: false,
unreported: false,
},
price,
size,
exchange: String::new(),
special_conditions: String::new(),
}
}
fn make_tick_bid_ask(
unix_time: i64,
bid_price: f64,
bid_size: Option<f64>,
ask_price: f64,
ask_size: Option<f64>,
) -> TickBidAsk {
use ibapi::market_data::historical::TickAttributeBidAsk;
TickBidAsk {
timestamp: time::OffsetDateTime::from_unix_timestamp(unix_time)
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
tick_attribute_bid_ask: TickAttributeBidAsk {
bid_past_low: false,
ask_past_high: false,
},
price_bid: bid_price,
price_ask: ask_price,
size_bid: bid_size,
size_ask: ask_size,
}
}
#[test]
fn tick_last_converts_to_public_trade() {
use rust_decimal_macros::dec;
let tick = make_tick_last(1700000000, 150.25, Some(100.0));
let trade = tick_last_to_public_trade(&tick, 0).unwrap();
assert_eq!(trade.price, dec!(150.25));
assert_eq!(trade.amount, dec!(100));
assert!(trade.side.is_none());
assert!(!trade.id.is_empty());
}
#[test]
fn tick_last_rejects_non_finite_price() {
let tick = make_tick_last(1700000000, f64::NAN, Some(100.0));
assert!(tick_last_to_public_trade(&tick, 0).is_none());
let tick = make_tick_last(1700000000, f64::INFINITY, Some(100.0));
assert!(tick_last_to_public_trade(&tick, 0).is_none());
}
#[test]
fn tick_last_preserves_fractional_size() {
use rust_decimal_macros::dec;
let tick = make_tick_last(1700000000, 150.25, Some(0.5));
let trade = tick_last_to_public_trade(&tick, 0).expect("fractional size is valid");
assert_eq!(trade.amount, dec!(0.5));
}
#[test]
fn tick_last_without_size_is_dropped() {
assert!(tick_last_to_public_trade(&make_tick_last(1700000000, 150.25, None), 0).is_none());
}
#[test]
fn tick_last_with_non_finite_size_is_dropped() {
let nan = make_tick_last(1700000000, 150.25, Some(f64::NAN));
assert!(tick_last_to_public_trade(&nan, 0).is_none());
let inf = make_tick_last(1700000000, 150.25, Some(f64::INFINITY));
assert!(tick_last_to_public_trade(&inf, 0).is_none());
}
#[test]
fn tick_last_keeps_a_real_zero_size() {
use rust_decimal_macros::dec;
let tick = make_tick_last(1700000000, 150.25, Some(0.0));
let trade = tick_last_to_public_trade(&tick, 0).expect("a reported zero is real data");
assert_eq!(trade.amount, dec!(0));
}
#[test]
fn tick_id_distinguishes_absent_size_from_zero() {
let absent = make_tick_last(1700000000, 150.25, None);
let zero = make_tick_last(1700000000, 150.25, Some(0.0));
assert_ne!(
generate_tick_id(absent.timestamp, absent.price, absent.size, 0),
generate_tick_id(zero.timestamp, zero.price, zero.size, 0)
);
}
#[test]
fn tick_bid_ask_without_size_is_dropped() {
let no_bid = make_tick_bid_ask(1700000000, 150.00, None, 150.05, Some(300.0));
assert!(tick_bid_ask_to_order_book_l1(&no_bid).is_none());
let no_ask = make_tick_bid_ask(1700000000, 150.00, Some(500.0), 150.05, None);
assert!(tick_bid_ask_to_order_book_l1(&no_ask).is_none());
}
#[test]
fn tick_bid_ask_preserves_fractional_sizes() {
use rust_decimal_macros::dec;
let tick = make_tick_bid_ask(1700000000, 150.00, Some(0.25), 150.05, Some(0.75));
let l1 = tick_bid_ask_to_order_book_l1(&tick).expect("fractional sizes are valid");
assert_eq!(l1.best_bid.expect("bid").amount, dec!(0.25));
assert_eq!(l1.best_ask.expect("ask").amount, dec!(0.75));
}
#[test]
fn tick_last_generates_unique_ids() {
let tick1 = make_tick_last(1700000000, 150.25, Some(100.0));
let tick2 = make_tick_last(1700000001, 150.25, Some(100.0));
let tick3 = make_tick_last(1700000000, 150.26, Some(100.0));
let id1 = generate_tick_id(tick1.timestamp, tick1.price, tick1.size, 0);
let id2 = generate_tick_id(tick2.timestamp, tick2.price, tick2.size, 0);
let id3 = generate_tick_id(tick3.timestamp, tick3.price, tick3.size, 0);
assert_ne!(id1, id2);
assert_ne!(id1, id3);
assert_ne!(id2, id3);
}
#[test]
fn tick_last_same_data_same_seq_same_id() {
let tick1 = make_tick_last(1700000000, 150.25, Some(100.0));
let tick2 = make_tick_last(1700000000, 150.25, Some(100.0));
let id1 = generate_tick_id(tick1.timestamp, tick1.price, tick1.size, 0);
let id2 = generate_tick_id(tick2.timestamp, tick2.price, tick2.size, 0);
assert_eq!(id1, id2);
}
#[test]
fn tick_last_same_data_different_seq_different_id() {
let tick1 = make_tick_last(1700000000, 150.25, Some(100.0));
let tick2 = make_tick_last(1700000000, 150.25, Some(100.0));
let id1 = generate_tick_id(tick1.timestamp, tick1.price, tick1.size, 0);
let id2 = generate_tick_id(tick2.timestamp, tick2.price, tick2.size, 1);
assert_ne!(id1, id2);
}
#[test]
fn tick_bid_ask_converts_to_order_book_l1() {
use rust_decimal_macros::dec;
let tick = make_tick_bid_ask(1700000000, 150.00, Some(500.0), 150.05, Some(300.0));
let l1 = tick_bid_ask_to_order_book_l1(&tick).unwrap();
let bid = l1.best_bid.unwrap();
let ask = l1.best_ask.unwrap();
assert_eq!(bid.price, dec!(150.00));
assert_eq!(bid.amount, dec!(500));
assert_eq!(ask.price, dec!(150.05));
assert_eq!(ask.amount, dec!(300));
assert_eq!(l1.last_update_time.timestamp(), 1700000000);
}
#[test]
fn tick_bid_ask_rejects_non_finite_prices() {
let tick = make_tick_bid_ask(1700000000, f64::NAN, Some(500.0), 150.05, Some(300.0));
assert!(tick_bid_ask_to_order_book_l1(&tick).is_none());
let tick = make_tick_bid_ask(1700000000, 150.00, Some(500.0), f64::INFINITY, Some(300.0));
assert!(tick_bid_ask_to_order_book_l1(&tick).is_none());
}
#[test]
fn parse_tick_timestamp_converts_correctly() {
let ts = time::OffsetDateTime::from_unix_timestamp(1700000000).unwrap();
let dt = parse_tick_timestamp(ts).unwrap();
assert_eq!(dt.timestamp(), 1700000000);
assert_eq!(dt.timestamp_subsec_nanos(), 0);
}
#[test]
fn parse_tick_timestamp_preserves_subsecond_nanos() {
let ts =
time::OffsetDateTime::from_unix_timestamp_nanos(1_700_000_000_123_456_789).unwrap();
let dt = parse_tick_timestamp(ts).unwrap();
assert_eq!(dt.timestamp(), 1700000000);
assert_eq!(dt.timestamp_subsec_nanos(), 123_456_789);
}
#[test]
fn warn_if_short_tick_fetch_threshold_is_panic_free() {
warn_if_short_tick_fetch("AAPL", 500, 1000);
warn_if_short_tick_fetch("AAPL", 1000, 1000);
warn_if_short_tick_fetch("AAPL", 1001, 1000);
warn_if_short_tick_fetch("AAPL", 0, 0);
}
#[test]
fn warn_if_short_tick_fetch_clamps_negative_requested_to_zero() {
warn_if_short_tick_fetch("AAPL", 0, -1);
warn_if_short_tick_fetch("AAPL", 5, i32::MIN);
}
#[test]
fn collect_ticks_stops_and_records_reason_on_mid_stream_error() {
let items: Vec<Result<TickLast, ibapi::Error>> = vec![
Ok(make_tick_last(1_700_000_000, 100.0, Some(10.0))),
Ok(make_tick_last(1_700_000_001, 101.0, Some(10.0))),
Err(ibapi::Error::Simple("boom".into())),
Ok(make_tick_last(1_700_000_002, 999.0, Some(10.0))),
];
let mut on_error_calls = 0;
let fetched = collect_ticks(
items.into_iter(),
"TEST",
10,
tick_last_to_public_trade,
|_e| on_error_calls += 1,
);
assert_eq!(
fetched.ticks.len(),
2,
"ticks after the error must be dropped"
);
assert_eq!(
fetched.truncation_error.as_deref(),
Some("error occurred: boom"),
"the formatted ibapi::Error must be recorded"
);
assert_eq!(on_error_calls, 1, "on_error fires exactly once");
}
#[test]
fn collect_ticks_clean_end_of_data_is_not_truncated() {
let items: Vec<Result<TickLast, ibapi::Error>> = vec![
Ok(make_tick_last(1_700_000_000, 100.0, Some(10.0))),
Ok(make_tick_last(1_700_000_001, 101.0, Some(10.0))),
];
let fetched = collect_ticks(
items.into_iter(),
"TEST",
2,
tick_last_to_public_trade,
|_e| panic!("on_error must not fire when the stream never yields Err"),
);
assert_eq!(fetched.ticks.len(), 2);
assert_eq!(fetched.truncation_error, None);
}
#[test]
fn collect_ticks_filters_non_finite_without_flagging_truncation() {
let items: Vec<Result<TickLast, ibapi::Error>> = vec![
Ok(make_tick_last(1_700_000_000, f64::NAN, Some(10.0))),
Ok(make_tick_last(1_700_000_001, 100.0, Some(10.0))),
];
let fetched = collect_ticks(
items.into_iter(),
"TEST",
2,
tick_last_to_public_trade,
|_e| panic!("no error in this stream"),
);
assert_eq!(
fetched.ticks.len(),
1,
"NaN tick dropped by the map closure"
);
assert_eq!(
fetched.truncation_error, None,
"filtering is not truncation"
);
}
fn make_option_chain(exchange: &str) -> ibapi::contracts::OptionChain {
ibapi::contracts::OptionChain {
underlying_contract_id: 265598,
trading_class: "AAPL".to_string(),
multiplier: "100".to_string(),
exchange: exchange.to_string(),
expirations: vec!["20300118".to_string()],
strikes: vec![100.0, 105.0],
}
}
#[test]
fn drain_until_error_keeps_entries_decoded_before_the_error() {
let items: Vec<Result<ibapi::contracts::OptionChain, ibapi::Error>> = vec![
Ok(make_option_chain("SMART")),
Ok(make_option_chain("CBOE")),
Err(ibapi::Error::Simple("pacing violation".into())),
Ok(make_option_chain("NASDAQOM")),
];
let mut on_error_calls = 0;
let (entries, received, truncation_error) = drain_until_error(
items.into_iter(),
16,
|chain, _seq| Some(OptionChainEntry::from_ib(chain)),
|_e| on_error_calls += 1,
);
assert_eq!(
entries
.iter()
.map(|e| e.exchange.as_str())
.collect::<Vec<_>>(),
vec!["SMART", "CBOE"],
"entries decoded before the error are preserved; later ones dropped"
);
assert_eq!(received, 2, "raw items received before the error");
assert!(
truncation_error
.as_deref()
.is_some_and(|reason| reason.contains("pacing violation")),
"the formatted ibapi::Error must be recorded, got {truncation_error:?}"
);
assert_eq!(on_error_calls, 1, "on_error fires exactly once");
}
#[test]
fn drain_until_error_clean_end_reports_no_truncation() {
let items: Vec<Result<ibapi::contracts::OptionChain, ibapi::Error>> = vec![
Ok(make_option_chain("SMART")),
Ok(make_option_chain("CBOE")),
];
let (entries, received, truncation_error) = drain_until_error(
items.into_iter(),
16,
|chain, _seq| Some(OptionChainEntry::from_ib(chain)),
|_e| panic!("on_error must not fire when the stream never yields Err"),
);
assert_eq!(entries.len(), 2);
assert_eq!(received, 2);
assert_eq!(truncation_error, None);
}
}