use crate::backtesting::results::SimulationResult;
use crate::error::BacktestError;
use optionstratlib_core::model::decimal::d_sum_iter;
use optionstratlib_simulation::simulation::{ExitPolicy, PathOutcome, PathStatistics};
use rust_decimal::Decimal;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct SimulationStats {
profitable_closes: usize,
loss_closes: usize,
expired_trades: usize,
exit_reasons: HashMap<ExitPolicy, usize>,
results: Vec<SimulationResult>,
outcomes: Vec<PathOutcome>,
}
#[inline]
fn checked_increment(current: usize, counter: &'static str) -> Result<usize, BacktestError> {
current
.checked_add(1)
.ok_or_else(|| BacktestError::counter_overflow(counter))
}
impl Default for SimulationStats {
fn default() -> Self {
Self::new()
}
}
impl SimulationStats {
#[must_use]
pub fn new() -> Self {
Self {
profitable_closes: 0,
loss_closes: 0,
expired_trades: 0,
exit_reasons: HashMap::new(),
results: Vec::new(),
outcomes: Vec::new(),
}
}
pub fn update(&mut self, result: SimulationResult) -> Result<(), BacktestError> {
self.fold(PathOutcome::from(&result))?;
self.results.push(result);
Ok(())
}
pub fn update_outcome(&mut self, outcome: &PathOutcome) -> Result<(), BacktestError> {
self.fold(outcome.clone())
}
fn fold(&mut self, outcome: PathOutcome) -> Result<(), BacktestError> {
let mut profitable_closes = self.profitable_closes;
let mut loss_closes = self.loss_closes;
let mut expired_trades = self.expired_trades;
if outcome.hit_take_profit {
profitable_closes = checked_increment(profitable_closes, "profitable_closes")?;
} else if outcome.hit_stop_loss {
loss_closes = checked_increment(loss_closes, "loss_closes")?;
} else if outcome.expired {
expired_trades = checked_increment(expired_trades, "expired_trades")?;
}
let exit_reason_count = checked_increment(
self.exit_reasons
.get(&outcome.exit_reason)
.copied()
.unwrap_or(0),
"exit_reasons",
)?;
self.profitable_closes = profitable_closes;
self.loss_closes = loss_closes;
self.expired_trades = expired_trades;
self.exit_reasons
.insert(outcome.exit_reason.clone(), exit_reason_count);
self.outcomes.push(outcome);
Ok(())
}
#[must_use = "the statistics are the only product of this call"]
pub fn statistics(&self) -> Result<PathStatistics, BacktestError> {
Ok(PathStatistics::from_outcomes(&self.outcomes)?)
}
#[must_use]
#[inline]
pub fn total_simulations(&self) -> usize {
self.outcomes.len()
}
#[must_use]
#[inline]
pub fn profitable_closes(&self) -> usize {
self.profitable_closes
}
#[must_use]
#[inline]
pub fn loss_closes(&self) -> usize {
self.loss_closes
}
#[must_use]
#[inline]
pub fn expired_trades(&self) -> usize {
self.expired_trades
}
pub fn total_pnl(&self) -> Result<Decimal, BacktestError> {
Ok(d_sum_iter(
self.outcomes
.iter()
.map(|outcome| outcome.pnl.unwrap_or_default()),
"backtesting::stats::total_pnl",
)?)
}
#[must_use]
#[inline]
pub fn exit_reasons(&self) -> &HashMap<ExitPolicy, usize> {
&self.exit_reasons
}
#[must_use]
#[inline]
pub fn results(&self) -> &[SimulationResult] {
&self.results
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backtesting::results::SimulationStatsResult;
use optionstratlib_analytics::pnl::PnL;
use rust_decimal_macros::dec;
use chrono::Utc;
use optionstratlib_core::pos_or_panic;
use std::collections::HashMap;
fn create_test_result(
pnl_value: Decimal,
holding_period: usize,
hit_take_profit: bool,
hit_stop_loss: bool,
expired: bool,
exit_reason: ExitPolicy,
) -> SimulationResult {
SimulationResult {
simulation_count: 1,
risk_metrics: None,
final_equity_percentiles: HashMap::new(),
max_premium: dec!(100.0),
min_premium: dec!(50.0),
avg_premium: dec!(75.0),
hit_take_profit,
hit_stop_loss,
expired,
expiration_premium: if expired { Some(dec!(50.0)) } else { None },
pnl: PnL::new(
Some(pnl_value),
None,
pos_or_panic!(10.0),
pos_or_panic!(5.0),
Utc::now(),
),
holding_period,
exit_reason,
}
}
fn take_profit(pnl: Decimal, holding_period: usize) -> SimulationResult {
create_test_result(
pnl,
holding_period,
true,
false,
false,
ExitPolicy::ProfitPercent(dec!(0.5)),
)
}
fn stop_loss(pnl: Decimal, holding_period: usize) -> SimulationResult {
create_test_result(
pnl,
holding_period,
false,
true,
false,
ExitPolicy::LossPercent(dec!(1.0)),
)
}
fn statistics_of(stats: &SimulationStats) -> PathStatistics {
match stats.statistics() {
Ok(statistics) => statistics,
Err(error) => panic!("the outcomes fit: {error}"),
}
}
fn total_pnl_of(stats: &SimulationStats) -> Decimal {
match stats.total_pnl() {
Ok(total) => total,
Err(error) => panic!("the total fits: {error}"),
}
}
fn accumulate(results: &[SimulationResult]) -> Result<SimulationStats, BacktestError> {
let mut stats = SimulationStats::new();
for result in results {
stats.update(result.clone())?;
}
Ok(stats)
}
fn assert_untouched(stats: &SimulationStats, before: &SimulationStats) {
assert_eq!(stats.profitable_closes, before.profitable_closes);
assert_eq!(stats.loss_closes, before.loss_closes);
assert_eq!(stats.expired_trades, before.expired_trades);
assert_eq!(stats.exit_reasons, before.exit_reasons);
assert_eq!(stats.results.len(), before.results.len());
assert_eq!(stats.outcomes, before.outcomes);
}
#[test]
fn test_update_outcome_drives_counters_without_storing_a_result() {
let mut stats = SimulationStats::new();
let outcome = PathOutcome {
pnl: Some(dec!(40.0)),
holding_period: 8,
exit_reason: ExitPolicy::ProfitPercent(dec!(0.5)),
hit_take_profit: true,
..PathOutcome::default()
};
stats.update_outcome(&outcome).unwrap();
stats
.update_outcome(&PathOutcome {
pnl: None,
holding_period: 2,
exit_reason: ExitPolicy::Expiration,
expired: true,
..PathOutcome::default()
})
.unwrap();
assert_eq!(stats.total_simulations(), 2);
assert_eq!(stats.profitable_closes, 1);
assert_eq!(stats.expired_trades, 1);
assert_eq!(total_pnl_of(&stats), dec!(40.0));
let statistics = statistics_of(&stats);
assert_eq!(statistics.best_pnl, dec!(40.0));
assert_eq!(statistics.worst_pnl, Decimal::ZERO);
assert_eq!(statistics.average_holding_period, dec!(5));
assert_eq!(stats.exit_reasons.len(), 2);
assert!(stats.results.is_empty());
}
#[test]
fn test_new_creates_default_stats() {
let stats = SimulationStats::new();
assert_eq!(stats.total_simulations(), 0);
assert_eq!(stats.profitable_closes, 0);
assert_eq!(stats.loss_closes, 0);
assert_eq!(stats.expired_trades, 0);
assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
assert!(stats.exit_reasons.is_empty());
assert!(stats.results.is_empty());
assert!(stats.outcomes.is_empty());
}
#[test]
fn test_default_trait() {
let stats = SimulationStats::default();
assert_eq!(stats.total_simulations(), 0);
assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
}
#[test]
fn test_update_with_profitable_trade() {
let mut stats = SimulationStats::new();
stats.update(take_profit(dec!(50.0), 10)).unwrap();
assert_eq!(stats.total_simulations(), 1);
assert_eq!(stats.profitable_closes, 1);
assert_eq!(stats.loss_closes, 0);
assert_eq!(stats.expired_trades, 0);
assert_eq!(total_pnl_of(&stats), dec!(50.0));
let statistics = statistics_of(&stats);
assert_eq!(statistics.best_pnl, dec!(50.0));
assert_eq!(statistics.average_holding_period, dec!(10));
assert_eq!(stats.results.len(), 1);
}
#[test]
fn test_update_with_loss_trade() {
let mut stats = SimulationStats::new();
stats.update(stop_loss(dec!(-100.0), 15)).unwrap();
assert_eq!(stats.total_simulations(), 1);
assert_eq!(stats.profitable_closes, 0);
assert_eq!(stats.loss_closes, 1);
assert_eq!(stats.expired_trades, 0);
assert_eq!(total_pnl_of(&stats), dec!(-100.0));
let statistics = statistics_of(&stats);
assert_eq!(statistics.worst_pnl, dec!(-100.0));
assert_eq!(statistics.average_holding_period, dec!(15));
}
#[test]
fn test_update_with_expired_trade() {
let mut stats = SimulationStats::new();
let result = create_test_result(dec!(25.0), 20, false, false, true, ExitPolicy::Expiration);
stats.update(result).unwrap();
assert_eq!(stats.total_simulations(), 1);
assert_eq!(stats.profitable_closes, 0);
assert_eq!(stats.loss_closes, 0);
assert_eq!(stats.expired_trades, 1);
assert_eq!(total_pnl_of(&stats), dec!(25.0));
assert_eq!(statistics_of(&stats).average_holding_period, dec!(20));
}
#[test]
fn test_update_multiple_trades() {
let mut stats = SimulationStats::new();
stats.update(take_profit(dec!(50.0), 10)).unwrap();
stats.update(stop_loss(dec!(-100.0), 20)).unwrap();
stats
.update(create_test_result(
dec!(25.0),
15,
false,
false,
true,
ExitPolicy::Expiration,
))
.unwrap();
assert_eq!(stats.total_simulations(), 3);
assert_eq!(stats.profitable_closes, 1);
assert_eq!(stats.loss_closes, 1);
assert_eq!(stats.expired_trades, 1);
assert_eq!(total_pnl_of(&stats), dec!(-25.0)); let statistics = statistics_of(&stats);
assert_eq!(statistics.best_pnl, dec!(50.0));
assert_eq!(statistics.worst_pnl, dec!(-100.0));
assert_eq!(statistics.average_holding_period, dec!(15)); assert_eq!(stats.results.len(), 3);
}
#[test]
fn test_update_tracks_exit_reasons() {
let mut stats = SimulationStats::new();
stats.update(take_profit(dec!(50.0), 10)).unwrap();
stats.update(take_profit(dec!(50.0), 10)).unwrap();
stats.update(stop_loss(dec!(-100.0), 20)).unwrap();
assert_eq!(stats.exit_reasons.len(), 2);
assert_eq!(
*stats
.exit_reasons
.get(&ExitPolicy::ProfitPercent(dec!(0.5)))
.unwrap(),
2
);
assert_eq!(
*stats
.exit_reasons
.get(&ExitPolicy::LossPercent(dec!(1.0)))
.unwrap(),
1
);
}
#[test]
fn test_update_with_none_pnl() {
let mut stats = SimulationStats::new();
let mut result = take_profit(dec!(50.0), 10);
result.pnl.realized = None;
stats.update(result).unwrap();
assert_eq!(stats.total_simulations(), 1);
assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
let statistics = statistics_of(&stats);
assert_eq!(statistics.best_pnl, Decimal::ZERO);
assert_eq!(statistics.worst_pnl, Decimal::ZERO);
}
#[test]
fn test_average_holding_period_calculation() {
let mut stats = SimulationStats::new();
stats.update(take_profit(dec!(50.0), 10)).unwrap();
assert_eq!(statistics_of(&stats).average_holding_period, dec!(10));
stats.update(take_profit(dec!(50.0), 20)).unwrap();
assert_eq!(statistics_of(&stats).average_holding_period, dec!(15));
stats.update(take_profit(dec!(50.0), 30)).unwrap();
assert_eq!(statistics_of(&stats).average_holding_period, dec!(20)); }
#[test]
fn test_best_pnl_updates_correctly() {
let mut stats = SimulationStats::new();
stats.update(take_profit(dec!(50.0), 10)).unwrap();
assert_eq!(statistics_of(&stats).best_pnl, dec!(50.0));
stats.update(take_profit(dec!(100.0), 10)).unwrap();
assert_eq!(statistics_of(&stats).best_pnl, dec!(100.0));
stats.update(take_profit(dec!(75.0), 10)).unwrap();
assert_eq!(statistics_of(&stats).best_pnl, dec!(100.0));
}
#[test]
fn test_worst_pnl_updates_correctly() {
let mut stats = SimulationStats::new();
stats.update(stop_loss(dec!(-50.0), 10)).unwrap();
assert_eq!(statistics_of(&stats).worst_pnl, dec!(-50.0));
stats.update(stop_loss(dec!(-100.0), 10)).unwrap();
assert_eq!(statistics_of(&stats).worst_pnl, dec!(-100.0));
stats.update(stop_loss(dec!(-75.0), 10)).unwrap();
assert_eq!(statistics_of(&stats).worst_pnl, dec!(-100.0));
}
#[test]
fn test_clone_trait() {
let mut stats = SimulationStats::new();
stats.update(take_profit(dec!(50.0), 10)).unwrap();
let cloned = stats.clone();
assert_eq!(cloned.total_simulations(), stats.total_simulations());
assert_eq!(cloned.profitable_closes, stats.profitable_closes);
assert_eq!(cloned.outcomes, stats.outcomes);
assert_eq!(cloned.results.len(), stats.results.len());
}
#[test]
fn test_update_with_complex_exit_policy() {
let mut stats = SimulationStats::new();
let complex_exit = ExitPolicy::Or(vec![
ExitPolicy::ProfitPercent(dec!(0.5)),
ExitPolicy::Expiration,
]);
stats
.update(create_test_result(
dec!(50.0),
10,
true,
false,
false,
complex_exit.clone(),
))
.unwrap();
assert_eq!(stats.exit_reasons.len(), 1);
assert_eq!(*stats.exit_reasons.get(&complex_exit).unwrap(), 1);
}
#[test]
fn test_total_pnl_accumulation() {
let mut stats = SimulationStats::new();
stats.update(take_profit(dec!(50.0), 10)).unwrap();
assert_eq!(total_pnl_of(&stats), dec!(50.0));
stats.update(take_profit(dec!(30.0), 10)).unwrap();
assert_eq!(total_pnl_of(&stats), dec!(80.0));
stats.update(stop_loss(dec!(-20.0), 10)).unwrap();
assert_eq!(total_pnl_of(&stats), dec!(60.0));
}
#[test]
fn test_total_pnl_overflow_is_reported_when_read() {
let mut stats = SimulationStats::new();
assert!(
stats
.update(create_test_result(
Decimal::MAX,
5,
true,
false,
false,
ExitPolicy::Expiration,
))
.is_ok()
);
assert!(stats.update(stop_loss(dec!(1.0), 7)).is_ok());
assert_eq!(stats.total_simulations(), 2);
match stats.total_pnl() {
Err(BacktestError::Decimal(error)) => {
assert!(error.to_string().contains("backtesting::stats::total_pnl"));
}
other => panic!("expected a Decimal overflow, got {other:?}"),
}
assert!(matches!(
stats.statistics(),
Err(BacktestError::Simulation(_))
));
}
#[test]
fn test_update_outcome_counter_overflow_reports_backtest_error() {
let take_profit = PathOutcome {
pnl: Some(dec!(1.0)),
exit_reason: ExitPolicy::ProfitPercent(dec!(0.5)),
hit_take_profit: true,
..PathOutcome::default()
};
let stop_loss = PathOutcome {
pnl: Some(dec!(-1.0)),
exit_reason: ExitPolicy::LossPercent(dec!(1.0)),
hit_stop_loss: true,
..PathOutcome::default()
};
let expired = PathOutcome {
pnl: Some(dec!(0.5)),
exit_reason: ExitPolicy::Expiration,
expired: true,
..PathOutcome::default()
};
type Saturate = fn(&mut SimulationStats);
let cases: [(&str, Saturate, &PathOutcome); 4] = [
(
"profitable_closes",
|stats| stats.profitable_closes = usize::MAX,
&take_profit,
),
(
"loss_closes",
|stats| stats.loss_closes = usize::MAX,
&stop_loss,
),
(
"expired_trades",
|stats| stats.expired_trades = usize::MAX,
&expired,
),
(
"exit_reasons",
|stats| {
stats
.exit_reasons
.insert(ExitPolicy::Expiration, usize::MAX);
},
&expired,
),
];
for (counter, saturate, outcome) in cases {
let mut stats = SimulationStats::new();
saturate(&mut stats);
let before = stats.clone();
match stats.update_outcome(outcome) {
Err(BacktestError::CounterOverflow { counter: reported }) => {
assert_eq!(reported, counter);
}
other => panic!("expected `{counter}` to overflow, got {other:?}"),
}
assert_untouched(&stats, &before);
}
}
#[test]
fn test_update_counter_overflow_stores_no_result() {
let mut stats = SimulationStats::new();
stats.profitable_closes = usize::MAX;
let before = stats.clone();
assert!(matches!(
stats.update(take_profit(dec!(50.0), 10)),
Err(BacktestError::CounterOverflow {
counter: "profitable_closes"
})
));
assert_untouched(&stats, &before);
assert_eq!(stats.total_simulations(), 0);
}
fn library_shaped_results() -> Vec<SimulationResult> {
let mut expiry =
create_test_result(dec!(25.0), 15, false, false, true, ExitPolicy::Expiration);
expiry.pnl.unrealized = Some(Decimal::ZERO);
vec![
take_profit(dec!(50.0), 10),
stop_loss(dec!(-100.0), 20),
expiry,
]
}
fn assert_agrees_with_simulation_stats_result(results: Vec<SimulationResult>) {
let stats = match accumulate(&results) {
Ok(stats) => stats,
Err(error) => panic!("the results fit: {error}"),
};
let aggregate = match SimulationStatsResult::from_results(results) {
Ok(aggregate) => aggregate,
Err(error) => panic!("the results fit: {error}"),
};
let statistics = statistics_of(&stats);
assert_eq!(stats.total_simulations(), aggregate.total_simulations);
assert_eq!(statistics.total_paths, aggregate.total_simulations);
assert_eq!(statistics.profitable_count, aggregate.profitable_count);
assert_eq!(statistics.loss_count, aggregate.loss_count);
assert_eq!(statistics.average_pnl, aggregate.average_pnl);
assert_eq!(statistics.median_pnl, aggregate.median_pnl);
assert_eq!(statistics.std_dev_pnl, aggregate.std_dev_pnl);
assert_eq!(statistics.best_pnl, aggregate.best_pnl);
assert_eq!(statistics.worst_pnl, aggregate.worst_pnl);
assert_eq!(statistics.win_rate, aggregate.win_rate);
assert_eq!(
statistics.average_holding_period,
aggregate.average_holding_period
);
}
#[test]
fn test_update_agrees_with_simulation_stats_result() {
let results = library_shaped_results();
assert_agrees_with_simulation_stats_result(results.clone());
let stats = match accumulate(&results) {
Ok(stats) => stats,
Err(error) => panic!("the results fit: {error}"),
};
let statistics = statistics_of(&stats);
assert_eq!(stats.total_simulations(), 3);
assert_eq!(total_pnl_of(&stats), dec!(-25.0));
assert_eq!(statistics.average_pnl, dec!(-25.0) / dec!(3));
assert_eq!(statistics.best_pnl, dec!(50.0));
assert_eq!(statistics.worst_pnl, dec!(-100.0));
assert_eq!(statistics.average_holding_period, dec!(15));
assert_eq!(statistics.profitable_count, 2);
assert_eq!(statistics.loss_count, 1);
assert_eq!(statistics.median_pnl, dec!(25.0));
assert_eq!(statistics.win_rate, dec!(2) / dec!(3) * dec!(100.0));
}
#[test]
fn test_statistics_empty_is_all_zero() {
match SimulationStats::new().statistics() {
Ok(statistics) => assert_eq!(statistics, PathStatistics::default()),
Err(error) => panic!("an empty set has statistics: {error}"),
}
}
#[test]
fn test_statistics_variance_overflow_reports_simulation_error() {
let mut stats = SimulationStats::new();
for pnl in [dec!(1e20), dec!(-1e20)] {
let outcome = PathOutcome {
pnl: Some(pnl),
..PathOutcome::default()
};
assert!(stats.update_outcome(&outcome).is_ok());
}
assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
assert!(matches!(
stats.statistics(),
Err(BacktestError::Simulation(_))
));
}
#[test]
fn test_update_sums_total_pnl_with_unrealized_leg() {
let mut result = take_profit(dec!(10.0), 4);
result.pnl.unrealized = Some(dec!(2.5));
let mut unrealized_only = take_profit(dec!(0.0), 6);
unrealized_only.pnl.realized = None;
unrealized_only.pnl.unrealized = Some(dec!(-3.0));
let results = vec![result, unrealized_only];
assert_agrees_with_simulation_stats_result(results.clone());
let stats = match accumulate(&results) {
Ok(stats) => stats,
Err(error) => panic!("the results fit: {error}"),
};
assert_eq!(total_pnl_of(&stats), dec!(9.5));
let statistics = statistics_of(&stats);
assert_eq!(statistics.best_pnl, dec!(12.5));
assert_eq!(statistics.worst_pnl, dec!(-3.0));
}
#[test]
fn test_update_matches_update_outcome_on_the_adapter_projection() {
let mut via_result = SimulationStats::new();
let mut via_outcome = SimulationStats::new();
let mut results = library_shaped_results();
let mut unrealized = take_profit(dec!(10.0), 4);
unrealized.pnl.unrealized = Some(dec!(2.5));
results.push(unrealized);
for result in results {
let outcome = PathOutcome::from(&result);
assert!(via_outcome.update_outcome(&outcome).is_ok());
assert!(via_result.update(result).is_ok());
}
assert_eq!(via_result.profitable_closes, via_outcome.profitable_closes);
assert_eq!(via_result.loss_closes, via_outcome.loss_closes);
assert_eq!(via_result.expired_trades, via_outcome.expired_trades);
assert_eq!(via_result.exit_reasons, via_outcome.exit_reasons);
assert_eq!(via_result.outcomes, via_outcome.outcomes);
assert_eq!(via_result.results.len(), 4);
assert!(via_outcome.results.is_empty());
}
#[test]
fn test_results_vector_grows() {
let mut stats = SimulationStats::new();
for i in 0..10 {
stats.update(take_profit(dec!(50.0), i)).unwrap();
}
assert_eq!(stats.results.len(), 10);
assert_eq!(stats.total_simulations(), 10);
}
#[test]
fn test_accessors_report_the_folded_state() {
let empty = SimulationStats::new();
assert_eq!(empty.total_simulations(), 0);
assert!(empty.results().is_empty());
assert!(empty.exit_reasons().is_empty());
let mut stats = SimulationStats::new();
stats.update(take_profit(dec!(40.0), 4)).unwrap();
stats.update(stop_loss(dec!(-10.0), 8)).unwrap();
stats
.update(create_test_result(
dec!(5.0),
12,
false,
false,
true,
ExitPolicy::Expiration,
))
.unwrap();
assert_eq!(stats.total_simulations(), 3);
assert_eq!(stats.profitable_closes(), 1);
assert_eq!(stats.loss_closes(), 1);
assert_eq!(stats.expired_trades(), 1);
assert_eq!(total_pnl_of(&stats), dec!(35.0));
assert_eq!(stats.exit_reasons().len(), 3);
assert_eq!(stats.exit_reasons().get(&ExitPolicy::Expiration), Some(&1));
assert_eq!(stats.results().len(), 3);
}
}