use crate::domain::series::SeriesSnapshot;
use crate::infrastructure::{
CURRENT_SNAPSHOT_GENERATION, ExpirationRecord, QuoteRow, SnapshotRecord,
};
pub(crate) const SNAPSHOT_TAPE_GENERATION: u64 = CURRENT_SNAPSHOT_GENERATION;
#[must_use]
pub(crate) fn snapshot_quote_count(snapshot: &SeriesSnapshot) -> usize {
snapshot
.chains
.iter()
.map(|chain| chain.chain.options.len())
.sum()
}
pub(crate) fn snapshot_record(
simulation: uuid::Uuid,
symbol: &str,
snapshot: &SeriesSnapshot,
) -> SnapshotRecord {
let expirations = snapshot
.chains
.iter()
.map(|chain| ExpirationRecord {
expires_at: chain.expires_at,
days_to_expiration: chain.days_to_expiration,
labels: chain.labels.clone(),
quotes: chain
.chain
.iter()
.map(|data| QuoteRow {
strike: data.strike_price,
implied_volatility: data.implied_volatility,
call_bid: data.call_bid,
call_ask: data.call_ask,
call_mid: data.call_middle,
put_bid: data.put_bid,
put_ask: data.put_ask,
put_mid: data.put_middle,
delta_call: data.delta_call,
delta_put: data.delta_put,
gamma: data.gamma,
})
.collect(),
})
.collect();
SnapshotRecord {
simulation,
generation: SNAPSHOT_TAPE_GENERATION,
step: snapshot.step,
simulated_at: snapshot.simulated_at,
symbol: symbol.to_string(),
spot: snapshot.spot,
base_volatility: snapshot.base_volatility,
expirations,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::rest::models::{ApiTimeFrame, ApiWalkType};
use crate::api::rest::requests_v2::CreateSimulationRequest;
use crate::domain::factors::FactorTape;
use crate::domain::series::SeriesBuilder;
use crate::session::{ExpiryRule, ExpiryRuleKind, SimulationParametersV2};
use chrono::{TimeZone, Utc, Weekday};
use uuid::Uuid;
fn parameters() -> SimulationParametersV2 {
let start_at = match Utc.with_ymd_and_hms(2026, 1, 5, 14, 30, 0).single() {
Some(instant) => instant,
None => panic!("the test instant must be valid"),
};
let rule =
|id: &str, kind: ExpiryRuleKind, count: usize| match ExpiryRule::new(id, kind, count) {
Ok(rule) => rule,
Err(error) => panic!("the test rule must be valid: {error}"),
};
let request = CreateSimulationRequest {
symbol: "SPX".to_string(),
steps: 2,
start_at: Some(start_at),
step_interval_seconds: Some(86_400),
timezone: "America/New_York".to_string(),
calendar: Some("weekdays_v1".to_string()),
expiration_time: "17:00".to_string(),
schedules: vec![
rule("zero_dte", ExpiryRuleKind::Daily, 1),
rule(
"weeklies",
ExpiryRuleKind::weekly([Weekday::Mon, Weekday::Wed, Weekday::Fri]),
2,
),
],
initial_price: 5000.0,
volatility: 0.18,
risk_free_rate: 0.04,
dividend_yield: 0.012,
method: ApiWalkType::Brownian {
dt: 0.004,
drift: 0.0,
volatility: 0.18,
},
time_frame: ApiTimeFrame::Day,
chain_size: Some(5),
strike_interval: Some(25.0),
skew_slope: None,
smile_curve: None,
spread: Some(0.02),
seed: Some(42),
};
match SimulationParametersV2::try_from(request) {
Ok(parameters) => parameters,
Err(error) => panic!("the request must convert: {error}"),
}
}
#[test]
fn test_the_record_carries_the_whole_snapshot() {
let parameters = parameters();
let tape = match FactorTape::build(¶meters, ¶meters.method) {
Ok(tape) => tape,
Err(error) => panic!("the tape must build: {error}"),
};
let builder = match SeriesBuilder::new(¶meters, &tape) {
Ok(builder) => builder,
Err(error) => panic!("the builder must accept the parameters: {error}"),
};
let snapshot = match builder.snapshot(1) {
Ok(snapshot) => snapshot,
Err(error) => panic!("the snapshot must build: {error}"),
};
let simulation = Uuid::new_v4();
let record = snapshot_record(simulation, ¶meters.symbol, &snapshot);
assert_eq!(record.simulation, simulation);
assert_eq!(record.generation, SNAPSHOT_TAPE_GENERATION);
assert_eq!(record.step, snapshot.step);
assert_eq!(record.simulated_at, snapshot.simulated_at);
assert_eq!(record.symbol, parameters.symbol);
assert_eq!(record.spot, snapshot.spot);
assert_eq!(record.base_volatility, snapshot.base_volatility);
assert_eq!(record.expirations.len(), snapshot.chains.len());
assert!(
!record.expirations.is_empty(),
"the fixture must price something"
);
for (stored, live) in record.expirations.iter().zip(&snapshot.chains) {
assert_eq!(stored.expires_at, live.expires_at);
assert_eq!(stored.days_to_expiration, live.days_to_expiration);
assert_eq!(stored.labels, live.labels);
let quotes = live.chain.iter().count();
assert_eq!(stored.quotes.len(), quotes, "every strike must be carried");
for (row, data) in stored.quotes.iter().zip(live.chain.iter()) {
assert_eq!(row.strike, data.strike_price);
assert_eq!(row.implied_volatility, data.implied_volatility);
assert_eq!(row.call_bid, data.call_bid);
assert_eq!(row.call_ask, data.call_ask);
assert_eq!(row.call_mid, data.call_middle);
assert_eq!(row.put_bid, data.put_bid);
assert_eq!(row.put_ask, data.put_ask);
assert_eq!(row.put_mid, data.put_middle);
assert_eq!(row.delta_call, data.delta_call);
assert_eq!(row.delta_put, data.delta_put);
assert_eq!(row.gamma, data.gamma);
}
}
match record.validate() {
Ok(()) => {}
Err(error) => panic!("a record built from a real snapshot must be valid: {error}"),
}
}
#[test]
fn test_the_same_step_files_under_a_stable_id() {
let parameters = parameters();
let tape = match FactorTape::build(¶meters, ¶meters.method) {
Ok(tape) => tape,
Err(error) => panic!("the tape must build: {error}"),
};
let builder = match SeriesBuilder::new(¶meters, &tape) {
Ok(builder) => builder,
Err(error) => panic!("the builder must accept the parameters: {error}"),
};
let simulation = Uuid::new_v4();
let first = match builder.snapshot(0) {
Ok(snapshot) => snapshot_record(simulation, ¶meters.symbol, &snapshot),
Err(error) => panic!("the snapshot must build: {error}"),
};
let second = match builder.snapshot(0) {
Ok(snapshot) => snapshot_record(simulation, ¶meters.symbol, &snapshot),
Err(error) => panic!("the snapshot must build: {error}"),
};
let next_step = match builder.snapshot(1) {
Ok(snapshot) => snapshot_record(simulation, ¶meters.symbol, &snapshot),
Err(error) => panic!("the snapshot must build: {error}"),
};
assert_eq!(first.snapshot_id(), second.snapshot_id());
assert_ne!(
first.snapshot_id(),
next_step.snapshot_id(),
"two steps of one simulation are two rows"
);
}
}