use crate::error::SimulationError;
use crate::simulation::ExitPolicy;
use crate::simulation::randomwalk::RandomWalk;
use crate::simulation::simulator::Simulator;
use optionstratlib_core::model::Positive;
use optionstratlib_core::model::decimal::{d_add, d_div, d_mul, d_sqrt, d_sub, d_sum_iter};
use optionstratlib_core::utils::Len;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::ops::AddAssign;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct PathOutcome {
pub pnl: Option<Decimal>,
pub holding_period: usize,
pub exit_reason: ExitPolicy,
pub hit_take_profit: bool,
pub hit_stop_loss: bool,
pub expired: bool,
pub max_premium: Decimal,
pub min_premium: Decimal,
pub avg_premium: Decimal,
pub expiration_premium: Option<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct PathStatistics {
pub total_paths: usize,
pub profitable_count: usize,
pub loss_count: usize,
pub average_pnl: Decimal,
pub median_pnl: Decimal,
pub std_dev_pnl: Decimal,
pub best_pnl: Decimal,
pub worst_pnl: Decimal,
pub win_rate: Decimal,
pub average_holding_period: Decimal,
}
#[inline]
fn div_unrounded(lhs: Decimal, rhs: Decimal, op: &'static str) -> Result<Decimal, SimulationError> {
if rhs.is_zero() {
return Err(SimulationError::invalid_parameters(&format!(
"{op}: division by zero"
)));
}
lhs.checked_div(rhs).ok_or_else(|| {
SimulationError::invalid_parameters(&format!("{op}: {lhs} / {rhs} overflowed"))
})
}
impl PathStatistics {
#[must_use = "the statistics are the only product of this call"]
pub fn from_outcomes(outcomes: &[PathOutcome]) -> Result<Self, SimulationError> {
let total_paths = outcomes.len();
let mut pnl_values: Vec<Decimal> = outcomes
.iter()
.map(|outcome| outcome.pnl.unwrap_or(dec!(0.0)))
.collect();
pnl_values.sort();
let profitable_count = pnl_values.iter().filter(|&&pnl| pnl > dec!(0.0)).count();
let loss_count = pnl_values.iter().filter(|&&pnl| pnl < dec!(0.0)).count();
let sum_pnl = d_sum_iter(
pnl_values.iter().copied(),
"PathStatistics::from_outcomes/sum_pnl",
)?;
let average_pnl = if total_paths > 0 {
div_unrounded(
sum_pnl,
Decimal::from(total_paths),
"PathStatistics::from_outcomes/average_pnl",
)?
} else {
dec!(0.0)
};
let median_pnl = if pnl_values.is_empty() {
dec!(0.0)
} else {
let mid = pnl_values.len() / 2;
let at = |i: usize| {
pnl_values.get(i).copied().ok_or_else(|| {
SimulationError::invalid_parameters(
"PathStatistics::from_outcomes: median index out of range",
)
})
};
if pnl_values.len().is_multiple_of(2) {
let lower = mid.checked_sub(1).ok_or_else(|| {
SimulationError::invalid_parameters(
"PathStatistics::from_outcomes: even sample count with no lower median",
)
})?;
d_div(
d_add(at(lower)?, at(mid)?, "PathStatistics::from_outcomes/median")?,
dec!(2.0),
"PathStatistics::from_outcomes/median",
)?
} else {
at(mid)?
}
};
let variance = if total_paths > 1 {
let mut sum_squared_diff = Decimal::ZERO;
for &pnl in &pnl_values {
let diff = d_sub(
pnl,
average_pnl,
"PathStatistics::from_outcomes/variance_diff",
)?;
sum_squared_diff = d_add(
sum_squared_diff,
d_mul(diff, diff, "PathStatistics::from_outcomes/variance_square")?,
"PathStatistics::from_outcomes/variance_sum",
)?;
}
d_div(
sum_squared_diff,
Decimal::from(total_paths.checked_sub(1).ok_or_else(|| {
SimulationError::invalid_parameters(
"PathStatistics::from_outcomes: variance of an empty sample",
)
})?),
"PathStatistics::from_outcomes/variance",
)?
} else {
dec!(0.0)
};
let std_dev_pnl =
d_sqrt(variance, "simulation::outcome::from_outcomes").unwrap_or(dec!(0.0));
let best_pnl = pnl_values.last().copied().unwrap_or(dec!(0.0));
let worst_pnl = pnl_values.first().copied().unwrap_or(dec!(0.0));
let win_rate = if total_paths > 0 {
d_mul(
div_unrounded(
Decimal::from(profitable_count),
Decimal::from(total_paths),
"PathStatistics::from_outcomes/win_rate",
)?,
dec!(100.0),
"PathStatistics::from_outcomes/win_rate",
)?
} else {
dec!(0.0)
};
let average_holding_period = if total_paths > 0 {
let sum_holding = outcomes
.iter()
.try_fold(0usize, |acc, outcome| {
acc.checked_add(outcome.holding_period)
})
.ok_or_else(|| {
SimulationError::invalid_parameters(
"PathStatistics::from_outcomes: holding period total overflowed",
)
})?;
div_unrounded(
Decimal::from(sum_holding),
Decimal::from(total_paths),
"PathStatistics::from_outcomes/average_holding_period",
)?
} else {
dec!(0.0)
};
Ok(Self {
total_paths,
profitable_count,
loss_count,
average_pnl,
median_pnl,
std_dev_pnl,
best_pnl,
worst_pnl,
win_rate,
average_holding_period,
})
}
}
pub trait PathEvaluator<X, Y>
where
X: Copy + TryInto<Positive> + AddAssign + Display,
Y: TryInto<Positive> + Display + Clone,
{
type Outcome;
type Error: From<SimulationError>;
fn evaluate_path(
&self,
walk: &RandomWalk<X, Y>,
exit: &ExitPolicy,
) -> Result<Self::Outcome, Self::Error>;
}
#[must_use = "the outcomes are the only product of this call"]
pub fn evaluate_paths<X, Y, E>(
evaluator: &E,
sim: &Simulator<X, Y>,
exit: &ExitPolicy,
) -> Result<Vec<E::Outcome>, E::Error>
where
X: Copy + TryInto<Positive> + AddAssign + Display,
Y: TryInto<Positive> + Display + Clone,
E: PathEvaluator<X, Y>,
{
let mut outcomes = Vec::with_capacity(sim.len());
for walk in sim {
outcomes.push(evaluator.evaluate_path(walk, exit)?);
}
Ok(outcomes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::steps::Step;
use crate::simulation::{WalkParams, WalkType, WalkTypeAble, generator_positive};
use optionstratlib_core::model::ExpirationDate;
use optionstratlib_core::pos_or_panic;
use optionstratlib_core::utils::TimeFrame;
fn outcome(
pnl: Option<Decimal>,
holding_period: usize,
exit_reason: ExitPolicy,
) -> PathOutcome {
PathOutcome {
pnl,
holding_period,
exit_reason,
hit_take_profit: false,
hit_stop_loss: false,
expired: false,
max_premium: dec!(10.0),
min_premium: dec!(5.0),
avg_premium: dec!(7.5),
expiration_premium: None,
}
}
#[test]
fn test_path_statistics_empty_set_all_zero() {
let stats = PathStatistics::from_outcomes(&[]).unwrap();
assert_eq!(stats, PathStatistics::default());
}
#[test]
fn test_path_statistics_single_outcome_zero_std_dev() {
let stats =
PathStatistics::from_outcomes(&[outcome(Some(dec!(12.5)), 4, ExitPolicy::Expiration)])
.unwrap();
assert_eq!(stats.total_paths, 1);
assert_eq!(stats.profitable_count, 1);
assert_eq!(stats.loss_count, 0);
assert_eq!(stats.average_pnl, dec!(12.5));
assert_eq!(stats.median_pnl, dec!(12.5));
assert_eq!(stats.std_dev_pnl, dec!(0.0));
assert_eq!(stats.best_pnl, dec!(12.5));
assert_eq!(stats.worst_pnl, dec!(12.5));
assert_eq!(stats.win_rate, dec!(100.0));
assert_eq!(stats.average_holding_period, dec!(4));
}
#[test]
fn test_path_statistics_hand_built_set_exact() {
let outcomes = [
outcome(Some(dec!(50.0)), 10, ExitPolicy::ProfitPercent(dec!(0.5))),
outcome(Some(dec!(-100.0)), 20, ExitPolicy::LossPercent(dec!(1.0))),
outcome(Some(dec!(25.0)), 15, ExitPolicy::Expiration),
outcome(None, 5, ExitPolicy::Expiration),
];
let stats = PathStatistics::from_outcomes(&outcomes).unwrap();
assert_eq!(stats.total_paths, 4);
assert_eq!(stats.profitable_count, 2);
assert_eq!(stats.loss_count, 1);
assert_eq!(stats.average_pnl, dec!(-6.25));
assert_eq!(stats.median_pnl, dec!(12.5));
assert_eq!(stats.best_pnl, dec!(50.0));
assert_eq!(stats.worst_pnl, dec!(-100.0));
assert_eq!(stats.win_rate, dec!(50.0));
assert_eq!(stats.average_holding_period, dec!(12.5));
let variance = d_div(dec!(12968.75), dec!(3), "test/variance").unwrap();
assert_eq!(stats.std_dev_pnl, d_sqrt(variance, "test").unwrap());
}
#[test]
fn test_path_statistics_odd_count_median_is_middle_value() {
let outcomes = [
outcome(Some(dec!(3.0)), 1, ExitPolicy::Expiration),
outcome(Some(dec!(-1.0)), 1, ExitPolicy::Expiration),
outcome(Some(dec!(7.0)), 1, ExitPolicy::Expiration),
];
let stats = PathStatistics::from_outcomes(&outcomes).unwrap();
assert_eq!(stats.median_pnl, dec!(3.0));
assert_eq!(stats.average_pnl, dec!(3.0));
assert_eq!(stats.std_dev_pnl, dec!(4.0));
}
#[test]
fn test_path_statistics_sum_overflow_is_reported() {
let outcomes = [
outcome(Some(Decimal::MAX), 1, ExitPolicy::Expiration),
outcome(Some(Decimal::MAX), 1, ExitPolicy::Expiration),
];
assert!(matches!(
PathStatistics::from_outcomes(&outcomes),
Err(SimulationError::Decimal(_))
));
}
#[test]
fn test_div_unrounded_reports_division_by_zero() {
assert!(matches!(
div_unrounded(dec!(1), Decimal::ZERO, "test"),
Err(SimulationError::InvalidParameters { .. })
));
}
struct LastMinusFirst;
impl PathEvaluator<Positive, Positive> for LastMinusFirst {
type Outcome = PathOutcome;
type Error = SimulationError;
fn evaluate_path(
&self,
walk: &RandomWalk<Positive, Positive>,
_exit: &ExitPolicy,
) -> Result<PathOutcome, SimulationError> {
let steps = walk.get_steps();
let first = steps
.first()
.ok_or_else(|| SimulationError::invalid_parameters("empty walk"))?;
let last = steps
.last()
.ok_or_else(|| SimulationError::invalid_parameters("empty walk"))?;
let pnl = d_sub(
last.y.positive()?.to_dec(),
first.y.positive()?.to_dec(),
"test",
)?;
Ok(PathOutcome {
pnl: Some(pnl),
holding_period: steps.len() - 1,
exit_reason: ExitPolicy::Expiration,
expired: true,
..PathOutcome::default()
})
}
}
#[derive(Clone)]
struct TestWalker;
impl WalkTypeAble<Positive, Positive> for TestWalker {}
fn historical_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,
}
}
#[test]
fn test_evaluate_paths_simulation_only_fixture_summarises() {
let params = historical_params(vec![
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(110.0),
]);
let sim = Simulator::new("fixture".to_string(), 3, ¶ms, generator_positive).unwrap();
let outcomes = evaluate_paths(&LastMinusFirst, &sim, &ExitPolicy::Expiration).unwrap();
assert_eq!(outcomes.len(), 3);
for outcome in &outcomes {
assert_eq!(outcome.pnl, Some(dec!(10.0)));
assert_eq!(outcome.holding_period, 2);
assert!(outcome.expired);
}
let stats = PathStatistics::from_outcomes(&outcomes).unwrap();
assert_eq!(stats.total_paths, 3);
assert_eq!(stats.profitable_count, 3);
assert_eq!(stats.average_pnl, dec!(10.0));
assert_eq!(stats.median_pnl, dec!(10.0));
assert_eq!(stats.std_dev_pnl, dec!(0.0));
assert_eq!(stats.win_rate, dec!(100.0));
assert_eq!(stats.average_holding_period, dec!(2));
}
struct AlwaysFails;
impl PathEvaluator<Positive, Positive> for AlwaysFails {
type Outcome = ();
type Error = SimulationError;
fn evaluate_path(
&self,
_walk: &RandomWalk<Positive, Positive>,
_exit: &ExitPolicy,
) -> Result<(), SimulationError> {
Err(SimulationError::invalid_parameters("fixture failure"))
}
}
#[test]
fn test_evaluate_paths_propagates_first_error() {
let params = historical_params(vec![Positive::HUNDRED, pos_or_panic!(101.0)]);
let sim = Simulator::new("fixture".to_string(), 2, ¶ms, generator_positive).unwrap();
assert!(matches!(
evaluate_paths(&AlwaysFails, &sim, &ExitPolicy::Expiration),
Err(SimulationError::InvalidParameters { .. })
));
}
}