use crate::error::ChainError;
use crate::series::{OptionSeries, OptionSeriesBuildParams};
use crate::simulation::steps::Step;
use crate::simulation::{WalkParams, walk_steps_par};
use core::option::Option;
use positive::Positive;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use std::sync::Mutex;
#[cfg(test)]
use positive::pos_or_panic;
fn create_series_from_step(
build_params: &OptionSeriesBuildParams,
new_price: &Positive,
volatility: Option<Positive>,
aged_series: Vec<Positive>,
) -> Result<OptionSeries, ChainError> {
let mut series_params = build_params.clone();
series_params.set_underlying_price(new_price);
if let Some(volatility) = volatility {
let min_walk_iv = Positive::new_decimal(dec!(0.0001)).unwrap_or(Positive::ONE);
let volatility = volatility.min(Positive::ONE).max(min_walk_iv);
series_params.set_implied_volatility(volatility);
}
series_params.series = aged_series;
let new_chain = OptionSeries::build_series(&series_params)?;
Ok(new_chain)
}
pub fn generator_optionseries(
walk_params: &WalkParams<Positive, OptionSeries>,
) -> Result<Vec<Step<Positive, OptionSeries>>, ChainError> {
let context: Mutex<Option<(OptionSeriesBuildParams, Positive)>> = Mutex::new(None);
let init_ystep = walk_params.ystep_ref();
let init_x = walk_params.init_step.x;
walk_steps_par(walk_params, |new_price, volatility, x_step| {
let (build_params, initial_days_left) = {
let mut guard = context.lock().map_err(|_| {
ChainError::invalid_parameters("build_params", "params cache lock poisoned")
})?;
match &*guard {
Some(context) => context.clone(),
None => {
let build_params = init_ystep.value().to_build_params()?;
let initial_days_left = init_x.days_left()?;
*guard = Some((build_params.clone(), initial_days_left));
(build_params, initial_days_left)
}
}
};
let overflow = || {
ChainError::invalid_parameters(
"series",
"elapsed-days subtraction overflowed while aging the series",
)
};
let elapsed_days = initial_days_left
.to_dec()
.checked_sub(x_step.days_left()?.to_dec())
.ok_or_else(overflow)?
.max(Decimal::ZERO);
let aged_series: Vec<Positive> = build_params
.series
.iter()
.filter(|days| days.to_dec() > elapsed_days)
.map(|days| {
let remaining = days
.to_dec()
.checked_sub(elapsed_days)
.ok_or_else(overflow)?;
Positive::new_decimal(remaining).map_err(|e| {
ChainError::invalid_parameters("series", &format!("failed to age series: {e}"))
})
})
.collect::<Result<Vec<Positive>, ChainError>>()?;
if aged_series.is_empty() {
return Ok(None);
}
let y_step_series =
create_series_from_step(&build_params, new_price, volatility, aged_series)?;
Ok(Some(y_step_series))
})
}
#[cfg(test)]
mod tests_generator_optionseries {
use super::*;
use positive::{assert_pos_relative_eq, spos};
use crate::ExpirationDate;
use crate::chains::utils::OptionChainBuildParams;
use crate::chains::utils::OptionDataPriceParams;
use crate::error::SimulationError;
use crate::series::{OptionSeries, OptionSeriesBuildParams};
use crate::simulation::steps::{Step, Xstep, Ystep};
use crate::simulation::{WalkParams, WalkType, WalkTypeAble};
use crate::utils::TimeFrame;
use crate::utils::time::convert_time_frame;
use rust_decimal_macros::dec;
#[derive(Clone)]
struct TestWalker {}
impl TestWalker {
fn new() -> Self {
TestWalker {}
}
}
impl WalkTypeAble<Positive, OptionSeries> for TestWalker {}
fn create_test_option_series() -> OptionSeries {
let price_params = OptionDataPriceParams::new(
Some(Box::new(Positive::HUNDRED)),
Some(ExpirationDate::Days(pos_or_panic!(30.0))),
Some(dec!(0.05)),
spos!(0.02),
Some("TEST".to_string()),
);
let chain_params = OptionChainBuildParams::new(
"TEST".to_string(),
None,
5,
spos!(5.0),
dec!(-0.2),
dec!(0.1),
pos_or_panic!(0.02),
2,
price_params,
pos_or_panic!(0.2),
);
let series = vec![
pos_or_panic!(30.0),
pos_or_panic!(60.0),
pos_or_panic!(90.0),
];
let series_params = OptionSeriesBuildParams::new(chain_params, series);
OptionSeries::build_series(&series_params).unwrap()
}
#[test]
fn test_generator_optionseries_basic() {
let n_steps = 5;
let initial_series = create_test_option_series();
let std_dev = pos_or_panic!(0.2);
let days = pos_or_panic!(30.0);
let walker = Box::new(TestWalker::new());
let walk_params = WalkParams {
size: n_steps,
init_step: Step {
x: Xstep::new(Positive::ONE, TimeFrame::Day, ExpirationDate::Days(days)),
y: Ystep::new(0, initial_series),
},
walk_type: WalkType::GeometricBrownian {
dt: convert_time_frame(Positive::ONE, &TimeFrame::Day, &TimeFrame::Day),
drift: dec!(0.0),
volatility: std_dev,
},
walker,
};
let Ok(steps) = generator_optionseries(&walk_params) else {
panic!("test fixture failed")
};
assert!(!steps.is_empty(), "Steps should not be empty");
assert_eq!(
steps.len(),
5,
"Should start with just the initial step since we're mocking"
);
let first_step = &steps[0];
assert_eq!(
first_step.x.datetime().get_days().unwrap(),
pos_or_panic!(30.0)
);
assert_eq!(*first_step.y.index(), 0);
}
#[test]
fn test_generator_optionseries_empty_result() {
#[derive(Clone)]
struct TestWalker {}
let initial_series = create_test_option_series();
let walker = Box::new(TestWalker {});
impl WalkTypeAble<Positive, OptionSeries> for TestWalker {}
let walk_params = WalkParams {
size: 5,
init_step: Step {
x: Xstep::new(
Positive::ONE,
TimeFrame::Day,
ExpirationDate::Days(pos_or_panic!(30.0)),
),
y: Ystep::new(0, initial_series),
},
walk_type: WalkType::Brownian {
dt: pos_or_panic!(0.01),
drift: dec!(0.0),
volatility: pos_or_panic!(0.2),
},
walker,
};
let Ok(steps) = generator_optionseries(&walk_params) else {
panic!("test fixture failed")
};
assert!(!steps.is_empty(), "Steps shouldn't be empty");
}
#[test]
fn test_generator_optionseries_historical_empty_prices() {
let initial_series = create_test_option_series();
let walker = Box::new(TestWalker::new());
let walk_params = WalkParams {
size: 5,
init_step: Step {
x: Xstep::new(
Positive::ONE,
TimeFrame::Day,
ExpirationDate::Days(pos_or_panic!(30.0)),
),
y: Ystep::new(0, initial_series),
},
walk_type: WalkType::Historical {
timeframe: TimeFrame::Day,
prices: Vec::new(), symbol: None,
},
walker,
};
let result = generator_optionseries(&walk_params);
match result {
Err(ChainError::Simulation(e)) => {
assert!(
matches!(*e, SimulationError::InsufficientHistoricalData { .. }),
"expected InsufficientHistoricalData, got: {e}"
);
}
Err(other) => panic!("expected ChainError::Simulation, got: {other}"),
Ok(_) => panic!("empty historical prices must not produce a silent walk"),
}
}
#[test]
fn test_generator_optionseries_historical_insufficient_prices() {
let initial_series = create_test_option_series();
let walker = Box::new(TestWalker::new());
let walk_params = WalkParams {
size: 5,
init_step: Step {
x: Xstep::new(
Positive::ONE,
TimeFrame::Day,
ExpirationDate::Days(pos_or_panic!(30.0)),
),
y: Ystep::new(0, initial_series),
},
walk_type: WalkType::Historical {
timeframe: TimeFrame::Day,
prices: vec![Positive::HUNDRED, pos_or_panic!(101.0)], symbol: None,
},
walker,
};
let result = generator_optionseries(&walk_params);
match result {
Err(ChainError::Simulation(e)) => {
assert!(
matches!(*e, SimulationError::InsufficientHistoricalData { .. }),
"expected InsufficientHistoricalData, got: {e}"
);
}
Err(other) => panic!("expected ChainError::Simulation, got: {other}"),
Ok(_) => panic!("insufficient historical prices must not produce a silent walk"),
}
}
#[test]
fn test_generator_optionseries_all_walk_types() {
let initial_series = create_test_option_series();
let walker = Box::new(TestWalker::new());
let volatility = pos_or_panic!(0.2);
let walk_types = vec![
WalkType::Brownian {
dt: pos_or_panic!(0.01),
drift: dec!(0.0),
volatility,
},
WalkType::GeometricBrownian {
dt: pos_or_panic!(0.01),
drift: dec!(0.0),
volatility,
},
WalkType::LogReturns {
dt: pos_or_panic!(0.01),
expected_return: dec!(0.0),
volatility,
autocorrelation: Some(dec!(0.0)),
},
WalkType::MeanReverting {
dt: pos_or_panic!(0.01),
volatility,
speed: pos_or_panic!(0.1),
mean: Positive::HUNDRED,
},
WalkType::JumpDiffusion {
dt: pos_or_panic!(0.01),
drift: dec!(0.0),
volatility,
intensity: pos_or_panic!(0.1),
jump_mean: dec!(0.0),
jump_volatility: pos_or_panic!(0.1),
},
WalkType::Garch {
dt: pos_or_panic!(0.01),
drift: dec!(0.0),
volatility,
alpha: pos_or_panic!(0.1),
beta: pos_or_panic!(0.8),
},
WalkType::Heston {
dt: pos_or_panic!(0.01),
drift: dec!(0.0),
volatility,
kappa: Positive::TWO,
theta: pos_or_panic!(0.04),
xi: pos_or_panic!(0.1),
rho: dec!(-0.7),
},
WalkType::Custom {
dt: pos_or_panic!(0.01),
drift: dec!(0.0),
volatility,
vov: pos_or_panic!(0.1),
vol_speed: pos_or_panic!(0.1),
vol_mean: pos_or_panic!(0.2),
},
];
for walk_type in walk_types {
let walk_params = WalkParams {
size: 5,
init_step: Step {
x: Xstep::new(
Positive::ONE,
TimeFrame::Day,
ExpirationDate::Days(pos_or_panic!(30.0)),
),
y: Ystep::new(0, initial_series.clone()),
},
walk_type,
walker: walker.clone(),
};
let _ = generator_optionseries(&walk_params);
}
}
#[test]
fn test_generator_optionseries_historical() {
let initial_series = create_test_option_series();
let walker = Box::new(TestWalker {});
let historical_prices = vec![
Positive::HUNDRED,
pos_or_panic!(102.0),
pos_or_panic!(98.0),
pos_or_panic!(105.0),
pos_or_panic!(110.0),
pos_or_panic!(115.0),
pos_or_panic!(112.0),
pos_or_panic!(118.0),
pos_or_panic!(120.0),
pos_or_panic!(125.0),
];
let walk_params = WalkParams {
size: 5,
init_step: Step {
x: Xstep::new(
Positive::ONE,
TimeFrame::Day,
ExpirationDate::Days(pos_or_panic!(30.0)),
),
y: Ystep::new(0, initial_series),
},
walk_type: WalkType::Historical {
timeframe: TimeFrame::Day,
prices: historical_prices,
symbol: None,
},
walker,
};
let Ok(steps) = generator_optionseries(&walk_params) else {
panic!("test fixture failed")
};
assert!(!steps.is_empty(), "Should have at least the initial step");
assert_eq!(
steps.len(),
5,
"Should have just the initial step with our mock"
);
}
#[test]
fn test_create_series_from_step() {
let initial_series = create_test_option_series();
let build_params = match initial_series.to_build_params() {
Ok(params) => params,
Err(e) => panic!("to_build_params failed: {e}"),
};
let new_price = pos_or_panic!(105.0);
let volatility = spos!(0.22);
let aged_series = build_params.series.clone();
let result = create_series_from_step(&build_params, &new_price, volatility, aged_series);
assert!(result.is_ok(), "create_series_from_step should succeed");
let new_series = result.unwrap();
assert_eq!(
new_series.underlying_price, new_price,
"New series should have updated price"
);
if let Ok(params) = new_series.to_build_params() {
let iv = params.chain_params.get_implied_volatility();
assert_pos_relative_eq!(iv, volatility.unwrap(), pos_or_panic!(0.01));
}
}
#[test]
fn test_assert_steps_length() {
let n_steps = 3;
let initial_series = create_test_option_series();
let walker = Box::new(TestWalker {});
let walk_params = WalkParams {
size: n_steps,
init_step: Step {
x: Xstep::new(
Positive::ONE,
TimeFrame::Day,
ExpirationDate::Days(pos_or_panic!(30.0)),
),
y: Ystep::new(0, initial_series),
},
walk_type: WalkType::GeometricBrownian {
dt: pos_or_panic!(0.01),
drift: dec!(0.0),
volatility: pos_or_panic!(0.2),
},
walker,
};
let Ok(steps) = generator_optionseries(&walk_params) else {
panic!("test fixture failed")
};
assert!(
steps.len() <= n_steps,
"Steps length should not exceed the specified size"
);
}
#[test]
fn test_generator_optionseries_multi_step_aging() {
use crate::simulation::walk_test_support::RampWalker;
let initial_series = create_test_option_series(); let size = 4;
let walk_params = WalkParams {
size,
init_step: Step {
x: Xstep::new(
Positive::ONE,
TimeFrame::Day,
ExpirationDate::Days(pos_or_panic!(30.0)),
),
y: Ystep::new(0, initial_series),
},
walk_type: WalkType::GeometricBrownian {
dt: pos_or_panic!(0.01),
drift: dec!(0.0),
volatility: pos_or_panic!(0.2),
},
walker: Box::new(RampWalker {
delta: Positive::TWO,
}),
};
let steps = match generator_optionseries(&walk_params) {
Ok(steps) => steps,
Err(e) => panic!("generator_optionseries failed: {e}"),
};
assert_eq!(steps.len(), size);
for (i, step) in steps.iter().enumerate().skip(1) {
let series = step.y.value();
let expected_price = Positive::HUNDRED + Positive::TWO * i as f64;
assert_eq!(
series.underlying_price, expected_price,
"underlying at step {i}"
);
let expirations = match series.get_expiration_dates() {
Ok(dates) => dates,
Err(e) => panic!("expirations missing at step {i}: {e}"),
};
let elapsed = i as f64;
let expected: Vec<Positive> = [30.0, 60.0, 90.0]
.iter()
.map(|d| pos_or_panic!(d - elapsed))
.collect();
assert_eq!(
expirations, expected,
"series did not age at step {i}: {expirations:?}"
);
}
}
#[test]
fn test_generator_optionseries_stops_when_all_expired() {
use crate::chains::utils::OptionChainBuildParams;
use crate::chains::utils::OptionDataPriceParams;
use crate::simulation::walk_test_support::RampWalker;
let price_params = OptionDataPriceParams::new(
Some(Box::new(Positive::HUNDRED)),
Some(ExpirationDate::Days(pos_or_panic!(2.0))),
Some(dec!(0.05)),
spos!(0.02),
Some("TEST".to_string()),
);
let chain_params = OptionChainBuildParams::new(
"TEST".to_string(),
None,
5,
spos!(5.0),
dec!(-0.2),
dec!(0.1),
pos_or_panic!(0.02),
2,
price_params,
pos_or_panic!(0.2),
);
let series_params = OptionSeriesBuildParams::new(chain_params, vec![pos_or_panic!(2.0)]);
let initial_series = match OptionSeries::build_series(&series_params) {
Ok(series) => series,
Err(e) => panic!("series build failed: {e}"),
};
let walk_params = WalkParams {
size: 10,
init_step: Step {
x: Xstep::new(
Positive::ONE,
TimeFrame::Day,
ExpirationDate::Days(pos_or_panic!(30.0)),
),
y: Ystep::new(0, initial_series),
},
walk_type: WalkType::GeometricBrownian {
dt: pos_or_panic!(0.01),
drift: dec!(0.0),
volatility: pos_or_panic!(0.2),
},
walker: Box::new(RampWalker {
delta: Positive::ONE,
}),
};
let steps = match generator_optionseries(&walk_params) {
Ok(steps) => steps,
Err(e) => panic!("generator_optionseries failed: {e}"),
};
assert_eq!(
steps.len(),
2,
"walk must stop once every series expiration has passed"
);
}
}