Skip to main content

optionstratlib_backtest/backtesting/
stats.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 8/11/25
5******************************************************************************/
6use crate::backtesting::results::SimulationResult;
7use crate::error::BacktestError;
8use optionstratlib_core::model::decimal::d_sum_iter;
9use optionstratlib_simulation::simulation::{ExitPolicy, PathOutcome, PathStatistics};
10use rust_decimal::Decimal;
11use std::collections::HashMap;
12
13/// Running statistics over the runs of a strategy simulation.
14///
15/// Results are folded in one at a time through [`SimulationStats::update`]
16/// (or [`SimulationStats::update_outcome`] for a bare [`PathOutcome`]). Each
17/// result is projected onto its [`PathOutcome`] through the backtest
18/// adapter, `From<&SimulationResult> for PathOutcome`, so the P&L of a run
19/// is [`PnL::total_pnl`](optionstratlib_analytics::pnl::PnL::total_pnl), the
20/// same figure [`crate::backtesting::results::SimulationStatsResult`]
21/// reports.
22///
23/// The accumulator counts how each run closed (take profit, stop loss,
24/// expiry) and by which exit policy. Every P&L and holding-period figure
25/// (mean, median, standard deviation, best, worst, average holding period)
26/// comes from [`SimulationStats::statistics`], that is from
27/// [`PathStatistics::from_outcomes`] over the stored outcomes; no second,
28/// running copy of them is kept.
29///
30/// # Memory
31///
32/// Every outcome folded in is stored, and every result folded in through
33/// `update` is stored as well, so memory grows linearly with the number of
34/// runs. The outcomes cannot be rebuilt from the results on demand: an
35/// outcome folded in through `update_outcome` has no result behind it.
36#[derive(Debug, Clone)]
37pub struct SimulationStats {
38    /// Number of runs that closed on their take-profit condition
39    profitable_closes: usize,
40    /// Number of runs that closed on their stop-loss condition
41    loss_closes: usize,
42    /// Number of runs that expired without hitting another exit condition
43    expired_trades: usize,
44    /// Distribution of exit policies that triggered exits
45    exit_reasons: HashMap<ExitPolicy, usize>,
46    /// Individual simulation results
47    results: Vec<SimulationResult>,
48    /// Every outcome folded in, in arrival order, for
49    /// [`SimulationStats::statistics`]
50    outcomes: Vec<PathOutcome>,
51}
52
53/// Adds one to a run counter, reporting the overflow instead of wrapping.
54///
55/// A `usize` counter cannot realistically reach its maximum here, but
56/// [`SimulationStats::update`] promises to apply a result whole or not at
57/// all, and that promise is only keepable if every arithmetic step in it can
58/// be answered before the first field is written.
59#[inline]
60fn checked_increment(current: usize, counter: &'static str) -> Result<usize, BacktestError> {
61    current
62        .checked_add(1)
63        .ok_or_else(|| BacktestError::counter_overflow(counter))
64}
65
66impl Default for SimulationStats {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl SimulationStats {
73    /// Creates a new instance of `SimulationStats` with default values.
74    ///
75    /// # Returns
76    ///
77    /// A new `SimulationStats` instance with all counters set to zero.
78    #[must_use]
79    pub fn new() -> Self {
80        Self {
81            profitable_closes: 0,
82            loss_closes: 0,
83            expired_trades: 0,
84            exit_reasons: HashMap::new(),
85            results: Vec::new(),
86            outcomes: Vec::new(),
87        }
88    }
89
90    /// Updates statistics with results from a single simulation run.
91    ///
92    /// The counters are driven by the backtest adapter's `From<&SimulationResult>
93    /// for PathOutcome` view of the result, whose P&L is
94    /// [`PnL::total_pnl`](optionstratlib_analytics::pnl::PnL::total_pnl)
95    /// (realized plus unrealized); the result itself is then stored and read
96    /// back through [`SimulationStats::results`]. A result whose P&L reports
97    /// a non-zero unrealized leg therefore counts with that leg included, as
98    /// in [`crate::backtesting::results::SimulationStatsResult`].
99    ///
100    /// # Parameters
101    ///
102    /// * `result` - The simulation result containing all metrics
103    ///
104    /// # Errors
105    ///
106    /// Returns [`BacktestError::CounterOverflow`] when a run counter
107    /// overflows. The accumulator is left untouched when that happens: every
108    /// fallible step is resolved before the first field is written, so a
109    /// rejected result cannot leave one counter advanced for a run the
110    /// others never saw.
111    pub fn update(&mut self, result: SimulationResult) -> Result<(), BacktestError> {
112        self.fold(PathOutcome::from(&result))?;
113        // The counters have committed; storing the result cannot fail.
114        self.results.push(result);
115        Ok(())
116    }
117
118    /// Folds one generic path outcome into the counters.
119    ///
120    /// This is the strategy-agnostic half of [`SimulationStats::update`]: it
121    /// advances the outcome counters and the exit-reason distribution, and
122    /// stores the outcome for [`SimulationStats::statistics`].
123    ///
124    /// # Errors
125    ///
126    /// Same contract as [`SimulationStats::update`]: the accumulator is
127    /// left untouched when a counter overflows.
128    pub fn update_outcome(&mut self, outcome: &PathOutcome) -> Result<(), BacktestError> {
129        self.fold(outcome.clone())
130    }
131
132    /// Folds an owned outcome: the shared body of `update` and
133    /// `update_outcome`, taking the outcome by value so `update` needs no
134    /// copy of the projection it just built.
135    fn fold(&mut self, outcome: PathOutcome) -> Result<(), BacktestError> {
136        let mut profitable_closes = self.profitable_closes;
137        let mut loss_closes = self.loss_closes;
138        let mut expired_trades = self.expired_trades;
139        if outcome.hit_take_profit {
140            profitable_closes = checked_increment(profitable_closes, "profitable_closes")?;
141        } else if outcome.hit_stop_loss {
142            loss_closes = checked_increment(loss_closes, "loss_closes")?;
143        } else if outcome.expired {
144            expired_trades = checked_increment(expired_trades, "expired_trades")?;
145        }
146
147        let exit_reason_count = checked_increment(
148            self.exit_reasons
149                .get(&outcome.exit_reason)
150                .copied()
151                .unwrap_or(0),
152            "exit_reasons",
153        )?;
154
155        // Every fallible step above has succeeded, so the writes below commit
156        // the outcome as a whole.
157        self.profitable_closes = profitable_closes;
158        self.loss_closes = loss_closes;
159        self.expired_trades = expired_trades;
160        self.exit_reasons
161            .insert(outcome.exit_reason.clone(), exit_reason_count);
162        self.outcomes.push(outcome);
163        Ok(())
164    }
165
166    /// Aggregate statistics over every outcome folded in so far.
167    ///
168    /// The figures are [`PathStatistics::from_outcomes`] over the outcomes
169    /// [`SimulationStats::update`] and [`SimulationStats::update_outcome`]
170    /// accepted, so they follow the same formulas, and for results the same
171    /// P&L, as [`crate::backtesting::results::SimulationStatsResult`]. An
172    /// outcome without a P&L counts as zero, in the mean and in the best and
173    /// worst figures alike.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`BacktestError::Simulation`] carrying the error of
178    /// [`PathStatistics::from_outcomes`], for example when the variance of a
179    /// P&L set far outside any traded size overflows `Decimal`.
180    #[must_use = "the statistics are the only product of this call"]
181    pub fn statistics(&self) -> Result<PathStatistics, BacktestError> {
182        Ok(PathStatistics::from_outcomes(&self.outcomes)?)
183    }
184
185    /// Number of outcomes folded in.
186    #[must_use]
187    #[inline]
188    pub fn total_simulations(&self) -> usize {
189        self.outcomes.len()
190    }
191
192    /// Number of runs that closed on their take-profit condition.
193    #[must_use]
194    #[inline]
195    pub fn profitable_closes(&self) -> usize {
196        self.profitable_closes
197    }
198
199    /// Number of runs that closed on their stop-loss condition.
200    #[must_use]
201    #[inline]
202    pub fn loss_closes(&self) -> usize {
203        self.loss_closes
204    }
205
206    /// Number of runs that reached expiration without another exit.
207    #[must_use]
208    #[inline]
209    pub fn expired_trades(&self) -> usize {
210        self.expired_trades
211    }
212
213    /// Sum of the P&L of every run, in the strategy's currency.
214    ///
215    /// Summed over the stored outcomes, so it is the same P&L the
216    /// [`SimulationStats::statistics`] mean is taken over; a run without a
217    /// P&L counts as zero.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`BacktestError::Decimal`] when the sum leaves the
222    /// representable `Decimal` range.
223    pub fn total_pnl(&self) -> Result<Decimal, BacktestError> {
224        Ok(d_sum_iter(
225            self.outcomes
226                .iter()
227                .map(|outcome| outcome.pnl.unwrap_or_default()),
228            "backtesting::stats::total_pnl",
229        )?)
230    }
231
232    /// How many runs each exit policy closed.
233    #[must_use]
234    #[inline]
235    pub fn exit_reasons(&self) -> &HashMap<ExitPolicy, usize> {
236        &self.exit_reasons
237    }
238
239    /// The results folded in through [`SimulationStats::update`], in
240    /// arrival order. Outcomes folded in through
241    /// [`SimulationStats::update_outcome`] alone are not stored.
242    #[must_use]
243    #[inline]
244    pub fn results(&self) -> &[SimulationResult] {
245        &self.results
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::backtesting::results::SimulationStatsResult;
253    use optionstratlib_analytics::pnl::PnL;
254    use rust_decimal_macros::dec;
255
256    use chrono::Utc;
257    use optionstratlib_core::pos_or_panic;
258    use std::collections::HashMap;
259
260    /// Helper function to create a test SimulationResult
261    fn create_test_result(
262        pnl_value: Decimal,
263        holding_period: usize,
264        hit_take_profit: bool,
265        hit_stop_loss: bool,
266        expired: bool,
267        exit_reason: ExitPolicy,
268    ) -> SimulationResult {
269        SimulationResult {
270            simulation_count: 1,
271            risk_metrics: None,
272            final_equity_percentiles: HashMap::new(),
273            max_premium: dec!(100.0),
274            min_premium: dec!(50.0),
275            avg_premium: dec!(75.0),
276            hit_take_profit,
277            hit_stop_loss,
278            expired,
279            expiration_premium: if expired { Some(dec!(50.0)) } else { None },
280            pnl: PnL::new(
281                Some(pnl_value),
282                None,
283                pos_or_panic!(10.0),
284                pos_or_panic!(5.0),
285                Utc::now(),
286            ),
287            holding_period,
288            exit_reason,
289        }
290    }
291
292    fn take_profit(pnl: Decimal, holding_period: usize) -> SimulationResult {
293        create_test_result(
294            pnl,
295            holding_period,
296            true,
297            false,
298            false,
299            ExitPolicy::ProfitPercent(dec!(0.5)),
300        )
301    }
302
303    fn stop_loss(pnl: Decimal, holding_period: usize) -> SimulationResult {
304        create_test_result(
305            pnl,
306            holding_period,
307            false,
308            true,
309            false,
310            ExitPolicy::LossPercent(dec!(1.0)),
311        )
312    }
313
314    fn statistics_of(stats: &SimulationStats) -> PathStatistics {
315        match stats.statistics() {
316            Ok(statistics) => statistics,
317            Err(error) => panic!("the outcomes fit: {error}"),
318        }
319    }
320
321    fn total_pnl_of(stats: &SimulationStats) -> Decimal {
322        match stats.total_pnl() {
323            Ok(total) => total,
324            Err(error) => panic!("the total fits: {error}"),
325        }
326    }
327
328    /// Folds `results` into a fresh accumulator, reporting the first rejection.
329    fn accumulate(results: &[SimulationResult]) -> Result<SimulationStats, BacktestError> {
330        let mut stats = SimulationStats::new();
331        for result in results {
332            stats.update(result.clone())?;
333        }
334        Ok(stats)
335    }
336
337    /// Asserts that `stats` is exactly `before`, field by field.
338    fn assert_untouched(stats: &SimulationStats, before: &SimulationStats) {
339        assert_eq!(stats.profitable_closes, before.profitable_closes);
340        assert_eq!(stats.loss_closes, before.loss_closes);
341        assert_eq!(stats.expired_trades, before.expired_trades);
342        assert_eq!(stats.exit_reasons, before.exit_reasons);
343        assert_eq!(stats.results.len(), before.results.len());
344        assert_eq!(stats.outcomes, before.outcomes);
345    }
346
347    #[test]
348    fn test_update_outcome_drives_counters_without_storing_a_result() {
349        let mut stats = SimulationStats::new();
350        let outcome = PathOutcome {
351            pnl: Some(dec!(40.0)),
352            holding_period: 8,
353            exit_reason: ExitPolicy::ProfitPercent(dec!(0.5)),
354            hit_take_profit: true,
355            ..PathOutcome::default()
356        };
357        stats.update_outcome(&outcome).unwrap();
358        stats
359            .update_outcome(&PathOutcome {
360                pnl: None,
361                holding_period: 2,
362                exit_reason: ExitPolicy::Expiration,
363                expired: true,
364                ..PathOutcome::default()
365            })
366            .unwrap();
367
368        assert_eq!(stats.total_simulations(), 2);
369        assert_eq!(stats.profitable_closes, 1);
370        assert_eq!(stats.expired_trades, 1);
371        assert_eq!(total_pnl_of(&stats), dec!(40.0));
372        let statistics = statistics_of(&stats);
373        assert_eq!(statistics.best_pnl, dec!(40.0));
374        // #691 decision 2: the extremes come from `PathStatistics`, which
375        // counts the outcome without a P&L as zero. The running worst used to
376        // skip it and read 40.
377        assert_eq!(statistics.worst_pnl, Decimal::ZERO);
378        assert_eq!(statistics.average_holding_period, dec!(5));
379        assert_eq!(stats.exit_reasons.len(), 2);
380        assert!(stats.results.is_empty());
381    }
382
383    #[test]
384    fn test_new_creates_default_stats() {
385        let stats = SimulationStats::new();
386
387        assert_eq!(stats.total_simulations(), 0);
388        assert_eq!(stats.profitable_closes, 0);
389        assert_eq!(stats.loss_closes, 0);
390        assert_eq!(stats.expired_trades, 0);
391        assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
392        assert!(stats.exit_reasons.is_empty());
393        assert!(stats.results.is_empty());
394        assert!(stats.outcomes.is_empty());
395    }
396
397    #[test]
398    fn test_default_trait() {
399        let stats = SimulationStats::default();
400
401        assert_eq!(stats.total_simulations(), 0);
402        assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
403    }
404
405    #[test]
406    fn test_update_with_profitable_trade() {
407        let mut stats = SimulationStats::new();
408        stats.update(take_profit(dec!(50.0), 10)).unwrap();
409
410        assert_eq!(stats.total_simulations(), 1);
411        assert_eq!(stats.profitable_closes, 1);
412        assert_eq!(stats.loss_closes, 0);
413        assert_eq!(stats.expired_trades, 0);
414        assert_eq!(total_pnl_of(&stats), dec!(50.0));
415        let statistics = statistics_of(&stats);
416        assert_eq!(statistics.best_pnl, dec!(50.0));
417        assert_eq!(statistics.average_holding_period, dec!(10));
418        assert_eq!(stats.results.len(), 1);
419    }
420
421    #[test]
422    fn test_update_with_loss_trade() {
423        let mut stats = SimulationStats::new();
424        stats.update(stop_loss(dec!(-100.0), 15)).unwrap();
425
426        assert_eq!(stats.total_simulations(), 1);
427        assert_eq!(stats.profitable_closes, 0);
428        assert_eq!(stats.loss_closes, 1);
429        assert_eq!(stats.expired_trades, 0);
430        assert_eq!(total_pnl_of(&stats), dec!(-100.0));
431        let statistics = statistics_of(&stats);
432        assert_eq!(statistics.worst_pnl, dec!(-100.0));
433        assert_eq!(statistics.average_holding_period, dec!(15));
434    }
435
436    #[test]
437    fn test_update_with_expired_trade() {
438        let mut stats = SimulationStats::new();
439        let result = create_test_result(dec!(25.0), 20, false, false, true, ExitPolicy::Expiration);
440
441        stats.update(result).unwrap();
442
443        assert_eq!(stats.total_simulations(), 1);
444        assert_eq!(stats.profitable_closes, 0);
445        assert_eq!(stats.loss_closes, 0);
446        assert_eq!(stats.expired_trades, 1);
447        assert_eq!(total_pnl_of(&stats), dec!(25.0));
448        assert_eq!(statistics_of(&stats).average_holding_period, dec!(20));
449    }
450
451    #[test]
452    fn test_update_multiple_trades() {
453        let mut stats = SimulationStats::new();
454        stats.update(take_profit(dec!(50.0), 10)).unwrap();
455        stats.update(stop_loss(dec!(-100.0), 20)).unwrap();
456        stats
457            .update(create_test_result(
458                dec!(25.0),
459                15,
460                false,
461                false,
462                true,
463                ExitPolicy::Expiration,
464            ))
465            .unwrap();
466
467        assert_eq!(stats.total_simulations(), 3);
468        assert_eq!(stats.profitable_closes, 1);
469        assert_eq!(stats.loss_closes, 1);
470        assert_eq!(stats.expired_trades, 1);
471        assert_eq!(total_pnl_of(&stats), dec!(-25.0)); // 50 - 100 + 25
472        let statistics = statistics_of(&stats);
473        assert_eq!(statistics.best_pnl, dec!(50.0));
474        assert_eq!(statistics.worst_pnl, dec!(-100.0));
475        assert_eq!(statistics.average_holding_period, dec!(15)); // (10 + 20 + 15) / 3
476        assert_eq!(stats.results.len(), 3);
477    }
478
479    #[test]
480    fn test_update_tracks_exit_reasons() {
481        let mut stats = SimulationStats::new();
482        stats.update(take_profit(dec!(50.0), 10)).unwrap();
483        stats.update(take_profit(dec!(50.0), 10)).unwrap();
484        stats.update(stop_loss(dec!(-100.0), 20)).unwrap();
485
486        assert_eq!(stats.exit_reasons.len(), 2);
487        assert_eq!(
488            *stats
489                .exit_reasons
490                .get(&ExitPolicy::ProfitPercent(dec!(0.5)))
491                .unwrap(),
492            2
493        );
494        assert_eq!(
495            *stats
496                .exit_reasons
497                .get(&ExitPolicy::LossPercent(dec!(1.0)))
498                .unwrap(),
499            1
500        );
501    }
502
503    /// A result with no P&L at all counts as zero everywhere. #691 decision
504    /// 2 re-baselines the extremes: they come from `PathStatistics`, which
505    /// reads the missing P&L as zero, instead of running values that skipped
506    /// it and stayed at the `Decimal::MIN` / `Decimal::MAX` sentinels.
507    #[test]
508    fn test_update_with_none_pnl() {
509        let mut stats = SimulationStats::new();
510        let mut result = take_profit(dec!(50.0), 10);
511        result.pnl.realized = None;
512
513        stats.update(result).unwrap();
514
515        assert_eq!(stats.total_simulations(), 1);
516        assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
517        let statistics = statistics_of(&stats);
518        assert_eq!(statistics.best_pnl, Decimal::ZERO);
519        assert_eq!(statistics.worst_pnl, Decimal::ZERO);
520    }
521
522    #[test]
523    fn test_average_holding_period_calculation() {
524        let mut stats = SimulationStats::new();
525
526        stats.update(take_profit(dec!(50.0), 10)).unwrap();
527        assert_eq!(statistics_of(&stats).average_holding_period, dec!(10));
528
529        stats.update(take_profit(dec!(50.0), 20)).unwrap();
530        assert_eq!(statistics_of(&stats).average_holding_period, dec!(15)); // (10 + 20) / 2
531
532        stats.update(take_profit(dec!(50.0), 30)).unwrap();
533        assert_eq!(statistics_of(&stats).average_holding_period, dec!(20)); // (10 + 20 + 30) / 3
534    }
535
536    #[test]
537    fn test_best_pnl_updates_correctly() {
538        let mut stats = SimulationStats::new();
539
540        stats.update(take_profit(dec!(50.0), 10)).unwrap();
541        assert_eq!(statistics_of(&stats).best_pnl, dec!(50.0));
542
543        stats.update(take_profit(dec!(100.0), 10)).unwrap();
544        assert_eq!(statistics_of(&stats).best_pnl, dec!(100.0));
545
546        // Lower profit should not update the best
547        stats.update(take_profit(dec!(75.0), 10)).unwrap();
548        assert_eq!(statistics_of(&stats).best_pnl, dec!(100.0));
549    }
550
551    #[test]
552    fn test_worst_pnl_updates_correctly() {
553        let mut stats = SimulationStats::new();
554
555        stats.update(stop_loss(dec!(-50.0), 10)).unwrap();
556        assert_eq!(statistics_of(&stats).worst_pnl, dec!(-50.0));
557
558        stats.update(stop_loss(dec!(-100.0), 10)).unwrap();
559        assert_eq!(statistics_of(&stats).worst_pnl, dec!(-100.0));
560
561        // Smaller loss should not update the worst
562        stats.update(stop_loss(dec!(-75.0), 10)).unwrap();
563        assert_eq!(statistics_of(&stats).worst_pnl, dec!(-100.0));
564    }
565
566    #[test]
567    fn test_clone_trait() {
568        let mut stats = SimulationStats::new();
569        stats.update(take_profit(dec!(50.0), 10)).unwrap();
570
571        let cloned = stats.clone();
572
573        assert_eq!(cloned.total_simulations(), stats.total_simulations());
574        assert_eq!(cloned.profitable_closes, stats.profitable_closes);
575        assert_eq!(cloned.outcomes, stats.outcomes);
576        assert_eq!(cloned.results.len(), stats.results.len());
577    }
578
579    #[test]
580    fn test_update_with_complex_exit_policy() {
581        let mut stats = SimulationStats::new();
582
583        let complex_exit = ExitPolicy::Or(vec![
584            ExitPolicy::ProfitPercent(dec!(0.5)),
585            ExitPolicy::Expiration,
586        ]);
587
588        stats
589            .update(create_test_result(
590                dec!(50.0),
591                10,
592                true,
593                false,
594                false,
595                complex_exit.clone(),
596            ))
597            .unwrap();
598
599        assert_eq!(stats.exit_reasons.len(), 1);
600        assert_eq!(*stats.exit_reasons.get(&complex_exit).unwrap(), 1);
601    }
602
603    #[test]
604    fn test_total_pnl_accumulation() {
605        let mut stats = SimulationStats::new();
606
607        stats.update(take_profit(dec!(50.0), 10)).unwrap();
608        assert_eq!(total_pnl_of(&stats), dec!(50.0));
609
610        stats.update(take_profit(dec!(30.0), 10)).unwrap();
611        assert_eq!(total_pnl_of(&stats), dec!(80.0));
612
613        stats.update(stop_loss(dec!(-20.0), 10)).unwrap();
614        assert_eq!(total_pnl_of(&stats), dec!(60.0));
615    }
616
617    /// No running total is kept (#691 decision 2), so a P&L set whose sum
618    /// leaves the `Decimal` range is accepted and the overflow is reported
619    /// where the sum is taken, with the backtest call-site label, and by
620    /// `statistics`, whose mean needs the same sum.
621    #[test]
622    fn test_total_pnl_overflow_is_reported_when_read() {
623        let mut stats = SimulationStats::new();
624        assert!(
625            stats
626                .update(create_test_result(
627                    Decimal::MAX,
628                    5,
629                    true,
630                    false,
631                    false,
632                    ExitPolicy::Expiration,
633                ))
634                .is_ok()
635        );
636        assert!(stats.update(stop_loss(dec!(1.0), 7)).is_ok());
637        assert_eq!(stats.total_simulations(), 2);
638
639        match stats.total_pnl() {
640            Err(BacktestError::Decimal(error)) => {
641                assert!(error.to_string().contains("backtesting::stats::total_pnl"));
642            }
643            other => panic!("expected a Decimal overflow, got {other:?}"),
644        }
645        assert!(matches!(
646            stats.statistics(),
647            Err(BacktestError::Simulation(_))
648        ));
649    }
650
651    /// Each run counter reports its own name when it cannot advance, and the
652    /// rejected outcome leaves every field as it was.
653    #[test]
654    fn test_update_outcome_counter_overflow_reports_backtest_error() {
655        let take_profit = PathOutcome {
656            pnl: Some(dec!(1.0)),
657            exit_reason: ExitPolicy::ProfitPercent(dec!(0.5)),
658            hit_take_profit: true,
659            ..PathOutcome::default()
660        };
661        let stop_loss = PathOutcome {
662            pnl: Some(dec!(-1.0)),
663            exit_reason: ExitPolicy::LossPercent(dec!(1.0)),
664            hit_stop_loss: true,
665            ..PathOutcome::default()
666        };
667        let expired = PathOutcome {
668            pnl: Some(dec!(0.5)),
669            exit_reason: ExitPolicy::Expiration,
670            expired: true,
671            ..PathOutcome::default()
672        };
673
674        /// Puts one counter at the top of its range.
675        type Saturate = fn(&mut SimulationStats);
676        let cases: [(&str, Saturate, &PathOutcome); 4] = [
677            (
678                "profitable_closes",
679                |stats| stats.profitable_closes = usize::MAX,
680                &take_profit,
681            ),
682            (
683                "loss_closes",
684                |stats| stats.loss_closes = usize::MAX,
685                &stop_loss,
686            ),
687            (
688                "expired_trades",
689                |stats| stats.expired_trades = usize::MAX,
690                &expired,
691            ),
692            (
693                "exit_reasons",
694                |stats| {
695                    stats
696                        .exit_reasons
697                        .insert(ExitPolicy::Expiration, usize::MAX);
698                },
699                &expired,
700            ),
701        ];
702
703        for (counter, saturate, outcome) in cases {
704            let mut stats = SimulationStats::new();
705            saturate(&mut stats);
706            let before = stats.clone();
707            match stats.update_outcome(outcome) {
708                Err(BacktestError::CounterOverflow { counter: reported }) => {
709                    assert_eq!(reported, counter);
710                }
711                other => panic!("expected `{counter}` to overflow, got {other:?}"),
712            }
713            assert_untouched(&stats, &before);
714        }
715    }
716
717    /// `update` reports the counter overflow too, and stores neither the
718    /// outcome nor the result.
719    #[test]
720    fn test_update_counter_overflow_stores_no_result() {
721        let mut stats = SimulationStats::new();
722        stats.profitable_closes = usize::MAX;
723        let before = stats.clone();
724        assert!(matches!(
725            stats.update(take_profit(dec!(50.0), 10)),
726            Err(BacktestError::CounterOverflow {
727                counter: "profitable_closes"
728            })
729        ));
730        assert_untouched(&stats, &before);
731        assert_eq!(stats.total_simulations(), 0);
732    }
733
734    /// Shapes of the P&L the library builds: an early exit (realized, no
735    /// unrealized leg) and an expiry (realized plus a zero unrealized leg,
736    /// as `Position::calculate_pnl_at_expiration` returns it).
737    fn library_shaped_results() -> Vec<SimulationResult> {
738        let mut expiry =
739            create_test_result(dec!(25.0), 15, false, false, true, ExitPolicy::Expiration);
740        expiry.pnl.unrealized = Some(Decimal::ZERO);
741        vec![
742            take_profit(dec!(50.0), 10),
743            stop_loss(dec!(-100.0), 20),
744            expiry,
745        ]
746    }
747
748    /// Asserts that the accumulator over `results` reports the
749    /// `SimulationStatsResult` aggregate over the same results.
750    fn assert_agrees_with_simulation_stats_result(results: Vec<SimulationResult>) {
751        let stats = match accumulate(&results) {
752            Ok(stats) => stats,
753            Err(error) => panic!("the results fit: {error}"),
754        };
755        let aggregate = match SimulationStatsResult::from_results(results) {
756            Ok(aggregate) => aggregate,
757            Err(error) => panic!("the results fit: {error}"),
758        };
759        let statistics = statistics_of(&stats);
760
761        assert_eq!(stats.total_simulations(), aggregate.total_simulations);
762        assert_eq!(statistics.total_paths, aggregate.total_simulations);
763        assert_eq!(statistics.profitable_count, aggregate.profitable_count);
764        assert_eq!(statistics.loss_count, aggregate.loss_count);
765        assert_eq!(statistics.average_pnl, aggregate.average_pnl);
766        assert_eq!(statistics.median_pnl, aggregate.median_pnl);
767        assert_eq!(statistics.std_dev_pnl, aggregate.std_dev_pnl);
768        assert_eq!(statistics.best_pnl, aggregate.best_pnl);
769        assert_eq!(statistics.worst_pnl, aggregate.worst_pnl);
770        assert_eq!(statistics.win_rate, aggregate.win_rate);
771        assert_eq!(
772            statistics.average_holding_period,
773            aggregate.average_holding_period
774        );
775    }
776
777    /// On results the library builds, the statistics are exactly the
778    /// `PathStatistics` aggregate `SimulationStatsResult` reports.
779    #[test]
780    fn test_update_agrees_with_simulation_stats_result() {
781        let results = library_shaped_results();
782        assert_agrees_with_simulation_stats_result(results.clone());
783
784        let stats = match accumulate(&results) {
785            Ok(stats) => stats,
786            Err(error) => panic!("the results fit: {error}"),
787        };
788        let statistics = statistics_of(&stats);
789        assert_eq!(stats.total_simulations(), 3);
790        assert_eq!(total_pnl_of(&stats), dec!(-25.0));
791        assert_eq!(statistics.average_pnl, dec!(-25.0) / dec!(3));
792        assert_eq!(statistics.best_pnl, dec!(50.0));
793        assert_eq!(statistics.worst_pnl, dec!(-100.0));
794        assert_eq!(statistics.average_holding_period, dec!(15));
795        assert_eq!(statistics.profitable_count, 2);
796        assert_eq!(statistics.loss_count, 1);
797        assert_eq!(statistics.median_pnl, dec!(25.0));
798        assert_eq!(statistics.win_rate, dec!(2) / dec!(3) * dec!(100.0));
799    }
800
801    /// No outcome yet: the statistics are the all-zero empty summary.
802    #[test]
803    fn test_statistics_empty_is_all_zero() {
804        match SimulationStats::new().statistics() {
805            Ok(statistics) => assert_eq!(statistics, PathStatistics::default()),
806            Err(error) => panic!("an empty set has statistics: {error}"),
807        }
808    }
809
810    /// `update_outcome` feeds the statistics as `update` does, and a P&L
811    /// set whose variance leaves the `Decimal` range is reported as the
812    /// simulation error `PathStatistics::from_outcomes` raises.
813    #[test]
814    fn test_statistics_variance_overflow_reports_simulation_error() {
815        let mut stats = SimulationStats::new();
816        for pnl in [dec!(1e20), dec!(-1e20)] {
817            let outcome = PathOutcome {
818                pnl: Some(pnl),
819                ..PathOutcome::default()
820            };
821            assert!(stats.update_outcome(&outcome).is_ok());
822        }
823        assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
824        assert!(matches!(
825            stats.statistics(),
826            Err(BacktestError::Simulation(_))
827        ));
828    }
829
830    /// A result whose P&L reports a non-zero unrealized leg counts with that
831    /// leg, as in `SimulationStatsResult`. #691 decision 1 re-baselines this
832    /// case: the accumulator used to sum the realized leg alone (10 here)
833    /// while the aggregate summed the total (12.5).
834    #[test]
835    fn test_update_sums_total_pnl_with_unrealized_leg() {
836        let mut result = take_profit(dec!(10.0), 4);
837        result.pnl.unrealized = Some(dec!(2.5));
838        let mut unrealized_only = take_profit(dec!(0.0), 6);
839        unrealized_only.pnl.realized = None;
840        unrealized_only.pnl.unrealized = Some(dec!(-3.0));
841        let results = vec![result, unrealized_only];
842
843        assert_agrees_with_simulation_stats_result(results.clone());
844
845        let stats = match accumulate(&results) {
846            Ok(stats) => stats,
847            Err(error) => panic!("the results fit: {error}"),
848        };
849        assert_eq!(total_pnl_of(&stats), dec!(9.5));
850        let statistics = statistics_of(&stats);
851        assert_eq!(statistics.best_pnl, dec!(12.5));
852        assert_eq!(statistics.worst_pnl, dec!(-3.0));
853    }
854
855    /// `update` folds exactly the adapter's projection: it leaves the
856    /// accumulator as `update_outcome` on that projection does, apart from
857    /// the stored result.
858    #[test]
859    fn test_update_matches_update_outcome_on_the_adapter_projection() {
860        let mut via_result = SimulationStats::new();
861        let mut via_outcome = SimulationStats::new();
862        let mut results = library_shaped_results();
863        let mut unrealized = take_profit(dec!(10.0), 4);
864        unrealized.pnl.unrealized = Some(dec!(2.5));
865        results.push(unrealized);
866        for result in results {
867            let outcome = PathOutcome::from(&result);
868            assert!(via_outcome.update_outcome(&outcome).is_ok());
869            assert!(via_result.update(result).is_ok());
870        }
871        assert_eq!(via_result.profitable_closes, via_outcome.profitable_closes);
872        assert_eq!(via_result.loss_closes, via_outcome.loss_closes);
873        assert_eq!(via_result.expired_trades, via_outcome.expired_trades);
874        assert_eq!(via_result.exit_reasons, via_outcome.exit_reasons);
875        assert_eq!(via_result.outcomes, via_outcome.outcomes);
876        assert_eq!(via_result.results.len(), 4);
877        assert!(via_outcome.results.is_empty());
878    }
879
880    #[test]
881    fn test_results_vector_grows() {
882        let mut stats = SimulationStats::new();
883
884        for i in 0..10 {
885            stats.update(take_profit(dec!(50.0), i)).unwrap();
886        }
887
888        assert_eq!(stats.results.len(), 10);
889        assert_eq!(stats.total_simulations(), 10);
890    }
891
892    /// The read accessors report exactly the folded counters and totals, so
893    /// a renderer outside this crate sees what `update` accumulated.
894    #[test]
895    fn test_accessors_report_the_folded_state() {
896        let empty = SimulationStats::new();
897        assert_eq!(empty.total_simulations(), 0);
898        assert!(empty.results().is_empty());
899        assert!(empty.exit_reasons().is_empty());
900
901        let mut stats = SimulationStats::new();
902        stats.update(take_profit(dec!(40.0), 4)).unwrap();
903        stats.update(stop_loss(dec!(-10.0), 8)).unwrap();
904        stats
905            .update(create_test_result(
906                dec!(5.0),
907                12,
908                false,
909                false,
910                true,
911                ExitPolicy::Expiration,
912            ))
913            .unwrap();
914
915        assert_eq!(stats.total_simulations(), 3);
916        assert_eq!(stats.profitable_closes(), 1);
917        assert_eq!(stats.loss_closes(), 1);
918        assert_eq!(stats.expired_trades(), 1);
919        assert_eq!(total_pnl_of(&stats), dec!(35.0));
920        assert_eq!(stats.exit_reasons().len(), 3);
921        assert_eq!(stats.exit_reasons().get(&ExitPolicy::Expiration), Some(&1));
922        assert_eq!(stats.results().len(), 3);
923    }
924}