use std::collections::BTreeMap;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::domain::contract::{ContractKey, Underlying};
use crate::domain::money::{PriceCents, Quantity};
use crate::domain::time::{SimTime, StepIndex};
use crate::error::BacktestError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "InstrumentSpecWire")]
pub struct InstrumentSpec {
pub tick_size_cents: PriceCents,
pub contract_multiplier: u32,
}
#[derive(Deserialize)]
struct InstrumentSpecWire {
tick_size_cents: PriceCents,
contract_multiplier: u32,
}
impl TryFrom<InstrumentSpecWire> for InstrumentSpec {
type Error = BacktestError;
fn try_from(wire: InstrumentSpecWire) -> Result<Self, Self::Error> {
Self::new(wire.tick_size_cents, wire.contract_multiplier)
}
}
impl InstrumentSpec {
#[must_use = "the validated instrument spec must be used"]
pub fn new(
tick_size_cents: PriceCents,
contract_multiplier: u32,
) -> Result<Self, BacktestError> {
if tick_size_cents.value() == 0 {
return Err(BacktestError::Conversion(
"instrument tick_size_cents must be > 0, got 0".to_string(),
));
}
if contract_multiplier == 0 {
return Err(BacktestError::Conversion(
"instrument contract_multiplier must be > 0, got 0".to_string(),
));
}
Ok(Self {
tick_size_cents,
contract_multiplier,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuoteView {
pub contract: ContractKey,
pub bid: PriceCents,
pub ask: PriceCents,
pub mid: PriceCents,
pub bid_size: Quantity,
pub ask_size: Quantity,
pub implied_volatility: Decimal,
pub delta: Decimal,
pub gamma: Decimal,
pub theta: Decimal,
pub vega: Decimal,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChainSnapshot {
pub ts: SimTime,
pub step: StepIndex,
pub underlying: Underlying,
pub underlying_price: PriceCents,
pub spec: InstrumentSpec,
pub quotes: BTreeMap<ContractKey, QuoteView>,
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use chrono::DateTime;
use optionstratlib::{ExpirationDate, OptionStyle};
use rust_decimal_macros::dec;
use super::{InstrumentSpec, QuoteView};
use crate::domain::contract::{ContractKey, Underlying};
use crate::domain::money::{PriceCents, Quantity};
use crate::error::BacktestError;
#[test]
fn test_instrument_spec_rejects_zero_tick_size_conversion_error() {
let spec = InstrumentSpec::new(PriceCents::new(0), 100);
assert!(matches!(spec, Err(BacktestError::Conversion(_))));
}
#[test]
fn test_instrument_spec_rejects_zero_multiplier_conversion_error() {
let spec = InstrumentSpec::new(PriceCents::new(5), 0);
assert!(matches!(spec, Err(BacktestError::Conversion(_))));
}
#[test]
fn test_instrument_spec_accepts_valid_values() {
let spec = InstrumentSpec::new(PriceCents::new(5), 100);
assert!(
matches!(spec, Ok(s) if s.tick_size_cents.value() == 5 && s.contract_multiplier == 100)
);
}
#[test]
fn test_instrument_spec_deserialize_rejects_zero_fields() {
let zero_tick: Result<InstrumentSpec, _> =
serde_json::from_str(r#"{"tick_size_cents":0,"contract_multiplier":100}"#);
assert!(zero_tick.is_err(), "zero tick_size must be rejected");
let zero_mult: Result<InstrumentSpec, _> =
serde_json::from_str(r#"{"tick_size_cents":5,"contract_multiplier":0}"#);
assert!(
zero_mult.is_err(),
"zero contract_multiplier must be rejected"
);
}
#[test]
fn test_instrument_spec_serde_round_trips_valid() {
let Ok(spec) = InstrumentSpec::new(PriceCents::new(5), 100) else {
panic!("valid spec must build");
};
let json = serde_json::to_string(&spec).unwrap_or_default();
let back: Result<InstrumentSpec, _> = serde_json::from_str(&json);
assert!(matches!(back, Ok(s) if s == spec));
}
fn key(strike_cents: u64) -> ContractKey {
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is a valid underlying");
};
ContractKey {
underlying,
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(
1_750_291_200_000_000_000,
)),
strike: PriceCents::new(strike_cents),
style: OptionStyle::Call,
}
}
fn quote(strike_cents: u64) -> QuoteView {
let Ok(qty) = Quantity::new(10) else {
panic!("10 is a valid quantity");
};
QuoteView {
contract: key(strike_cents),
bid: PriceCents::new(100),
ask: PriceCents::new(110),
mid: PriceCents::new(105),
bid_size: qty,
ask_size: qty,
implied_volatility: dec!(0.2),
delta: dec!(0.5),
gamma: dec!(0.01),
theta: dec!(-0.05),
vega: dec!(0.1),
}
}
#[test]
fn test_chain_snapshot_quotes_iterate_in_key_order_regardless_of_insertion() {
let mut quotes = BTreeMap::new();
for strike in [520_000u64, 500_000, 510_000] {
quotes.insert(key(strike), quote(strike));
}
let strikes: Vec<u64> = quotes.keys().map(|k| k.strike.value()).collect();
assert_eq!(strikes, vec![500_000, 510_000, 520_000]);
}
}