use crate::backtesting::results::{SimulationResult, SimulationStatsResult};
use crate::error::BacktestError;
use optionstratlib_analytics::pnl::{PnL, PnLCalculator};
use optionstratlib_core::model::Positive;
use optionstratlib_core::model::decimal::{d_add, d_div, d_sub};
use optionstratlib_core::model::{ExpirationDate, Options};
use optionstratlib_core::utils::Len;
use optionstratlib_pricing::pricing::OptionPricing;
use optionstratlib_simulation::error::SimulationError;
use optionstratlib_simulation::simulation::randomwalk::RandomWalk;
use optionstratlib_simulation::simulation::simulator::Simulator;
use optionstratlib_simulation::simulation::{ExitPolicy, PathEvaluator, check_exit_policy};
use optionstratlib_strategies::strategies::base::Positionable;
use optionstratlib_strategies::strategies::{LongCall, LongPut, ShortCall, ShortPut, Strategies};
use rayon::prelude::*;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use std::collections::HashMap;
use std::fmt::Display;
use std::ops::AddAssign;
pub trait Simulate<X, Y>
where
X: Copy + TryInto<Positive> + AddAssign + Display,
Y: TryInto<Positive> + Display + Clone,
{
fn simulate(
&self,
sim: &Simulator<X, Y>,
exit: ExitPolicy,
) -> Result<SimulationStatsResult, BacktestError>;
}
const TAKE_PROFIT_FRACTION: Decimal = dec!(0.5);
const STOP_LOSS_FRACTION: Decimal = dec!(-1.0);
const EXPIRATION_MARK_DAYS: f64 = 0.001;
pub trait SingleLegSimulation: PnLCalculator + Positionable {
const IS_LONG: bool;
fn mark_adjustment(&self) -> Result<Positive, BacktestError> {
Ok(Positive::ZERO)
}
fn simulated_option(&self) -> Result<&Options, SimulationError> {
let positions = self.get_positions().map_err(|e| {
SimulationError::invalid_parameters(&format!(
"single-leg simulation cannot read the strategy positions: {e}"
))
})?;
match positions.as_slice() {
[position] => Ok(&position.option),
_ => Err(SimulationError::invalid_parameters(&format!(
"single-leg simulation expects exactly one position, found {}",
positions.len()
))),
}
}
}
impl SingleLegSimulation for LongCall {
const IS_LONG: bool = true;
}
impl SingleLegSimulation for LongPut {
const IS_LONG: bool = true;
}
impl SingleLegSimulation for ShortCall {
const IS_LONG: bool = false;
}
impl SingleLegSimulation for ShortPut {
const IS_LONG: bool = false;
fn mark_adjustment(&self) -> Result<Positive, BacktestError> {
Ok(self.get_fees()?)
}
}
#[inline]
fn div_unrounded(lhs: Decimal, rhs: Decimal, op: &'static str) -> Result<Decimal, BacktestError> {
if rhs.is_zero() {
return Err(SimulationError::invalid_parameters(&format!("{op}: division by zero")).into());
}
lhs.checked_div(rhs).ok_or_else(|| {
SimulationError::invalid_parameters(&format!("{op}: {lhs} / {rhs} overflowed")).into()
})
}
#[derive(Debug)]
pub struct SingleLegPathEvaluator<'a, S> {
strategy: &'a S,
initial_premium: Decimal,
}
impl<'a, S> SingleLegPathEvaluator<'a, S>
where
S: SingleLegSimulation,
{
pub fn new(strategy: &'a S) -> Result<Self, BacktestError> {
let initial_premium = strategy
.simulated_option()?
.calculate_price_black_scholes()?
.abs();
Ok(Self {
strategy,
initial_premium,
})
}
#[must_use]
#[inline]
pub fn initial_premium(&self) -> Decimal {
self.initial_premium
}
}
impl<S, X, Y> PathEvaluator<X, Y> for SingleLegPathEvaluator<'_, S>
where
S: SingleLegSimulation,
X: Copy + Into<Positive> + AddAssign + Display,
Y: Into<Positive> + Display + Clone,
{
type Outcome = SimulationResult;
type Error = BacktestError;
fn evaluate_path(
&self,
random_walk: &RandomWalk<X, Y>,
exit: &ExitPolicy,
) -> Result<SimulationResult, BacktestError> {
let option = self.strategy.simulated_option()?;
let initial_premium = self.initial_premium;
let mark_adjustment = self.strategy.mark_adjustment()?.to_dec();
let mut max_premium = initial_premium;
let mut min_premium = initial_premium;
let mut premium_sum = initial_premium;
let mut premium_count: usize = 1;
let mut hit_take_profit = false;
let mut hit_stop_loss = false;
let mut expired = false;
let mut expiration_premium = None;
let mut holding_period = 0;
let mut exit_reason = ExitPolicy::Expiration;
let mut final_pnl = None;
let mut current_option = option.clone();
for step in random_walk.get_steps().iter().skip(1) {
let days_left = match step.x.days_left() {
Ok(days) => days,
Err(_) => {
expired = true;
break;
}
};
current_option.underlying_price = step.y.positive()?;
current_option.expiration_date = ExpirationDate::Days(days_left);
let current_premium = d_add(
current_option.calculate_price_black_scholes()?.abs(),
mark_adjustment,
"backtesting::strategy_simulation/mark_adjustment",
)?;
let index = usize::try_from(*step.x.index()).map_err(|_| {
SimulationError::invalid_parameters(&format!(
"backtesting::strategy_simulation: negative step index {}",
step.x.index()
))
})?;
max_premium = max_premium.max(current_premium);
min_premium = min_premium.min(current_premium);
premium_sum = d_add(
premium_sum,
current_premium,
"backtesting::strategy_simulation/premium_sum",
)?;
premium_count = premium_count.checked_add(1).ok_or_else(|| {
SimulationError::invalid_parameters(
"backtesting::strategy_simulation: premium count overflowed",
)
})?;
holding_period = index;
if let Some(reason) = check_exit_policy(
exit,
initial_premium,
current_premium,
index,
days_left,
current_option.underlying_price,
S::IS_LONG,
) {
exit_reason = reason;
let move_from_open = if S::IS_LONG {
d_sub(
current_premium,
initial_premium,
"backtesting::strategy_simulation/pnl_move",
)?
} else {
d_sub(
initial_premium,
current_premium,
"backtesting::strategy_simulation/pnl_move",
)?
};
let pnl_percent = d_div(
move_from_open,
initial_premium,
"backtesting::strategy_simulation/pnl_percent",
)
.unwrap_or(Decimal::ZERO);
if pnl_percent >= TAKE_PROFIT_FRACTION {
hit_take_profit = true;
} else if pnl_percent <= STOP_LOSS_FRACTION {
hit_stop_loss = true;
}
let pnl = PnL {
realized: Some(move_from_open),
unrealized: None,
initial_costs: Positive::ZERO,
initial_income: Positive::ZERO,
date_time: chrono::Utc::now(),
};
final_pnl = Some(pnl);
break;
}
}
if final_pnl.is_none()
&& let Some(last_step) = random_walk.last()
{
let final_price = last_step.y.positive()?;
let pnl = self.strategy.calculate_pnl_at_expiration(&final_price)?;
let mut exp_option = option.clone();
exp_option.underlying_price = final_price;
exp_option.expiration_date = ExpirationDate::Days(Positive::new(EXPIRATION_MARK_DAYS)?);
expiration_premium = Some(exp_option.calculate_price_black_scholes()?.abs());
expired = true;
exit_reason = ExitPolicy::Expiration;
final_pnl = Some(pnl);
holding_period = random_walk.len().checked_sub(1).ok_or_else(|| {
SimulationError::invalid_parameters(
"backtesting::strategy_simulation: a walk with a last step has no length",
)
})?;
}
let pnl = final_pnl.unwrap_or_default();
let avg_premium = div_unrounded(
premium_sum,
Decimal::from(premium_count),
"backtesting::strategy_simulation/avg_premium",
)?;
Ok(SimulationResult {
simulation_count: 1,
risk_metrics: None,
final_equity_percentiles: HashMap::new(),
max_premium,
min_premium,
avg_premium,
hit_take_profit,
hit_stop_loss,
expired,
expiration_premium,
pnl,
holding_period,
exit_reason,
})
}
}
#[must_use = "the simulation statistics are the only product of this call"]
#[tracing::instrument(level = "debug", skip(strategy, sim), fields(walks = sim.len()))]
pub fn simulate_single_leg<S, X, Y>(
strategy: &S,
sim: &Simulator<X, Y>,
exit: ExitPolicy,
) -> Result<SimulationStatsResult, BacktestError>
where
S: SingleLegSimulation + Sync,
X: Copy + Into<Positive> + AddAssign + Display + Send + Sync,
Y: Into<Positive> + Display + Clone + Send + Sync,
{
let evaluator = SingleLegPathEvaluator::new(strategy)?;
let walks = sim.get_random_walks();
let walk_count = walks.len();
let outcomes: Vec<Result<SimulationResult, BacktestError>> = walks
.into_par_iter()
.enumerate()
.map(|(path, random_walk)| {
let outcome = evaluator.evaluate_path(random_walk, &exit);
tracing::debug!(path, total = walk_count, "simulated path evaluated");
outcome
})
.collect();
let simulation_results = outcomes.into_iter().collect::<Result<Vec<_>, _>>()?;
tracing::info!(total = walk_count, "simulations completed");
Ok(SimulationStatsResult::from_results(simulation_results)?)
}
impl<X, Y> Simulate<X, Y> for LongCall
where
X: Copy + Into<Positive> + AddAssign + Display + Send + Sync,
Y: Into<Positive> + Display + Clone + Send + Sync,
{
fn simulate(
&self,
sim: &Simulator<X, Y>,
exit: ExitPolicy,
) -> Result<SimulationStatsResult, BacktestError> {
simulate_single_leg(self, sim, exit)
}
}
impl<X, Y> Simulate<X, Y> for LongPut
where
X: Copy + Into<Positive> + AddAssign + Display + Send + Sync,
Y: Into<Positive> + Display + Clone + Send + Sync,
{
fn simulate(
&self,
sim: &Simulator<X, Y>,
exit: ExitPolicy,
) -> Result<SimulationStatsResult, BacktestError> {
simulate_single_leg(self, sim, exit)
}
}
impl<X, Y> Simulate<X, Y> for ShortCall
where
X: Copy + Into<Positive> + AddAssign + Display + Send + Sync,
Y: Into<Positive> + Display + Clone + Send + Sync,
{
fn simulate(
&self,
sim: &Simulator<X, Y>,
exit: ExitPolicy,
) -> Result<SimulationStatsResult, BacktestError> {
simulate_single_leg(self, sim, exit)
}
}
impl<X, Y> Simulate<X, Y> for ShortPut
where
X: Copy + Into<Positive> + AddAssign + Display + Send + Sync,
Y: Into<Positive> + Display + Clone + Send + Sync,
{
fn simulate(
&self,
sim: &Simulator<X, Y>,
exit: ExitPolicy,
) -> Result<SimulationStatsResult, BacktestError> {
simulate_single_leg(self, sim, exit)
}
}
#[cfg(test)]
mod test_support {
use super::*;
use chrono::Utc;
use optionstratlib_core::model::position::Position;
use optionstratlib_core::model::types::{OptionStyle, OptionType, Side};
use optionstratlib_core::pos_or_panic;
use optionstratlib_core::utils::TimeFrame;
use optionstratlib_simulation::simulation::steps::Step;
use optionstratlib_simulation::simulation::{WalkParams, WalkType, WalkTypeAble};
#[derive(Clone)]
pub(super) struct TestWalker;
impl WalkTypeAble<Positive, Positive> for TestWalker {}
pub(super) fn test_leg(side: Side, style: OptionStyle) -> Position {
let option = Options::new(
OptionType::European,
side,
"TEST".to_string(),
Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.20),
Positive::ONE,
Positive::HUNDRED,
dec!(0.05),
style,
Positive::ZERO,
None,
);
Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ZERO,
Positive::ZERO,
None,
None,
)
}
pub(super) fn create_test_long_call() -> LongCall {
LongCall::new(
"TEST".to_string(),
Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.20),
Positive::ONE,
Positive::HUNDRED,
dec!(0.05),
Positive::ZERO,
pos_or_panic!(5.0),
Positive::ZERO,
Positive::ZERO,
)
.unwrap()
}
pub(super) fn create_test_long_put() -> LongPut {
let mut strategy = LongPut::default();
strategy
.add_position(&test_leg(Side::Long, OptionStyle::Put))
.unwrap();
strategy
}
pub(super) fn create_test_short_call() -> ShortCall {
let mut strategy = ShortCall::default();
strategy
.add_position(&test_leg(Side::Short, OptionStyle::Call))
.unwrap();
strategy
}
pub(super) fn create_test_short_put() -> ShortPut {
let mut strategy = ShortPut::default();
strategy
.add_position(&test_leg(Side::Short, OptionStyle::Put))
.unwrap();
strategy
}
pub(super) fn create_walk_params(prices: Vec<Positive>) -> WalkParams<Positive, Positive> {
let init_step = Step::new(
Positive::ONE,
TimeFrame::Day,
ExpirationDate::Days(pos_or_panic!(30.0)),
Positive::HUNDRED,
);
WalkParams {
size: prices.len(),
init_step,
walker: Box::new(TestWalker),
walk_type: WalkType::Historical {
timeframe: TimeFrame::Day,
prices,
symbol: Some("TEST".to_string()),
},
seed: None,
}
}
}
#[cfg(test)]
mod tests_single_leg_contract {
use super::test_support::*;
use super::*;
use optionstratlib_core::pos_or_panic;
use optionstratlib_simulation::simulation::generator_positive;
use rust_decimal::MathematicalOps;
#[test]
fn test_single_leg_sides_and_adjustments() {
let sides = [
<LongCall as SingleLegSimulation>::IS_LONG,
<LongPut as SingleLegSimulation>::IS_LONG,
<ShortCall as SingleLegSimulation>::IS_LONG,
<ShortPut as SingleLegSimulation>::IS_LONG,
];
assert_eq!(sides, [true, true, false, false]);
assert_eq!(
create_test_long_call().mark_adjustment().unwrap(),
Positive::ZERO
);
let short_put = create_test_short_put();
assert_eq!(
short_put.mark_adjustment().unwrap(),
short_put.get_fees().unwrap()
);
}
#[test]
fn test_simulated_option_is_the_single_leg() {
let strategy = create_test_long_call();
let option = strategy.simulated_option().unwrap();
assert_eq!(option.strike_price, Positive::HUNDRED);
let evaluator = SingleLegPathEvaluator::new(&strategy).unwrap();
assert_eq!(
evaluator.initial_premium(),
option.calculate_price_black_scholes().unwrap().abs()
);
}
#[test]
fn test_evaluate_path_rejects_a_negative_step_index() {
let strategy = create_test_long_call();
let evaluator = SingleLegPathEvaluator::new(&strategy).unwrap();
let walk_params = create_walk_params(vec![Positive::HUNDRED, Positive::HUNDRED]);
let walk = RandomWalk::new("negative index".to_string(), &walk_params, |params| {
let first = params.init_step.clone();
let mut rewound = first.clone();
rewound.x = first.x.previous()?;
Ok::<_, SimulationError>(vec![first, rewound])
})
.unwrap();
assert_eq!(*walk.get_steps()[1].x.index(), -1);
let result = evaluator.evaluate_path(&walk, &ExitPolicy::Expiration);
assert!(result.is_err(), "negative index accepted: {result:?}");
}
#[test]
fn test_simulated_option_rejects_a_strategy_without_a_single_leg() {
use optionstratlib_core::model::position::Position;
use optionstratlib_pricing::error::PricingError;
struct Legs(Vec<Position>);
impl PnLCalculator for Legs {
fn calculate_pnl(
&self,
_underlying_price: &Positive,
_expiration_date: ExpirationDate,
_implied_volatility: &Positive,
) -> Result<PnL, PricingError> {
Ok(PnL::default())
}
fn calculate_pnl_at_expiration(
&self,
_underlying_price: &Positive,
) -> Result<PnL, PricingError> {
Ok(PnL::default())
}
}
impl Positionable for Legs {
fn get_positions(
&self,
) -> Result<Vec<&Position>, optionstratlib_core::error::PositionError> {
Ok(self.0.iter().collect())
}
}
impl SingleLegSimulation for Legs {
const IS_LONG: bool = true;
}
assert!(matches!(
Legs(Vec::new()).simulated_option(),
Err(SimulationError::InvalidParameters { .. })
));
let leg = create_test_long_call().get_positions().unwrap()[0].clone();
assert!(matches!(
Legs(vec![leg.clone(), leg]).simulated_option(),
Err(SimulationError::InvalidParameters { .. })
));
assert!(matches!(
SingleLegPathEvaluator::new(&Legs(Vec::new())),
Err(BacktestError::Simulation(inner))
if matches!(*inner, SimulationError::InvalidParameters { .. })
));
}
#[test]
fn test_simulate_aggregate_matches_from_results() {
let strategy = create_test_short_put();
let walk_params = create_walk_params(vec![
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(110.0),
]);
let simulator =
Simulator::new("Test".to_string(), 3, &walk_params, generator_positive).unwrap();
let stats = strategy
.simulate(&simulator, ExitPolicy::Expiration)
.unwrap();
let recomputed = SimulationStatsResult::from_results(stats.results.clone()).unwrap();
assert_eq!(stats.average_pnl, recomputed.average_pnl);
assert_eq!(stats.median_pnl, recomputed.median_pnl);
assert_eq!(stats.std_dev_pnl, recomputed.std_dev_pnl);
assert_eq!(stats.win_rate, recomputed.win_rate);
assert_eq!(
stats.average_holding_period,
recomputed.average_holding_period
);
assert_eq!(stats.std_dev_pnl, dec!(0.0).sqrt().unwrap());
}
}
#[cfg(test)]
mod tests_simulate_long_call {
use super::test_support::*;
use super::*;
use optionstratlib_core::pos_or_panic;
use optionstratlib_simulation::simulation::generator_positive;
#[test]
fn test_simulate_profit_percent_exit() {
let strategy = create_test_long_call();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(110.0),
pos_or_panic!(115.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
else {
panic!("simulator setup failed");
};
let results = strategy.simulate(&simulator, ExitPolicy::ProfitPercent(dec!(0.5)));
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
}
#[test]
fn test_simulate_expiration_exit() {
let strategy = create_test_long_call();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(101.0),
pos_or_panic!(102.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
else {
panic!("simulator setup failed");
};
let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
}
#[test]
fn test_simulate_stats_aggregation() {
let strategy = create_test_long_call();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(110.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new("Test".to_string(), 3, &walk_params, generator_positive)
else {
panic!("simulator setup failed");
};
let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
let stats = results.unwrap();
assert!(stats.total_simulations >= 1);
assert_eq!(stats.total_simulations, stats.results.len());
assert!(stats.win_rate >= dec!(0.0) && stats.win_rate <= dec!(100.0));
}
}
#[cfg(test)]
mod tests_simulate_long_put {
use super::test_support::*;
use super::*;
use optionstratlib_core::pos_or_panic;
use optionstratlib_simulation::simulation::generator_positive;
#[test]
fn test_simulate_profit_percent_exit() {
let strategy = create_test_long_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(95.0),
pos_or_panic!(90.0),
pos_or_panic!(85.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
else {
panic!("simulator setup failed");
};
let results = strategy.simulate(&simulator, ExitPolicy::ProfitPercent(dec!(0.5)));
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
}
#[test]
fn test_simulate_expiration_exit() {
let strategy = create_test_long_put();
let prices = vec![Positive::HUNDRED, pos_or_panic!(99.0), pos_or_panic!(98.0)];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
else {
panic!("simulator setup failed");
};
let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
}
#[test]
fn test_simulate_stats_aggregation() {
let strategy = create_test_long_put();
let prices = vec![Positive::HUNDRED, pos_or_panic!(95.0), pos_or_panic!(90.0)];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new("Test".to_string(), 3, &walk_params, generator_positive)
else {
panic!("simulator setup failed");
};
let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
let stats = results.unwrap();
assert!(stats.total_simulations >= 1);
assert_eq!(stats.total_simulations, stats.results.len());
assert!(stats.win_rate >= dec!(0.0) && stats.win_rate <= dec!(100.0));
}
}
#[cfg(test)]
mod tests_simulate_short_call {
use super::test_support::*;
use super::*;
use optionstratlib_core::pos_or_panic;
use optionstratlib_simulation::simulation::generator_positive;
#[test]
fn test_simulate_profit_percent_exit() {
let strategy = create_test_short_call();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(95.0),
pos_or_panic!(90.0),
pos_or_panic!(85.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
else {
panic!("simulator setup failed");
};
let results = strategy.simulate(&simulator, ExitPolicy::ProfitPercent(dec!(0.5)));
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
}
#[test]
fn test_simulate_expiration_exit() {
let strategy = create_test_short_call();
let prices = vec![Positive::HUNDRED, pos_or_panic!(99.0), pos_or_panic!(98.0)];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new("Test".to_string(), 1, &walk_params, generator_positive)
else {
panic!("simulator setup failed");
};
let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
}
#[test]
fn test_simulate_stats_aggregation() {
let strategy = create_test_short_call();
let prices = vec![Positive::HUNDRED, pos_or_panic!(95.0), pos_or_panic!(90.0)];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new("Test".to_string(), 3, &walk_params, generator_positive)
else {
panic!("simulator setup failed");
};
let results = strategy.simulate(&simulator, ExitPolicy::Expiration);
assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
let stats = results.unwrap();
assert!(stats.total_simulations >= 1);
assert_eq!(stats.total_simulations, stats.results.len());
assert!(stats.win_rate >= dec!(0.0) && stats.win_rate <= dec!(100.0));
}
}
#[cfg(test)]
mod tests_simulate_short_put {
use super::test_support::*;
use super::*;
use optionstratlib_core::pos_or_panic;
use optionstratlib_simulation::simulation::generator_positive;
#[test]
fn test_simulate_profit_percent_exit() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(110.0),
pos_or_panic!(115.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::ProfitPercent(dec!(0.5));
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
assert!(!stats.results.is_empty());
}
#[test]
fn test_simulate_loss_percent_exit() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(95.0),
pos_or_panic!(90.0),
pos_or_panic!(85.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::LossPercent(dec!(1.0));
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
assert!(!stats.results.is_empty());
}
#[test]
fn test_simulate_expiration_exit() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(101.0),
pos_or_panic!(102.0),
pos_or_panic!(103.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::Expiration;
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
assert!(!stats.results.is_empty());
}
#[test]
fn test_simulate_or_exit_policy() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(110.0),
pos_or_panic!(115.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::Or(vec![
ExitPolicy::ProfitPercent(dec!(0.5)),
ExitPolicy::UnderlyingAbove(pos_or_panic!(112.0)),
]);
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
assert!(!stats.results.is_empty());
}
#[test]
fn test_simulate_stats_aggregation() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(110.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
5,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::Expiration;
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok(), "Simulate failed: {:?}", results.err());
let stats = results.unwrap();
assert!(stats.total_simulations >= 1);
assert_eq!(stats.total_simulations, stats.results.len());
assert!(stats.win_rate >= dec!(0.0) && stats.win_rate <= dec!(100.0));
assert!(stats.average_holding_period >= dec!(0.0));
}
#[test]
fn test_simulate_time_steps_exit() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(101.0),
pos_or_panic!(102.0),
pos_or_panic!(103.0),
pos_or_panic!(104.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::TimeSteps(2);
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
assert!(!stats.results.is_empty());
}
#[test]
fn test_simulate_underlying_below_exit() {
let strategy = create_test_short_put();
let prices = vec![Positive::HUNDRED, pos_or_panic!(95.0), pos_or_panic!(90.0)];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::UnderlyingBelow(pos_or_panic!(92.0));
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
}
#[test]
fn test_simulate_and_exit_policy() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(110.0),
pos_or_panic!(115.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::And(vec![
ExitPolicy::TimeSteps(2),
ExitPolicy::UnderlyingAbove(pos_or_panic!(105.0)),
]);
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
}
#[test]
fn test_simulate_multiple_simulations() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(102.0),
pos_or_panic!(104.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
10,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::Expiration;
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 10);
assert_eq!(stats.results.len(), 10);
assert!(stats.profitable_count + stats.loss_count <= 10);
}
#[test]
fn test_simulate_stats_calculations() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(110.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
3,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::Expiration;
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert!(stats.average_pnl.abs() >= dec!(0.0));
assert!(stats.median_pnl.abs() >= dec!(0.0));
assert!(stats.std_dev_pnl >= dec!(0.0));
assert!(stats.best_pnl >= stats.worst_pnl);
}
#[test]
fn test_simulate_expiration_path() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(100.5),
pos_or_panic!(101.0),
pos_or_panic!(100.8),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::Expiration;
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
assert!(!stats.results.is_empty());
let result = &stats.results[0];
assert!(result.expired);
assert!(result.expiration_premium.is_some());
}
#[test]
fn test_simulate_profit_target_hit() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(110.0),
pos_or_panic!(120.0),
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::ProfitPercent(dec!(0.5));
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
let result = &stats.results[0];
assert!(result.hit_take_profit || result.expired);
}
#[test]
fn test_simulate_stop_loss_hit() {
let strategy = create_test_short_put();
let prices = vec![Positive::HUNDRED, pos_or_panic!(85.0), pos_or_panic!(70.0)];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::LossPercent(dec!(1.0));
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 1);
let result = &stats.results[0];
assert!(result.hit_stop_loss || result.expired);
}
#[test]
fn test_simulate_premium_tracking() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(95.0),
pos_or_panic!(105.0),
Positive::HUNDRED,
];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::Expiration;
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
let result = &stats.results[0];
assert!(result.max_premium >= result.min_premium);
assert!(result.avg_premium >= result.min_premium);
assert!(result.avg_premium <= result.max_premium);
}
#[test]
fn test_simulate_holding_period() {
let strategy = create_test_short_put();
let prices = vec![
Positive::HUNDRED,
pos_or_panic!(101.0),
pos_or_panic!(102.0),
];
let price_count = prices.len();
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
1,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::TimeSteps(1);
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
let result = &stats.results[0];
assert!(result.holding_period > 0);
assert!(result.holding_period <= price_count);
}
#[test]
fn test_simulate_mixed_results() {
let strategy = create_test_short_put();
let prices = vec![Positive::HUNDRED, pos_or_panic!(102.0), pos_or_panic!(98.0)];
let walk_params = create_walk_params(prices);
let Ok(simulator) = Simulator::new(
"Test Simulator".to_string(),
5,
&walk_params,
generator_positive,
) else {
panic!("simulator setup failed");
};
let exit_policy = ExitPolicy::Or(vec![
ExitPolicy::ProfitPercent(dec!(0.5)),
ExitPolicy::LossPercent(dec!(1.0)),
]);
let results = strategy.simulate(&simulator, exit_policy);
assert!(results.is_ok());
let stats = results.unwrap();
assert_eq!(stats.total_simulations, 5);
assert_eq!(stats.results.len(), 5);
assert!(stats.win_rate >= dec!(0.0));
assert!(stats.win_rate <= dec!(100.0));
}
}