use crate::domain::Walker;
use crate::domain::simulator::{
DEFAULT_CHAIN_SIZE, DEFAULT_SKEW_SLOPE, DEFAULT_SMILE_CURVE, DEFAULT_SPREAD,
};
use crate::session::{SimulationMethod, SimulationParametersV2};
use crate::utils::ChainError;
use chrono::{DateTime, Utc};
use optionstratlib::ExpirationDate;
use optionstratlib::chains::{
OptionChainBuildParams, chain::OptionChain, utils::OptionDataPriceParams,
};
use optionstratlib::simulation::steps::{Step, Xstep, Ystep};
use optionstratlib::simulation::{WalkParams, WalkTypeAble};
use optionstratlib::utils::TimeFrame;
use optionstratlib::volatility::{adjust_volatility, constant_volatility};
use positive::Positive;
use rust_decimal::{Decimal, MathematicalOps};
use tracing::{debug, instrument};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FactorRow {
pub(crate) step: usize,
pub(crate) simulated_at: DateTime<Utc>,
pub(crate) spot: Positive,
pub(crate) base_volatility: Positive,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FactorTape {
rows: Vec<FactorRow>,
}
impl FactorTape {
#[instrument(skip(parameters, method), level = "debug")]
pub(crate) fn build(
parameters: &SimulationParametersV2,
method: &SimulationMethod,
) -> Result<Self, ChainError> {
parameters.validate()?;
ensure_method_matches(parameters, method)?;
ensure_historical_series_covers_the_horizon(parameters, method)?;
let base_volatility = resolve_base_volatility(parameters, method)?;
reject_unpriceable_volatility(base_volatility, None, volatility_source(method))?;
let walker = Walker::new_with_seed(parameters.seed);
let initial_chain = build_initial_chain(parameters, base_volatility)?;
let walk_params = WalkParams {
size: parameters.steps,
init_step: Step {
x: Xstep::new(
Positive::ONE,
parameters.time_frame,
ExpirationDate::Days(Positive::ONE),
),
y: Ystep::new(0, initial_chain),
},
walk_type: method.clone(),
walker: Box::new(walker.clone()),
};
let path = walker.generate_with_vol(&walk_params).map_err(|e| {
ChainError::Internal(format!("Failed to generate the factor tape: {e}"))
})?;
if path.prices.len() < parameters.steps {
return Err(ChainError::Internal(format!(
"the walk produced {} points but {} steps were requested",
path.prices.len(),
parameters.steps
)));
}
let step_volatilities = match path.vols {
Some(ref vols) => Some(vols.clone()),
None => match method {
SimulationMethod::Historical { timeframe, .. } => {
expanding_window_volatilities(&path.prices, *timeframe)?
}
_ => None,
},
};
let mut rows = Vec::with_capacity(parameters.steps);
for step in 0..parameters.steps {
let spot = *path.prices.get(step).ok_or_else(|| {
ChainError::Internal(format!("the walk has no price for step {step}"))
})?;
let row_volatility = match step_volatilities {
Some(ref vols) => *vols.get(step).ok_or_else(|| {
ChainError::Internal(format!("the walk has no volatility for step {step}"))
})?,
None => base_volatility,
};
reject_unpriceable_volatility(row_volatility, Some(step), volatility_source(method))?;
rows.push(FactorRow {
step,
simulated_at: parameters.simulated_at(step)?,
spot,
base_volatility: row_volatility,
});
}
debug!(
steps = rows.len(),
seed = parameters.seed,
"Built the factor tape"
);
Ok(Self { rows })
}
#[must_use]
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "the whole-tape accessor the tests compare against; the service reads \
one row at a time through `row`"
)
)]
pub(crate) fn rows(&self) -> &[FactorRow] {
&self.rows
}
#[must_use]
pub(crate) fn len(&self) -> usize {
self.rows.len()
}
#[must_use]
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "clippy's len_without_is_empty requires this alongside `len`; a built \
tape is never empty, since `steps >= 1` is validated at creation"
)
)]
pub(crate) fn is_empty(&self) -> bool {
self.rows.is_empty()
}
#[must_use]
pub(crate) fn row(&self, step: usize) -> Option<&FactorRow> {
self.rows.get(step)
}
}
fn ensure_method_matches(
parameters: &SimulationParametersV2,
method: &SimulationMethod,
) -> Result<(), ChainError> {
let agrees = match (¶meters.method, method) {
(SimulationMethod::Historical { .. }, SimulationMethod::Historical { .. }) => true,
(stored, resolved) => stored == resolved,
};
if agrees {
Ok(())
} else {
Err(ChainError::Internal(format!(
"the resolved walk method does not match the simulation's parameters: \
parameters say {:?}, the caller passed {method:?}",
parameters.method
)))
}
}
fn ensure_historical_series_covers_the_horizon(
parameters: &SimulationParametersV2,
method: &SimulationMethod,
) -> Result<(), ChainError> {
let SimulationMethod::Historical { prices, .. } = method else {
return Ok(());
};
if prices.len() < parameters.steps {
return Err(ChainError::Validation {
field: "method.prices".to_string(),
reason: format!(
"must carry at least one price per step: {} supplied for {} steps",
prices.len(),
parameters.steps
),
});
}
let window = prices
.get(..parameters.steps)
.ok_or_else(|| ChainError::Internal("the horizon guard above did not hold".to_string()))?;
if let Some(index) = window.iter().position(|price| price.is_zero()) {
return Err(ChainError::Validation {
field: "method.prices".to_string(),
reason: format!(
"must be strictly positive: the price at index {index} is zero, and a log \
return divides by the previous price and takes the log of the ratio"
),
});
}
Ok(())
}
fn resolve_base_volatility(
parameters: &SimulationParametersV2,
method: &SimulationMethod,
) -> Result<Positive, ChainError> {
match method.volatility() {
Some(model_volatility) => {
if model_volatility != parameters.volatility {
return Err(ChainError::Validation {
field: "volatility".to_string(),
reason: format!(
"must match the walk model's volatility ({model_volatility}), got {}; \
a simulation has exactly one base volatility",
parameters.volatility
),
});
}
Ok(model_volatility)
}
None => match method {
SimulationMethod::Historical {
timeframe, prices, ..
} => {
let window =
prices
.get(..parameters.steps)
.ok_or_else(|| ChainError::Validation {
field: "method.prices".to_string(),
reason: format!(
"must carry at least one price per step: {} supplied for {} steps",
prices.len(),
parameters.steps
),
})?;
historical_constant_volatility(window, *timeframe)
}
_ => Ok(parameters.volatility),
},
}
}
fn historical_constant_volatility(
prices: &[Positive],
timeframe: TimeFrame,
) -> Result<Positive, ChainError> {
let returns = log_returns(prices)?;
let volatility = constant_volatility(&returns).map_err(|e| ChainError::Validation {
field: "volatility".to_string(),
reason: format!("the historical series has no usable volatility: {e}"),
})?;
annualise(volatility, timeframe)
}
fn expanding_window_volatilities(
prices: &[Positive],
timeframe: TimeFrame,
) -> Result<Option<Vec<Positive>>, ChainError> {
if prices.len() < 3 {
return Ok(None);
}
let returns = log_returns(prices)?;
let mut volatilities = Vec::with_capacity(prices.len());
let mut first_computable: Option<Positive> = None;
for point in 0..prices.len() {
if point < 2 {
continue;
}
let window = returns.get(..point).ok_or_else(|| ChainError::Validation {
field: "method.prices".to_string(),
reason: format!(
"the historical series yielded {} returns for {} prices, too few to \
estimate the volatility at point {point}",
returns.len(),
prices.len()
),
})?;
let volatility = constant_volatility(window).map_err(|e| ChainError::Validation {
field: "volatility".to_string(),
reason: format!("the historical window ending at point {point} has no volatility: {e}"),
})?;
let annualised = annualise(volatility, timeframe)?;
if first_computable.is_none() {
first_computable = Some(annualised);
}
volatilities.push(annualised);
}
let Some(fill) = first_computable else {
return Ok(None);
};
let mut aligned = vec![fill; 2];
aligned.append(&mut volatilities);
Ok(Some(aligned))
}
fn log_returns(prices: &[Positive]) -> Result<Vec<Decimal>, ChainError> {
let unusable = |reason: String| ChainError::Validation {
field: "method.prices".to_string(),
reason,
};
let mut returns = Vec::with_capacity(prices.len().saturating_sub(1));
for (index, pair) in prices.windows(2).enumerate() {
let [previous, current] = pair else {
continue;
};
let previous = previous.to_dec();
let current = current.to_dec();
if previous.is_zero() {
return Err(unusable(format!(
"the price at index {index} is zero, so the series has no return at {}",
index + 1
)));
}
let ratio = current.checked_div(previous).ok_or_else(|| {
unusable(format!(
"the price ratio at index {} is not representable ({current} over {previous})",
index + 1
))
})?;
let log = ratio.checked_ln().ok_or_else(|| {
unusable(format!(
"the log return at index {} is not representable (ratio {ratio})",
index + 1
))
})?;
returns.push(log);
}
Ok(returns)
}
fn annualise(volatility: Positive, timeframe: TimeFrame) -> Result<Positive, ChainError> {
adjust_volatility(volatility, timeframe, TimeFrame::Year).map_err(|e| ChainError::Validation {
field: "volatility".to_string(),
reason: format!("the historical volatility cannot be annualised from {timeframe}: {e}"),
})
}
fn volatility_source(method: &SimulationMethod) -> VolatilitySource {
match method {
SimulationMethod::Historical { .. } => VolatilitySource::Series,
_ => VolatilitySource::Model,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VolatilitySource {
Model,
Series,
}
impl VolatilitySource {
fn field(self) -> &'static str {
match self {
Self::Model => "volatility",
Self::Series => "method.prices",
}
}
fn remedy_too_high(self) -> &'static str {
match self {
Self::Model => "lower the model's volatility or shorten the horizon",
Self::Series => Self::SERIES_REMEDY,
}
}
fn remedy_zero(self) -> &'static str {
match self {
Self::Model => {
"raise the model's volatility, or change the parameters that let its variance \
collapse to zero"
}
Self::Series => Self::SERIES_REMEDY,
}
}
const SERIES_REMEDY: &'static str = "the volatility is estimated from the series, so it is the series that has to change — \
the request's volatility prices nothing for a historical walk";
}
fn reject_unpriceable_volatility(
volatility: Positive,
step: Option<usize>,
source: VolatilitySource,
) -> Result<(), ChainError> {
if volatility.is_zero() {
return Err(ChainError::Validation {
field: source.field().to_string(),
reason: format!(
"the volatility is zero{}, and an option chain priced at zero volatility is a \
chain of zero-value options; {}",
at_step(step),
source.remedy_zero()
),
});
}
if volatility <= Positive::ONE {
return Ok(());
}
Err(ChainError::Validation {
field: source.field().to_string(),
reason: format!(
"the volatility reaches {volatility}{}, above the 1.0 maximum an option chain can \
be priced at; {}",
at_step(step),
source.remedy_too_high()
),
})
}
fn at_step(step: Option<usize>) -> String {
match step {
Some(step) => format!(" at step {step}"),
None => String::new(),
}
}
pub(crate) fn build_chain(
parameters: &SimulationParametersV2,
spot: Positive,
volatility: Positive,
expiration: ExpirationDate,
) -> Result<OptionChain, ChainError> {
let chain_size = parameters.chain_size.unwrap_or(DEFAULT_CHAIN_SIZE);
let skew_slope = parameters.skew_slope.unwrap_or(DEFAULT_SKEW_SLOPE);
let smile_curve = parameters.smile_curve.unwrap_or(DEFAULT_SMILE_CURVE);
let spread = match parameters.spread {
Some(spread) => spread,
None => Positive::new_decimal(DEFAULT_SPREAD).map_err(|e| {
ChainError::Internal(format!("the default spread is not a valid Positive: {e}"))
})?,
};
let price_params = OptionDataPriceParams::new(
Some(Box::new(spot)),
Some(expiration),
Some(parameters.risk_free_rate),
Some(parameters.dividend_yield),
Some(parameters.symbol.clone()),
);
let build_params = OptionChainBuildParams::new(
parameters.symbol.clone(),
Some(Positive::ONE),
chain_size,
parameters.strike_interval,
skew_slope,
smile_curve,
spread,
2,
price_params,
volatility,
);
OptionChain::build_chain(&build_params)
.map_err(|e| ChainError::Internal(format!("Failed to build the option chain: {e}")))
}
fn build_initial_chain(
parameters: &SimulationParametersV2,
base_volatility: Positive,
) -> Result<OptionChain, ChainError> {
build_chain(
parameters,
parameters.initial_price,
base_volatility,
ExpirationDate::Days(Positive::ONE),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::rest::models::{ApiTimeFrame, ApiWalkType};
use crate::api::rest::requests_v2::CreateSimulationRequest;
use crate::session::{ExpiryRule, ExpiryRuleKind};
use chrono::{TimeZone, Weekday};
use optionstratlib::error::SimulationError;
use optionstratlib::simulation::walk_steps_par;
use std::sync::Mutex;
fn request(steps: usize, method: ApiWalkType, volatility: f64) -> CreateSimulationRequest {
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"),
};
CreateSimulationRequest {
symbol: "SPX".to_string(),
steps,
start_at: Some(start_at),
step_interval_seconds: Some(86_400),
timezone: "America/New_York".to_string(),
calendar: None,
expiration_time: "17:00".to_string(),
schedules: vec![rule("zero_dte", ExpiryRuleKind::Daily, 1)],
initial_price: 5000.0,
volatility,
risk_free_rate: 0.04,
dividend_yield: 0.0,
method,
time_frame: ApiTimeFrame::Day,
chain_size: Some(3),
strike_interval: Some(25.0),
skew_slope: None,
smile_curve: None,
spread: Some(0.02),
seed: Some(42),
}
}
fn rule(id: &str, kind: ExpiryRuleKind, count: usize) -> ExpiryRule {
match ExpiryRule::new(id, kind, count) {
Ok(rule) => rule,
Err(error) => panic!("the test rule must be valid: {error}"),
}
}
fn brownian(volatility: f64) -> ApiWalkType {
ApiWalkType::Brownian {
dt: 1.0 / 252.0,
drift: 0.0,
volatility,
}
}
fn garch(volatility: f64) -> ApiWalkType {
ApiWalkType::Garch {
dt: 1.0 / 252.0,
drift: 0.0,
volatility,
alpha: 0.1,
beta: 0.85,
}
}
fn heston(volatility: f64) -> ApiWalkType {
ApiWalkType::Heston {
dt: 1.0 / 252.0,
drift: 0.0,
volatility,
kappa: 2.0,
theta: 0.04,
xi: 0.3,
rho: -0.7,
}
}
fn parameters(request: CreateSimulationRequest) -> SimulationParametersV2 {
match SimulationParametersV2::try_from(request) {
Ok(parameters) => parameters,
Err(error) => panic!("the request must convert: {error}"),
}
}
fn tape(parameters: &SimulationParametersV2) -> FactorTape {
match FactorTape::build(parameters, ¶meters.method) {
Ok(tape) => tape,
Err(error) => panic!("the tape must build: {error}"),
}
}
#[test]
fn test_tape_has_exactly_one_row_per_step() {
let parameters = parameters(request(20, brownian(0.18), 0.18));
let tape = tape(¶meters);
assert_eq!(tape.len(), 20);
assert!(!tape.is_empty());
for (index, row) in tape.rows().iter().enumerate() {
assert_eq!(row.step, index);
}
assert!(tape.row(20).is_none(), "the tape must end at its last step");
}
#[test]
fn test_first_row_is_the_starting_state() {
let parameters = parameters(request(5, brownian(0.18), 0.18));
let tape = tape(¶meters);
let first = match tape.row(0) {
Some(row) => row,
None => panic!("the tape must have a first row"),
};
assert_eq!(first.simulated_at, parameters.effective_start);
assert_eq!(first.spot, parameters.initial_price);
}
#[test]
fn test_row_instants_follow_the_simulated_clock() {
let parameters = parameters(request(10, brownian(0.18), 0.18));
let tape = tape(¶meters);
for row in tape.rows() {
match parameters.simulated_at(row.step) {
Ok(expected) => assert_eq!(row.simulated_at, expected),
Err(error) => panic!("the clock must resolve: {error}"),
}
}
}
#[test]
fn test_a_multi_year_horizon_produces_every_requested_row() {
let parameters = parameters(request(800, brownian(0.18), 0.18));
let tape = tape(¶meters);
assert_eq!(tape.len(), 800);
let last = match tape.rows().last() {
Some(row) => row,
None => panic!("the tape must have a last row"),
};
let span = last.simulated_at - parameters.effective_start;
assert!(
span > chrono::Duration::days(365 * 2),
"the horizon must span more than two years, got {span}"
);
}
#[test]
fn test_same_parameters_produce_identical_tapes() {
let parameters = parameters(request(50, brownian(0.18), 0.18));
assert_eq!(tape(¶meters), tape(¶meters));
}
#[test]
fn test_same_seed_agrees_on_full_rows() {
let first = tape(¶meters(request(30, brownian(0.18), 0.18)));
let second = tape(¶meters(request(30, brownian(0.18), 0.18)));
assert_eq!(first.rows(), second.rows());
}
#[test]
fn test_a_different_seed_produces_a_different_tape() {
let mut other = request(30, brownian(0.18), 0.18);
other.seed = Some(43);
let baseline = tape(¶meters(request(30, brownian(0.18), 0.18)));
let different = tape(¶meters(other));
assert_ne!(baseline.rows(), different.rows());
assert_eq!(
baseline.row(0).map(|row| row.spot),
different.row(0).map(|row| row.spot)
);
assert_ne!(
baseline.rows().last().map(|row| row.spot),
different.rows().last().map(|row| row.spot)
);
}
#[test]
fn test_the_schedule_cannot_perturb_the_tape() {
let baseline = tape(¶meters(request(40, brownian(0.18), 0.18)));
let mut richer = request(40, brownian(0.18), 0.18);
richer.schedules = vec![
rule(
"monthlies",
ExpiryRuleKind::Monthly {
weekday: Weekday::Fri,
},
12,
),
rule(
"weeklies",
ExpiryRuleKind::weekly([Weekday::Mon, Weekday::Wed, Weekday::Fri]),
3,
),
rule("zero_dte", ExpiryRuleKind::Daily, 1),
];
assert_eq!(baseline.rows(), tape(¶meters(richer)).rows());
}
#[test]
fn test_rule_order_cannot_perturb_the_tape() {
let mut forwards = request(20, brownian(0.18), 0.18);
forwards.schedules = vec![
rule("zero_dte", ExpiryRuleKind::Daily, 1),
rule("weeklies", ExpiryRuleKind::weekly([Weekday::Fri]), 2),
];
let mut backwards = request(20, brownian(0.18), 0.18);
backwards.schedules = vec![
rule("weeklies", ExpiryRuleKind::weekly([Weekday::Fri]), 2),
rule("zero_dte", ExpiryRuleKind::Daily, 1),
];
assert_eq!(
tape(¶meters(forwards)).rows(),
tape(¶meters(backwards)).rows()
);
}
#[test]
fn test_a_different_start_shifts_instants_without_changing_the_path() {
let baseline = tape(¶meters(request(15, brownian(0.18), 0.18)));
let mut later = request(15, brownian(0.18), 0.18);
later.start_at = match Utc.with_ymd_and_hms(2030, 6, 3, 9, 0, 0).single() {
Some(instant) => Some(instant),
None => panic!("the test instant must be valid"),
};
let shifted = tape(¶meters(later));
let baseline_spots: Vec<Positive> = baseline.rows().iter().map(|row| row.spot).collect();
let shifted_spots: Vec<Positive> = shifted.rows().iter().map(|row| row.spot).collect();
assert_eq!(baseline_spots, shifted_spots);
assert_ne!(
baseline.row(0).map(|row| row.simulated_at),
shifted.row(0).map(|row| row.simulated_at)
);
}
#[test]
fn test_constant_volatility_stays_constant() {
let parameters = parameters(request(25, brownian(0.18), 0.18));
let tape = tape(¶meters);
for row in tape.rows() {
assert_eq!(
row.base_volatility, parameters.volatility,
"step {} drifted from the model's volatility",
row.step
);
}
}
#[test]
fn test_garch_volatility_varies_and_stays_aligned() {
let parameters = parameters(request(60, garch(0.18), 0.18));
let tape = tape(¶meters);
assert_eq!(tape.len(), 60);
let series: Vec<Positive> = tape.rows().iter().map(|row| row.base_volatility).collect();
let distinct: std::collections::BTreeSet<String> =
series.iter().map(ToString::to_string).collect();
assert!(
distinct.len() > 1,
"a GARCH tape must not have a constant volatility"
);
assert_eq!(
series.first(),
Some(¶meters.volatility),
"row 0 must carry the model's starting volatility"
);
let reversed: Vec<Positive> = series.iter().rev().copied().collect();
assert_ne!(
series, reversed,
"a symmetric series would hide a reversed column"
);
}
#[test]
fn test_heston_volatility_varies_and_stays_aligned() {
let parameters = parameters(request(60, heston(0.18), 0.18));
let tape = tape(¶meters);
assert_eq!(tape.len(), 60);
let series: Vec<Positive> = tape.rows().iter().map(|row| row.base_volatility).collect();
let distinct: std::collections::BTreeSet<String> =
series.iter().map(ToString::to_string).collect();
assert!(
distinct.len() > 1,
"a Heston tape must not have a constant volatility"
);
assert_eq!(
series.first(),
Some(¶meters.volatility),
"row 0 must carry the model's starting volatility"
);
let reversed: Vec<Positive> = series.iter().rev().copied().collect();
assert_ne!(
series, reversed,
"a symmetric series would hide a reversed column"
);
}
#[test]
fn test_stochastic_volatility_is_reproducible() {
let first = tape(¶meters(request(40, garch(0.18), 0.18)));
let second = tape(¶meters(request(40, garch(0.18), 0.18)));
assert_eq!(first.rows(), second.rows());
}
#[test]
fn test_disagreeing_volatilities_are_rejected() {
let parameters = parameters(request(10, brownian(0.35), 0.35));
let mismatched = SimulationParametersV2 {
volatility: match Positive::new(0.18) {
Ok(value) => value,
Err(error) => panic!("0.18 must be a valid Positive: {error}"),
},
..parameters
};
match FactorTape::build(&mismatched, &mismatched.method) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "volatility");
assert!(reason.contains("exactly one base volatility"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_disagreeing_volatilities_are_rejected_at_the_boundary() {
let mut contradictory = request(10, brownian(0.35), 0.35);
contradictory.volatility = 0.18;
match SimulationParametersV2::try_from(contradictory) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "volatility");
assert!(reason.contains("exactly one base volatility"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_method_the_parameters_do_not_name_is_refused() {
let parameters = parameters(request(10, brownian(0.18), 0.18));
let mismatched = match SimulationParametersV2::try_from(request(10, garch(0.18), 0.18)) {
Ok(other) => other.method,
Err(error) => panic!("the request must convert: {error}"),
};
match FactorTape::build(¶meters, &mismatched) {
Err(ChainError::Internal(reason)) => {
assert!(reason.contains("does not match"), "{reason}");
}
other => panic!("expected an internal error, got {other:?}"),
}
}
#[test]
fn test_a_resolved_historical_method_is_accepted() {
let prices: Vec<f64> = (0..30).map(|i| 5000.0 + f64::from(i)).collect();
let mut historical = request(15, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices: Vec::new(),
symbol: None,
};
let parameters = parameters(historical);
let resolved = SimulationMethod::Historical {
timeframe: optionstratlib::utils::TimeFrame::Day,
prices: prices
.iter()
.map(|price| match Positive::new(*price) {
Ok(value) => value,
Err(error) => panic!("the test price must be valid: {error}"),
})
.collect(),
symbol: Some("SPX".to_string()),
};
match FactorTape::build(¶meters, &resolved) {
Ok(tape) => assert_eq!(tape.len(), 15),
Err(error) => panic!("a resolved historical method must build: {error}"),
}
}
#[test]
fn test_a_short_historical_series_is_a_client_error() {
let mut historical = request(50, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices: vec![5000.0, 5001.0, 5002.0],
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
match FactorTape::build(¶meters, ¶meters.method) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "method.prices");
assert!(reason.contains("3 supplied for 50 steps"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_horizon_too_short_to_estimate_is_refused() {
let mut historical = request(2, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices: volatile_prices(60),
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
match FactorTape::build(¶meters, ¶meters.method) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "method.prices");
assert!(reason.contains("zero"), "{reason}");
assert!(reason.contains("prices nothing"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_series_without_dispersion_is_refused() {
let mut historical = request(4, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices: vec![5000.0; 8],
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
match FactorTape::build(¶meters, ¶meters.method) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "method.prices");
assert!(reason.contains("zero-value options"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_series_above_the_priceable_volatility_is_refused() {
let prices: Vec<f64> = (0..20)
.map(|index| if index % 2 == 0 { 5000.0 } else { 5500.0 })
.collect();
let mut historical = request(10, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices,
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
match FactorTape::build(¶meters, ¶meters.method) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "method.prices");
assert!(reason.contains("above the 1.0 maximum"), "{reason}");
assert!(reason.contains("the series that has to change"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_a_zero_price_in_the_resolved_series_is_refused() {
let mut historical = request(4, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices: volatile_prices(20),
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
let mut resolved = volatile_series(20);
match resolved.get_mut(3) {
Some(price) => *price = Positive::ZERO,
None => panic!("the fixture must have a fourth price"),
}
let resolved = SimulationMethod::Historical {
timeframe: TimeFrame::Day,
prices: resolved,
symbol: Some("SPX".to_string()),
};
match FactorTape::build(¶meters, &resolved) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "method.prices");
assert!(reason.contains("index 3 is zero"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_historical_ignores_the_requested_volatility() {
let prices: Vec<f64> = (0..40).map(|i| 5000.0 + f64::from(i)).collect();
let mut historical = request(20, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices,
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
let tape = tape(¶meters);
assert_eq!(tape.len(), 20);
for row in tape.rows() {
assert_ne!(row.base_volatility, parameters.volatility);
assert!(
row.base_volatility < parameters.volatility,
"a flat series cannot be as volatile as {}, got {} at step {}",
parameters.volatility,
row.base_volatility,
row.step
);
}
}
#[test]
fn test_historical_volatility_has_no_look_ahead() {
let series = volatile_series(40);
let full = match expanding_window_volatilities(&series, TimeFrame::Day) {
Ok(Some(volatilities)) => volatilities,
other => panic!("the full series must yield estimates, got {other:?}"),
};
for cut in 3..=series.len() {
let prefix = match series.get(..cut) {
Some(prefix) => prefix,
None => panic!("the cut must be within the series"),
};
let partial = match expanding_window_volatilities(prefix, TimeFrame::Day) {
Ok(Some(volatilities)) => volatilities,
other => panic!("the prefix of {cut} must yield estimates, got {other:?}"),
};
assert_eq!(partial.len(), cut);
for (point, volatility) in partial.iter().enumerate() {
assert_eq!(
Some(volatility),
full.get(point),
"point {point} moved when the series grew to {cut} observations"
);
}
}
}
#[test]
fn test_expanding_window_needs_three_prices() {
let series = volatile_series(4);
for length in 0..3 {
let prefix = match series.get(..length) {
Some(prefix) => prefix,
None => panic!("the prefix must be within the series"),
};
assert!(
matches!(
expanding_window_volatilities(prefix, TimeFrame::Day),
Ok(None)
),
"{length} prices cannot yield an expanding window"
);
}
assert!(matches!(
expanding_window_volatilities(&series, TimeFrame::Day),
Ok(Some(_))
));
}
#[test]
fn test_expanding_window_backfills_the_first_two_points() {
let series = volatile_series(12);
let volatilities = match expanding_window_volatilities(&series, TimeFrame::Day) {
Ok(Some(volatilities)) => volatilities,
other => panic!("the series must yield estimates, got {other:?}"),
};
assert_eq!(volatilities.len(), series.len());
assert_eq!(volatilities.first(), volatilities.get(2));
assert_eq!(volatilities.get(1), volatilities.get(2));
}
#[test]
fn test_expanding_window_of_a_constant_return_is_zero() {
let mut price = Positive::new(5000.0).unwrap_or(Positive::ONE);
let mut series = Vec::with_capacity(10);
for _ in 0..10 {
series.push(price);
price = price * Positive::new(1.01).unwrap_or(Positive::ONE);
}
let volatilities = match expanding_window_volatilities(&series, TimeFrame::Day) {
Ok(Some(volatilities)) => volatilities,
other => panic!("a constant-return series must still yield estimates, got {other:?}"),
};
assert_eq!(volatilities.len(), series.len());
for (point, volatility) in volatilities.iter().enumerate() {
assert!(
*volatility < Positive::new(1e-12).unwrap_or(Positive::ONE),
"point {point} of a constant-return series should be flat, got {volatility}"
);
}
}
#[test]
fn test_historical_tape_volatility_varies_across_steps() {
let parameters = parameters(volatile_historical_request(30));
let tape = tape(¶meters);
let first = match tape.row(0) {
Some(row) => row.base_volatility,
None => panic!("the tape must have a first row"),
};
assert!(
tape.rows().iter().any(|row| row.base_volatility != first),
"every step reported the same volatility, so nothing is being estimated"
);
}
#[test]
fn test_historical_tape_volatility_is_reproducible() {
let parameters = parameters(volatile_historical_request(30));
let first = tape(¶meters);
let second = tape(¶meters);
assert_eq!(first.rows(), second.rows());
}
#[test]
fn test_v1_and_v2_agree_on_historical_volatility() {
const TOLERANCE: Decimal = Decimal::from_parts(1, 0, 0, false, 18);
let parameters = parameters(volatile_historical_request(30));
let tape = tape(¶meters);
let base_volatility = match resolve_base_volatility(¶meters, ¶meters.method) {
Ok(volatility) => volatility,
Err(error) => panic!("the historical series must yield a volatility: {error}"),
};
let initial_chain = match build_initial_chain(¶meters, base_volatility) {
Ok(chain) => chain,
Err(error) => panic!("the seeding chain must build: {error}"),
};
let walk_params = WalkParams {
size: parameters.steps,
init_step: Step {
x: Xstep::new(
Positive::ONE,
parameters.time_frame,
ExpirationDate::Days(Positive::new(3650.0).unwrap_or(Positive::ONE)),
),
y: Ystep::new(0, initial_chain.clone()),
},
walk_type: parameters.method.clone(),
walker: Box::new(Walker::new_with_seed(parameters.seed)),
};
let observed: Mutex<Vec<(i32, Option<Positive>)>> = Mutex::new(Vec::new());
let walked = walk_steps_par::<OptionChain, SimulationError, _>(
&walk_params,
|_price, volatility, x_step| match observed.lock() {
Ok(mut guard) => {
guard.push((*x_step.index(), volatility));
Ok(Some(initial_chain.clone()))
}
Err(_) => Err(SimulationError::walk_error("the recorder lock is poisoned")),
},
);
if let Err(error) = walked {
panic!("the v1 driver must walk the series: {error}");
}
let mut v1_volatilities = match observed.into_inner() {
Ok(volatilities) => volatilities,
Err(_) => panic!("the recorder lock must not be poisoned"),
};
v1_volatilities.sort_by_key(|(index, _)| *index);
assert_eq!(
v1_volatilities.len(),
tape.len() - 1,
"v1 prices every step but the seeding one"
);
for (index, volatility) in &v1_volatilities {
let step = match usize::try_from(*index) {
Ok(step) => step,
Err(error) => panic!("the driver's step index must be non-negative: {error}"),
};
let v2_row = match tape.row(step) {
Some(row) => row,
None => panic!("the tape must have a row for step {step}"),
};
let v1_volatility = match volatility {
Some(volatility) => *volatility,
None => panic!("v1 must price historical step {step} with an estimate"),
};
let deviation = (v1_volatility.to_dec() - v2_row.base_volatility.to_dec()).abs();
assert!(
deviation <= TOLERANCE,
"step {step} disagrees by {deviation}: v1 {v1_volatility}, v2 {}",
v2_row.base_volatility
);
}
}
fn volatile_series(length: usize) -> Vec<Positive> {
volatile_series_from(&volatile_prices(length))
}
fn volatile_series_from(prices: &[f64]) -> Vec<Positive> {
prices
.iter()
.map(|price| match Positive::new(*price) {
Ok(price) => price,
Err(error) => panic!("the test price must be positive: {error}"),
})
.collect()
}
fn volatile_prices(length: usize) -> Vec<f64> {
let mut prices = Vec::with_capacity(length);
let mut price = 5000.0_f64;
for index in 0..length {
prices.push(price);
let shock = if index < length / 2 {
1.0
} else {
20.0 + f64::from(u32::try_from(index).unwrap_or(0))
};
price += if index % 2 == 0 { shock } else { -shock };
}
prices
}
fn volatile_historical_request(steps: usize) -> CreateSimulationRequest {
let mut historical = request(steps, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices: volatile_prices(steps * 2),
symbol: Some("SPX".to_string()),
};
historical
}
#[test]
fn test_an_unrepresentable_price_jump_is_rejected() {
let mut prices = vec![1e-28_f64; 4];
prices.push(7e28);
prices.extend(std::iter::repeat_n(7e28, 8));
let mut historical = request(4, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices,
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
match FactorTape::build(¶meters, ¶meters.method) {
Err(ChainError::Validation { field, .. }) => assert_eq!(field, "method.prices"),
other => panic!("an unrepresentable ratio must be a 400, got {other:?}"),
}
}
#[test]
fn test_an_unrepresentable_price_collapse_is_rejected() {
let mut prices = vec![7e28_f64; 4];
prices.push(1e-28);
prices.extend(std::iter::repeat_n(1e-28, 8));
let mut historical = request(4, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices,
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
match FactorTape::build(¶meters, ¶meters.method) {
Err(ChainError::Validation { field, .. }) => assert_eq!(field, "method.prices"),
other => panic!("an unrepresentable ratio must be a 400, got {other:?}"),
}
}
#[test]
fn test_a_violently_volatile_series_still_estimates() {
let prices: Vec<f64> = (0..40)
.map(|i| 5000.0 * (1.0 + 0.02 * f64::from(i).sin()))
.collect();
let mut historical = request(20, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices,
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
match FactorTape::build(¶meters, ¶meters.method) {
Ok(tape) => assert_eq!(tape.len(), 20),
Err(error) => panic!("a two percent daily move is legal, got {error}"),
}
}
#[test]
fn test_historical_replays_in_order_without_look_ahead() {
let prices: Vec<f64> = (0..30).map(|i| 5000.0 + f64::from(i)).collect();
let mut historical = request(15, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices: prices.clone(),
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
let tape = tape(¶meters);
for row in tape.rows() {
let expected = match prices.get(row.step) {
Some(price) => *price,
None => panic!("the series must cover step {}", row.step),
};
assert_eq!(
row.spot.to_f64(),
expected,
"step {} must replay its own observation",
row.step
);
}
}
#[test]
fn test_a_longer_series_leaves_earlier_steps_untouched() {
let build = |steps: usize, observations: u32| {
let prices: Vec<f64> = (0..observations)
.map(|i| 5000.0 + (f64::from(i) * 7.0).sin() * 250.0)
.collect();
let mut historical = request(steps, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices,
symbol: Some("SPX".to_string()),
};
tape(¶meters(historical))
};
let short = build(15, 20);
let long = build(15, 400);
for (early, later) in short.rows().iter().zip(long.rows()) {
assert_eq!(
early.base_volatility, later.base_volatility,
"step {} must not depend on observations after it",
early.step
);
assert_eq!(
early.spot, later.spot,
"step {} replayed differently",
early.step
);
}
}
#[test]
fn test_turbulence_past_the_horizon_cannot_refuse_a_calm_one() {
let mut prices: Vec<f64> = (0..10).map(|index| 5000.0 + f64::from(index)).collect();
prices.extend((0..10).map(|index| if index % 2 == 0 { 5000.0 } else { 5500.0 }));
let mut historical = request(10, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices: prices.clone(),
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
let tape = tape(¶meters);
assert_eq!(tape.len(), 10);
let whole_series = volatile_series_from(&prices);
match historical_constant_volatility(&whole_series, TimeFrame::Day) {
Ok(volatility) => assert!(
volatility > Positive::ONE,
"the fixture's tail must be unpriceable for this test to mean anything, got \
{volatility}"
),
Err(error) => panic!("the fixture must reduce: {error}"),
}
}
#[test]
fn test_a_flat_opening_is_refused_even_when_the_rest_moves() {
let mut prices = vec![5000.0, 5000.0, 5000.0];
prices.extend((0..10).map(|index| if index % 2 == 0 { 5050.0 } else { 4980.0 }));
let mut historical = request(8, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices,
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
match FactorTape::build(¶meters, ¶meters.method) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "method.prices");
assert!(reason.contains("zero at step 0"), "{reason}");
}
other => panic!("expected a validation error, got {other:?}"),
}
}
#[test]
fn test_historical_rebuild_is_deterministic() {
let prices: Vec<f64> = (0..30).map(|i| 5000.0 + f64::from(i)).collect();
let mut historical = request(15, brownian(0.18), 0.18);
historical.method = ApiWalkType::Historical {
timeframe: ApiTimeFrame::Day,
prices,
symbol: Some("SPX".to_string()),
};
let parameters = parameters(historical);
assert_eq!(tape(¶meters), tape(¶meters));
}
#[test]
fn test_memory_is_o_steps_and_independent_of_chain_size() {
let mut wide = request(30, brownian(0.18), 0.18);
wide.chain_size = Some(200);
let narrow = request(30, brownian(0.18), 0.18);
let wide_tape = tape(¶meters(wide));
let narrow_tape = tape(¶meters(narrow));
assert_eq!(wide_tape.rows(), narrow_tape.rows());
assert!(
std::mem::size_of::<FactorRow>() <= 128,
"a row is a handful of small fields, got {} bytes",
std::mem::size_of::<FactorRow>()
);
}
#[test]
fn test_a_volatility_above_one_is_rejected_when_the_tape_is_built() {
let mut request = request(30, brownian(1.5), 1.5);
request.volatility = 1.5;
let parameters = match SimulationParametersV2::try_from(request) {
Ok(parameters) => parameters,
Err(error) => panic!("the request must convert: {error}"),
};
match FactorTape::build(¶meters, ¶meters.method) {
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "volatility");
assert!(
reason.contains("1.0"),
"the reason must name the cap, got {reason}"
);
}
other => panic!("a 1.5 volatility must be refused, got {other:?}"),
}
}
#[test]
fn test_the_step_cap_is_enforced_at_the_boundary() {
let oversized = request(usize::MAX, brownian(0.18), 0.18);
match SimulationParametersV2::try_from(oversized) {
Err(ChainError::Validation { field, .. }) => assert_eq!(field, "steps"),
other => panic!("expected the step cap to reject it, got {other:?}"),
}
}
#[test]
fn test_a_single_step_simulation_builds_one_row() {
let parameters = parameters(request(1, brownian(0.18), 0.18));
let tape = tape(¶meters);
assert_eq!(tape.len(), 1);
assert_eq!(
tape.row(0).map(|row| row.spot),
Some(parameters.initial_price)
);
}
}