use std::collections::BTreeMap;
use optionstratlib::chains::OptionChain;
use optionstratlib::prelude::Positive;
use optionstratlib::{ExpirationDate, OptionStyle};
use rust_decimal::prelude::ToPrimitive;
use rust_decimal::{Decimal, RoundingStrategy};
use crate::domain::contract::{ContractKey, Underlying};
use crate::domain::market::{ChainSnapshot, InstrumentSpec, QuoteView};
use crate::domain::money::{PriceCents, Quantity};
use crate::domain::time::{SimTime, StepIndex};
use crate::error::BacktestError;
#[cfg(feature = "simulator")]
use crate::data::simulator::{ChainResponse, OptionPriceResponse};
const NANOS_PER_DAY: i64 = 86_400_000_000_000;
#[derive(Debug, Clone)]
pub struct SnapshotMeta {
pub ts: SimTime,
pub step: StepIndex,
pub anchor_ts: SimTime,
pub underlying: Underlying,
pub underlying_price: PriceCents,
pub tick_size_cents: PriceCents,
pub contract_multiplier: u32,
}
#[derive(Debug, Clone)]
pub struct RawQuote {
pub expiration: ExpirationDate,
pub strike: PriceCents,
pub style: OptionStyle,
pub bid: PriceCents,
pub ask: PriceCents,
pub bid_size: Quantity,
pub ask_size: Quantity,
pub implied_volatility: f64,
pub delta: f64,
pub gamma: f64,
pub theta: f64,
pub vega: f64,
}
#[must_use = "the converted price must be used"]
#[cfg_attr(not(feature = "simulator"), allow(dead_code))]
pub(crate) fn dollars_f64_to_price_cents(
field: &str,
value: f64,
) -> Result<PriceCents, BacktestError> {
if !value.is_finite() {
return Err(BacktestError::Conversion(format!(
"non-finite {field} price {value}"
)));
}
let dollars = Decimal::from_f64_retain(value).ok_or_else(|| {
BacktestError::Conversion(format!(
"{field} price {value} is not representable as a decimal"
))
})?;
PriceCents::from_decimal_dollars(dollars)
}
#[must_use = "the converted analytic must be used"]
fn analytic_f64_to_decimal(field: &str, value: f64) -> Result<Decimal, BacktestError> {
if !value.is_finite() {
return Err(BacktestError::Conversion(format!(
"non-finite {field} analytic {value}"
)));
}
Decimal::from_f64_retain(value).ok_or_else(|| {
BacktestError::Conversion(format!(
"{field} analytic {value} is not representable as a decimal"
))
})
}
#[inline]
#[must_use]
const fn floor_to_tick(value: u64, tick: u64) -> u64 {
value - value % tick
}
#[must_use = "the resolved expiration must be used"]
fn resolve_expiration(
expiration: &ExpirationDate,
anchor_ts: SimTime,
) -> Result<ExpirationDate, BacktestError> {
match expiration {
ExpirationDate::DateTime(instant) => Ok(ExpirationDate::DateTime(*instant)),
ExpirationDate::Days(days) => {
let offset = days
.to_dec()
.checked_mul(Decimal::from(NANOS_PER_DAY))
.ok_or(BacktestError::ArithmeticOverflow)?
.round_dp_with_strategy(0, RoundingStrategy::MidpointNearestEven)
.to_i128()
.ok_or(BacktestError::ArithmeticOverflow)?;
let expiration_ns = i128::from(anchor_ts.value())
.checked_add(offset)
.ok_or(BacktestError::ArithmeticOverflow)?;
let expiration_ns =
i64::try_from(expiration_ns).map_err(|_| BacktestError::ArithmeticOverflow)?;
Ok(ExpirationDate::DateTime(
chrono::DateTime::from_timestamp_nanos(expiration_ns),
))
}
}
}
#[must_use = "the converted snapshot must be used"]
pub fn raw_quotes_to_snapshot(
meta: &SnapshotMeta,
quotes: &[RawQuote],
) -> Result<ChainSnapshot, BacktestError> {
let spec = InstrumentSpec::new(meta.tick_size_cents, meta.contract_multiplier)?;
let tick = spec.tick_size_cents.value();
let mut map: BTreeMap<ContractKey, QuoteView> = BTreeMap::new();
for raw in quotes {
let strike = raw.strike.value();
if strike == 0 {
return Err(BacktestError::Conversion(
"non-positive strike 0".to_string(),
));
}
check_tick_aligned(strike, tick)?;
let bid = raw.bid.value();
let ask = raw.ask.value();
check_tick_aligned(bid, tick)?;
check_tick_aligned(ask, tick)?;
if bid > ask {
return Err(BacktestError::CrossedQuote { bid, ask });
}
let sum = bid
.checked_add(ask)
.ok_or(BacktestError::ArithmeticOverflow)?;
let mid = floor_to_tick(sum / 2, tick);
debug_assert!(bid <= mid && mid <= ask, "mid must sit within the spread");
let implied_volatility =
analytic_f64_to_decimal("implied_volatility", raw.implied_volatility)?;
let delta = analytic_f64_to_decimal("delta", raw.delta)?;
let gamma = analytic_f64_to_decimal("gamma", raw.gamma)?;
let theta = analytic_f64_to_decimal("theta", raw.theta)?;
let vega = analytic_f64_to_decimal("vega", raw.vega)?;
let expiration = resolve_expiration(&raw.expiration, meta.anchor_ts)?;
let contract = ContractKey {
underlying: meta.underlying.clone(),
expiration,
strike: raw.strike,
style: raw.style,
};
let view = QuoteView {
contract: contract.clone(),
bid: raw.bid,
ask: raw.ask,
mid: PriceCents::new(mid),
bid_size: raw.bid_size,
ask_size: raw.ask_size,
implied_volatility,
delta,
gamma,
theta,
vega,
};
if map.insert(contract, view).is_some() {
return Err(BacktestError::Conversion(format!(
"duplicate contract at strike {strike} for the same expiry and style"
)));
}
}
Ok(ChainSnapshot {
ts: meta.ts,
step: meta.step,
underlying: meta.underlying.clone(),
underlying_price: meta.underlying_price,
spec,
quotes: map,
})
}
#[inline]
fn check_tick_aligned(price: u64, tick: u64) -> Result<(), BacktestError> {
if price.is_multiple_of(tick) {
Ok(())
} else {
Err(BacktestError::PriceNotTickAligned { price, tick })
}
}
#[cfg(feature = "simulator")]
#[must_use = "the converted snapshot must be used"]
pub fn chain_response_to_snapshot(
resp: &ChainResponse,
step: StepIndex,
spec: InstrumentSpec,
anchor_ts: SimTime,
quote_size: Quantity,
) -> Result<ChainSnapshot, BacktestError> {
use chrono::{DateTime, Utc};
let underlying = Underlying::new(resp.underlying.as_str())?;
let ts_dt = DateTime::parse_from_rfc3339(&resp.timestamp).map_err(|e| {
BacktestError::Conversion(format!(
"unparseable snapshot timestamp {:?}: {e}",
resp.timestamp
))
})?;
let ts_ns = ts_dt.timestamp_nanos_opt().ok_or_else(|| {
BacktestError::Conversion(format!(
"snapshot timestamp {:?} is outside the nanosecond range",
resp.timestamp
))
})?;
let underlying_price = dollars_f64_to_price_cents("underlying", resp.price)?;
let mut raw: Vec<RawQuote> = Vec::with_capacity(resp.contracts.len().saturating_mul(2));
for contract in &resp.contracts {
let strike = dollars_f64_to_price_cents("strike", contract.strike)?;
let exp_dt = DateTime::parse_from_rfc3339(&contract.expiration).map_err(|e| {
BacktestError::Conversion(format!(
"unparseable contract expiration {:?}: {e}",
contract.expiration
))
})?;
let expiration = ExpirationDate::DateTime(exp_dt.with_timezone(&Utc));
let iv = contract.implied_volatility.ok_or_else(|| {
BacktestError::Conversion(format!(
"missing implied_volatility for the contract at strike {}",
strike.value()
))
})?;
let gamma = contract.gamma.unwrap_or(0.0);
raw.push(side_to_raw_quote(
expiration,
strike,
OptionStyle::Call,
&contract.call,
iv,
gamma,
quote_size,
)?);
raw.push(side_to_raw_quote(
expiration,
strike,
OptionStyle::Put,
&contract.put,
iv,
gamma,
quote_size,
)?);
}
let meta = SnapshotMeta {
ts: SimTime::new(ts_ns),
step,
anchor_ts,
underlying,
underlying_price,
tick_size_cents: spec.tick_size_cents,
contract_multiplier: spec.contract_multiplier,
};
raw_quotes_to_snapshot(&meta, &raw)
}
#[cfg(feature = "simulator")]
fn side_to_raw_quote(
expiration: ExpirationDate,
strike: PriceCents,
style: OptionStyle,
side: &OptionPriceResponse,
implied_volatility: f64,
gamma: f64,
size: Quantity,
) -> Result<RawQuote, BacktestError> {
let bid = side.bid.ok_or_else(|| {
BacktestError::Conversion(format!(
"missing bid for {style:?} at strike {}",
strike.value()
))
})?;
let ask = side.ask.ok_or_else(|| {
BacktestError::Conversion(format!(
"missing ask for {style:?} at strike {}",
strike.value()
))
})?;
Ok(RawQuote {
expiration,
strike,
style,
bid: dollars_f64_to_price_cents("bid", bid)?,
ask: dollars_f64_to_price_cents("ask", ask)?,
bid_size: size,
ask_size: size,
implied_volatility,
delta: side.delta.unwrap_or(0.0),
gamma,
theta: 0.0,
vega: 0.0,
})
}
#[derive(Default)]
struct StrikeAccumulator {
call_bid: Option<Positive>,
call_ask: Option<Positive>,
put_bid: Option<Positive>,
put_ask: Option<Positive>,
implied_volatility: Option<Positive>,
delta_call: Option<Decimal>,
delta_put: Option<Decimal>,
gamma: Option<Decimal>,
}
#[must_use = "the converted option chain must be used"]
pub fn snapshot_to_option_chain(snap: &ChainSnapshot) -> Result<OptionChain, BacktestError> {
let underlying_price = positive_from_price("underlying", snap.underlying_price)?;
let mut strikes: BTreeMap<u64, StrikeAccumulator> = BTreeMap::new();
let mut expiration_ns: Option<i64> = None;
for view in snap.quotes.values() {
let this_ns = view.contract.expiration_ns()?;
match expiration_ns {
Some(existing) if existing != this_ns => {
return Err(BacktestError::Conversion(format!(
"snapshot mixes expiries {existing} and {this_ns}; one option chain carries one expiry"
)));
}
Some(_) => {}
None => expiration_ns = Some(this_ns),
}
let bid = positive_from_price("bid", view.bid)?;
let ask = positive_from_price("ask", view.ask)?;
let iv = Positive::new_decimal(view.implied_volatility).map_err(|e| {
BacktestError::Conversion(format!(
"implied volatility {} is not a valid positive: {e}",
view.implied_volatility
))
})?;
let acc = strikes.entry(view.contract.strike.value()).or_default();
acc.implied_volatility.get_or_insert(iv);
match view.contract.style {
OptionStyle::Call => {
acc.call_bid = Some(bid);
acc.call_ask = Some(ask);
acc.delta_call = Some(view.delta);
acc.gamma.get_or_insert(view.gamma);
}
OptionStyle::Put => {
acc.put_bid = Some(bid);
acc.put_ask = Some(ask);
acc.delta_put = Some(view.delta);
acc.gamma.get_or_insert(view.gamma);
}
}
}
let expiration_date = match expiration_ns {
Some(ns) => chrono::DateTime::from_timestamp_nanos(ns).to_rfc3339(),
None => chrono::DateTime::from_timestamp_nanos(0).to_rfc3339(),
};
let mut chain = OptionChain::new(
snap.underlying.as_str(),
underlying_price,
expiration_date,
None,
None,
);
let expected = strikes.len();
for (strike_cents, acc) in strikes {
let strike = positive_from_price("strike", PriceCents::new(strike_cents))?;
let iv = acc.implied_volatility.unwrap_or(Positive::ZERO);
chain.add_option(
strike,
acc.call_bid,
acc.call_ask,
acc.put_bid,
acc.put_ask,
iv,
acc.delta_call,
acc.delta_put,
acc.gamma,
None,
None,
None,
);
}
if chain.options.len() != expected {
return Err(BacktestError::Conversion(format!(
"option chain built {} of {expected} strikes; an expiration parse failed",
chain.options.len()
)));
}
Ok(chain)
}
#[inline]
pub(crate) fn positive_from_price(
field: &str,
price: PriceCents,
) -> Result<Positive, BacktestError> {
Positive::new_decimal(price.to_decimal_dollars()).map_err(|e| {
BacktestError::Conversion(format!(
"{field} {} is not a valid positive dollar amount: {e}",
price.value()
))
})
}
#[cfg(test)]
mod tests {
use chrono::DateTime;
use optionstratlib::prelude::Positive;
use optionstratlib::{ExpirationDate, OptionStyle};
use super::{
RawQuote, SnapshotMeta, dollars_f64_to_price_cents, raw_quotes_to_snapshot,
snapshot_to_option_chain,
};
use crate::domain::money::{PriceCents, Quantity};
use crate::domain::time::{SimTime, StepIndex};
use crate::domain::{ContractKey, Underlying};
use crate::error::BacktestError;
const TS0: i64 = 1_750_291_200_000_000_000;
fn qty(n: u32) -> Quantity {
match Quantity::new(n) {
Ok(q) => q,
Err(e) => panic!("{n} must be a valid quantity: {e}"),
}
}
fn underlying() -> Underlying {
match Underlying::new("SPX") {
Ok(u) => u,
Err(e) => panic!("SPX must be valid: {e}"),
}
}
fn days(n: f64) -> ExpirationDate {
match Positive::new(n) {
Ok(p) => ExpirationDate::Days(p),
Err(e) => panic!("{n} must be a valid positive: {e}"),
}
}
fn meta(step: u32) -> SnapshotMeta {
SnapshotMeta {
ts: SimTime::new(TS0 + i64::from(step) * 86_400_000_000_000),
step: StepIndex::new(step),
anchor_ts: SimTime::new(TS0),
underlying: underlying(),
underlying_price: PriceCents::new(510_000),
tick_size_cents: PriceCents::new(5),
contract_multiplier: 100,
}
}
fn raw(
expiration: ExpirationDate,
strike: u64,
bid: u64,
ask: u64,
style: OptionStyle,
) -> RawQuote {
RawQuote {
expiration,
strike: PriceCents::new(strike),
style,
bid: PriceCents::new(bid),
ask: PriceCents::new(ask),
bid_size: qty(10),
ask_size: qty(10),
implied_volatility: 0.2,
delta: 0.5,
gamma: 0.01,
theta: -0.05,
vega: 0.1,
}
}
fn abs_expiry() -> ExpirationDate {
ExpirationDate::DateTime(DateTime::from_timestamp_nanos(
TS0 + 30 * 86_400_000_000_000,
))
}
#[test]
fn test_convert_chain_rejects_negative_strike() {
assert!(matches!(
dollars_f64_to_price_cents("strike", -100.0),
Err(BacktestError::Conversion(_))
));
}
#[test]
fn test_dollars_f64_to_price_cents_rejects_nan_and_inf_price_conversion_error() {
assert!(matches!(
dollars_f64_to_price_cents("bid", f64::NAN),
Err(BacktestError::Conversion(_))
));
assert!(matches!(
dollars_f64_to_price_cents("ask", f64::INFINITY),
Err(BacktestError::Conversion(_))
));
assert!(matches!(
dollars_f64_to_price_cents("bid", 1.23),
Ok(p) if p.value() == 123
));
}
#[test]
fn test_convert_chain_rejects_zero_strike_conversion_error() {
let quotes = [raw(abs_expiry(), 0, 100, 110, OptionStyle::Call)];
assert!(matches!(
raw_quotes_to_snapshot(&meta(0), "es),
Err(BacktestError::Conversion(_))
));
}
#[test]
fn test_convert_rejects_crossed_quote() {
let quotes = [raw(abs_expiry(), 510_000, 110, 105, OptionStyle::Call)];
assert!(matches!(
raw_quotes_to_snapshot(&meta(0), "es),
Err(BacktestError::CrossedQuote { bid: 110, ask: 105 })
));
}
#[test]
fn test_convert_rejects_non_tick_aligned_price() {
let quotes = [raw(abs_expiry(), 510_000, 100, 107, OptionStyle::Call)];
assert!(matches!(
raw_quotes_to_snapshot(&meta(0), "es),
Err(BacktestError::PriceNotTickAligned {
price: 107,
tick: 5
})
));
let quotes = [raw(abs_expiry(), 510_001, 100, 110, OptionStyle::Call)];
assert!(matches!(
raw_quotes_to_snapshot(&meta(0), "es),
Err(BacktestError::PriceNotTickAligned {
price: 510_001,
tick: 5
})
));
}
#[test]
fn test_mid_is_floor_to_tick() {
let quotes = [raw(abs_expiry(), 510_000, 100, 105, OptionStyle::Call)];
let snap = match raw_quotes_to_snapshot(&meta(0), "es) {
Ok(s) => s,
Err(e) => panic!("conversion must succeed: {e}"),
};
let view = match snap.quotes.values().next() {
Some(v) => v,
None => panic!("one quote must be present"),
};
assert_eq!(view.mid.value(), 100);
assert!(view.bid.value() <= view.mid.value() && view.mid.value() <= view.ask.value());
}
#[test]
fn test_convert_rejects_nan_iv_and_greek_conversion_error() {
let mut q = raw(abs_expiry(), 510_000, 100, 110, OptionStyle::Call);
q.implied_volatility = f64::NAN;
assert!(matches!(
raw_quotes_to_snapshot(&meta(0), &[q]),
Err(BacktestError::Conversion(_))
));
let mut q = raw(abs_expiry(), 510_000, 100, 110, OptionStyle::Call);
q.vega = f64::INFINITY;
assert!(matches!(
raw_quotes_to_snapshot(&meta(0), &[q]),
Err(BacktestError::Conversion(_))
));
}
#[test]
fn test_convert_rejects_zero_tick_and_zero_multiplier_conversion_error() {
let quotes = [raw(abs_expiry(), 510_000, 100, 110, OptionStyle::Call)];
let mut m = meta(0);
m.tick_size_cents = PriceCents::new(0);
assert!(matches!(
raw_quotes_to_snapshot(&m, "es),
Err(BacktestError::Conversion(_))
));
let mut m = meta(0);
m.contract_multiplier = 0;
assert!(matches!(
raw_quotes_to_snapshot(&m, "es),
Err(BacktestError::Conversion(_))
));
}
#[test]
fn test_convert_happy_path_orders_quotes_and_keeps_analytics_decimal() {
let quotes = [
raw(abs_expiry(), 520_000, 90, 100, OptionStyle::Call),
raw(abs_expiry(), 500_000, 200, 210, OptionStyle::Call),
raw(abs_expiry(), 510_000, 140, 150, OptionStyle::Call),
];
let snap = match raw_quotes_to_snapshot(&meta(0), "es) {
Ok(s) => s,
Err(e) => panic!("conversion must succeed: {e}"),
};
let strikes: Vec<u64> = snap.quotes.keys().map(|k| k.strike.value()).collect();
assert_eq!(strikes, vec![500_000, 510_000, 520_000]);
assert_eq!(snap.spec.tick_size_cents.value(), 5);
assert_eq!(snap.spec.contract_multiplier, 100);
let first = match snap.quotes.values().next() {
Some(v) => v,
None => panic!("quotes present"),
};
assert_eq!(first.mid.value(), 205);
assert_eq!(
Some(first.implied_volatility),
rust_decimal::Decimal::from_f64_retain(0.2)
);
}
#[test]
fn test_days_expiration_anchors_on_ts0_not_snapshot_ts() {
let q0 = raw(days(30.0), 510_000, 100, 110, OptionStyle::Call);
let q5 = raw(days(30.0), 510_000, 100, 110, OptionStyle::Call);
let s0 = match raw_quotes_to_snapshot(&meta(0), &[q0]) {
Ok(s) => s,
Err(e) => panic!("step 0 conversion must succeed: {e}"),
};
let s5 = match raw_quotes_to_snapshot(&meta(5), &[q5]) {
Ok(s) => s,
Err(e) => panic!("step 5 conversion must succeed: {e}"),
};
let k0 = match s0.quotes.keys().next() {
Some(k) => k,
None => panic!("step 0 quote present"),
};
let k5 = match s5.quotes.keys().next() {
Some(k) => k,
None => panic!("step 5 quote present"),
};
assert_eq!(k0, k5);
let expected = TS0 + 30 * 86_400_000_000_000;
assert!(matches!(k0.expiration_ns(), Ok(ns) if ns == expected));
}
#[test]
fn test_days_expiration_fractional_rounds_half_to_even() {
let q = raw(days(0.5), 510_000, 100, 110, OptionStyle::Call);
let snap = match raw_quotes_to_snapshot(&meta(0), &[q]) {
Ok(s) => s,
Err(e) => panic!("conversion must succeed: {e}"),
};
let key = match snap.quotes.keys().next() {
Some(k) => k,
None => panic!("quote present"),
};
let expected = TS0 + 43_200_000_000_000;
assert!(matches!(key.expiration_ns(), Ok(ns) if ns == expected));
}
#[test]
fn test_snapshot_to_option_chain_happy_path_round_trips_strikes() {
let quotes = [
raw(abs_expiry(), 500_000, 200, 210, OptionStyle::Call),
raw(abs_expiry(), 500_000, 180, 190, OptionStyle::Put),
raw(abs_expiry(), 510_000, 140, 150, OptionStyle::Call),
];
let snap = match raw_quotes_to_snapshot(&meta(0), "es) {
Ok(s) => s,
Err(e) => panic!("conversion must succeed: {e}"),
};
let chain = match snapshot_to_option_chain(&snap) {
Ok(c) => c,
Err(e) => panic!("reverse conversion must succeed: {e}"),
};
assert_eq!(chain.symbol, "SPX");
assert_eq!(chain.options.len(), 2);
assert_eq!(
chain.underlying_price,
Positive::new(5100.0).unwrap_or(Positive::ZERO)
);
}
#[test]
fn test_snapshot_to_option_chain_rejects_mixed_expiries() {
let far = ExpirationDate::DateTime(DateTime::from_timestamp_nanos(
TS0 + 60 * 86_400_000_000_000,
));
let mut snap = match raw_quotes_to_snapshot(
&meta(0),
&[raw(abs_expiry(), 510_000, 100, 110, OptionStyle::Call)],
) {
Ok(s) => s,
Err(e) => panic!("conversion must succeed: {e}"),
};
let key = ContractKey {
underlying: underlying(),
expiration: far,
strike: PriceCents::new(520_000),
style: OptionStyle::Call,
};
if let Some(view) = snap.quotes.values().next().cloned() {
let mut view = view;
view.contract = key.clone();
snap.quotes.insert(key, view);
}
assert!(matches!(
snapshot_to_option_chain(&snap),
Err(BacktestError::Conversion(_))
));
}
}
#[cfg(all(test, feature = "simulator"))]
mod dto_tests {
use super::chain_response_to_snapshot;
use crate::data::simulator::{
ChainResponse, OptionContractResponse, OptionPriceResponse, SessionInfoResponse,
};
use crate::domain::market::InstrumentSpec;
use crate::domain::money::{PriceCents, Quantity};
use crate::domain::time::{SimTime, StepIndex};
use crate::error::BacktestError;
const TS0: i64 = 1_750_291_200_000_000_000;
fn spec() -> InstrumentSpec {
match InstrumentSpec::new(PriceCents::new(5), 100) {
Ok(s) => s,
Err(e) => panic!("spec must build: {e}"),
}
}
fn size() -> Quantity {
match Quantity::new(10) {
Ok(q) => q,
Err(e) => panic!("size must be valid: {e}"),
}
}
fn response(strike: f64) -> ChainResponse {
ChainResponse {
underlying: "SPX".to_string(),
timestamp: "2025-06-19T00:00:00Z".to_string(),
price: 5100.0,
contracts: vec![OptionContractResponse {
strike,
expiration: "2025-07-19T00:00:00Z".to_string(),
call: OptionPriceResponse {
bid: Some(1.40),
ask: Some(1.50),
mid: Some(1.45),
delta: Some(0.30),
},
put: OptionPriceResponse {
bid: Some(1.20),
ask: Some(1.30),
mid: Some(1.25),
delta: Some(-0.30),
},
implied_volatility: Some(0.19),
gamma: Some(0.001),
}],
session_info: SessionInfoResponse::default(),
}
}
#[test]
fn test_chain_response_to_snapshot_happy_path_prices_are_cents() {
let resp = response(5100.0);
let snap = match chain_response_to_snapshot(
&resp,
StepIndex::new(0),
spec(),
SimTime::new(TS0),
size(),
) {
Ok(s) => s,
Err(e) => panic!("dto conversion must succeed: {e}"),
};
assert_eq!(snap.quotes.len(), 2);
assert_eq!(snap.underlying_price.value(), 510_000);
let call_mid = snap
.quotes
.values()
.find(|v| matches!(v.contract.style, optionstratlib::OptionStyle::Call))
.map(|v| v.mid.value());
assert_eq!(call_mid, Some(145));
}
#[test]
fn test_chain_response_to_snapshot_rejects_negative_strike() {
let resp = response(-5100.0);
assert!(matches!(
chain_response_to_snapshot(&resp, StepIndex::new(0), spec(), SimTime::new(TS0), size(),),
Err(BacktestError::Conversion(_))
));
}
}