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 optionstratlib_core::pos_or_panic;
257    use std::collections::HashMap;
258
259    /// Helper function to create a test SimulationResult
260    fn create_test_result(
261        pnl_value: Decimal,
262        holding_period: usize,
263        hit_take_profit: bool,
264        hit_stop_loss: bool,
265        expired: bool,
266        exit_reason: ExitPolicy,
267    ) -> SimulationResult {
268        SimulationResult {
269            simulation_count: 1,
270            risk_metrics: None,
271            final_equity_percentiles: HashMap::new(),
272            max_premium: dec!(100.0),
273            min_premium: dec!(50.0),
274            avg_premium: dec!(75.0),
275            hit_take_profit,
276            hit_stop_loss,
277            expired,
278            expiration_premium: if expired { Some(dec!(50.0)) } else { None },
279            pnl: PnL::new(
280                Some(pnl_value),
281                None,
282                pos_or_panic!(10.0),
283                pos_or_panic!(5.0),
284                chrono::DateTime::UNIX_EPOCH,
285            ),
286            holding_period,
287            exit_reason,
288        }
289    }
290
291    fn take_profit(pnl: Decimal, holding_period: usize) -> SimulationResult {
292        create_test_result(
293            pnl,
294            holding_period,
295            true,
296            false,
297            false,
298            ExitPolicy::ProfitPercent(dec!(0.5)),
299        )
300    }
301
302    fn stop_loss(pnl: Decimal, holding_period: usize) -> SimulationResult {
303        create_test_result(
304            pnl,
305            holding_period,
306            false,
307            true,
308            false,
309            ExitPolicy::LossPercent(dec!(1.0)),
310        )
311    }
312
313    fn statistics_of(stats: &SimulationStats) -> PathStatistics {
314        match stats.statistics() {
315            Ok(statistics) => statistics,
316            Err(error) => panic!("the outcomes fit: {error}"),
317        }
318    }
319
320    fn total_pnl_of(stats: &SimulationStats) -> Decimal {
321        match stats.total_pnl() {
322            Ok(total) => total,
323            Err(error) => panic!("the total fits: {error}"),
324        }
325    }
326
327    /// Folds `results` into a fresh accumulator, reporting the first rejection.
328    fn accumulate(results: &[SimulationResult]) -> Result<SimulationStats, BacktestError> {
329        let mut stats = SimulationStats::new();
330        for result in results {
331            stats.update(result.clone())?;
332        }
333        Ok(stats)
334    }
335
336    /// Asserts that `stats` is exactly `before`, field by field.
337    fn assert_untouched(stats: &SimulationStats, before: &SimulationStats) {
338        assert_eq!(stats.profitable_closes, before.profitable_closes);
339        assert_eq!(stats.loss_closes, before.loss_closes);
340        assert_eq!(stats.expired_trades, before.expired_trades);
341        assert_eq!(stats.exit_reasons, before.exit_reasons);
342        assert_eq!(stats.results.len(), before.results.len());
343        assert_eq!(stats.outcomes, before.outcomes);
344    }
345
346    #[test]
347    fn test_update_outcome_drives_counters_without_storing_a_result() {
348        let mut stats = SimulationStats::new();
349        let outcome = PathOutcome {
350            pnl: Some(dec!(40.0)),
351            holding_period: 8,
352            exit_reason: ExitPolicy::ProfitPercent(dec!(0.5)),
353            hit_take_profit: true,
354            ..PathOutcome::default()
355        };
356        stats.update_outcome(&outcome).unwrap();
357        stats
358            .update_outcome(&PathOutcome {
359                pnl: None,
360                holding_period: 2,
361                exit_reason: ExitPolicy::Expiration,
362                expired: true,
363                ..PathOutcome::default()
364            })
365            .unwrap();
366
367        assert_eq!(stats.total_simulations(), 2);
368        assert_eq!(stats.profitable_closes, 1);
369        assert_eq!(stats.expired_trades, 1);
370        assert_eq!(total_pnl_of(&stats), dec!(40.0));
371        let statistics = statistics_of(&stats);
372        assert_eq!(statistics.best_pnl, dec!(40.0));
373        // #691 decision 2: the extremes come from `PathStatistics`, which
374        // counts the outcome without a P&L as zero. The running worst used to
375        // skip it and read 40.
376        assert_eq!(statistics.worst_pnl, Decimal::ZERO);
377        assert_eq!(statistics.average_holding_period, dec!(5));
378        assert_eq!(stats.exit_reasons.len(), 2);
379        assert!(stats.results.is_empty());
380    }
381
382    #[test]
383    fn test_new_creates_default_stats() {
384        let stats = SimulationStats::new();
385
386        assert_eq!(stats.total_simulations(), 0);
387        assert_eq!(stats.profitable_closes, 0);
388        assert_eq!(stats.loss_closes, 0);
389        assert_eq!(stats.expired_trades, 0);
390        assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
391        assert!(stats.exit_reasons.is_empty());
392        assert!(stats.results.is_empty());
393        assert!(stats.outcomes.is_empty());
394    }
395
396    #[test]
397    fn test_default_trait() {
398        let stats = SimulationStats::default();
399
400        assert_eq!(stats.total_simulations(), 0);
401        assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
402    }
403
404    #[test]
405    fn test_update_with_profitable_trade() {
406        let mut stats = SimulationStats::new();
407        stats.update(take_profit(dec!(50.0), 10)).unwrap();
408
409        assert_eq!(stats.total_simulations(), 1);
410        assert_eq!(stats.profitable_closes, 1);
411        assert_eq!(stats.loss_closes, 0);
412        assert_eq!(stats.expired_trades, 0);
413        assert_eq!(total_pnl_of(&stats), dec!(50.0));
414        let statistics = statistics_of(&stats);
415        assert_eq!(statistics.best_pnl, dec!(50.0));
416        assert_eq!(statistics.average_holding_period, dec!(10));
417        assert_eq!(stats.results.len(), 1);
418    }
419
420    #[test]
421    fn test_update_with_loss_trade() {
422        let mut stats = SimulationStats::new();
423        stats.update(stop_loss(dec!(-100.0), 15)).unwrap();
424
425        assert_eq!(stats.total_simulations(), 1);
426        assert_eq!(stats.profitable_closes, 0);
427        assert_eq!(stats.loss_closes, 1);
428        assert_eq!(stats.expired_trades, 0);
429        assert_eq!(total_pnl_of(&stats), dec!(-100.0));
430        let statistics = statistics_of(&stats);
431        assert_eq!(statistics.worst_pnl, dec!(-100.0));
432        assert_eq!(statistics.average_holding_period, dec!(15));
433    }
434
435    #[test]
436    fn test_update_with_expired_trade() {
437        let mut stats = SimulationStats::new();
438        let result = create_test_result(dec!(25.0), 20, false, false, true, ExitPolicy::Expiration);
439
440        stats.update(result).unwrap();
441
442        assert_eq!(stats.total_simulations(), 1);
443        assert_eq!(stats.profitable_closes, 0);
444        assert_eq!(stats.loss_closes, 0);
445        assert_eq!(stats.expired_trades, 1);
446        assert_eq!(total_pnl_of(&stats), dec!(25.0));
447        assert_eq!(statistics_of(&stats).average_holding_period, dec!(20));
448    }
449
450    #[test]
451    fn test_update_multiple_trades() {
452        let mut stats = SimulationStats::new();
453        stats.update(take_profit(dec!(50.0), 10)).unwrap();
454        stats.update(stop_loss(dec!(-100.0), 20)).unwrap();
455        stats
456            .update(create_test_result(
457                dec!(25.0),
458                15,
459                false,
460                false,
461                true,
462                ExitPolicy::Expiration,
463            ))
464            .unwrap();
465
466        assert_eq!(stats.total_simulations(), 3);
467        assert_eq!(stats.profitable_closes, 1);
468        assert_eq!(stats.loss_closes, 1);
469        assert_eq!(stats.expired_trades, 1);
470        assert_eq!(total_pnl_of(&stats), dec!(-25.0)); // 50 - 100 + 25
471        let statistics = statistics_of(&stats);
472        assert_eq!(statistics.best_pnl, dec!(50.0));
473        assert_eq!(statistics.worst_pnl, dec!(-100.0));
474        assert_eq!(statistics.average_holding_period, dec!(15)); // (10 + 20 + 15) / 3
475        assert_eq!(stats.results.len(), 3);
476    }
477
478    #[test]
479    fn test_update_tracks_exit_reasons() {
480        let mut stats = SimulationStats::new();
481        stats.update(take_profit(dec!(50.0), 10)).unwrap();
482        stats.update(take_profit(dec!(50.0), 10)).unwrap();
483        stats.update(stop_loss(dec!(-100.0), 20)).unwrap();
484
485        assert_eq!(stats.exit_reasons.len(), 2);
486        assert_eq!(
487            *stats
488                .exit_reasons
489                .get(&ExitPolicy::ProfitPercent(dec!(0.5)))
490                .unwrap(),
491            2
492        );
493        assert_eq!(
494            *stats
495                .exit_reasons
496                .get(&ExitPolicy::LossPercent(dec!(1.0)))
497                .unwrap(),
498            1
499        );
500    }
501
502    /// A result with no P&L at all counts as zero everywhere. #691 decision
503    /// 2 re-baselines the extremes: they come from `PathStatistics`, which
504    /// reads the missing P&L as zero, instead of running values that skipped
505    /// it and stayed at the `Decimal::MIN` / `Decimal::MAX` sentinels.
506    #[test]
507    fn test_update_with_none_pnl() {
508        let mut stats = SimulationStats::new();
509        let mut result = take_profit(dec!(50.0), 10);
510        result.pnl.realized = None;
511
512        stats.update(result).unwrap();
513
514        assert_eq!(stats.total_simulations(), 1);
515        assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
516        let statistics = statistics_of(&stats);
517        assert_eq!(statistics.best_pnl, Decimal::ZERO);
518        assert_eq!(statistics.worst_pnl, Decimal::ZERO);
519    }
520
521    #[test]
522    fn test_average_holding_period_calculation() {
523        let mut stats = SimulationStats::new();
524
525        stats.update(take_profit(dec!(50.0), 10)).unwrap();
526        assert_eq!(statistics_of(&stats).average_holding_period, dec!(10));
527
528        stats.update(take_profit(dec!(50.0), 20)).unwrap();
529        assert_eq!(statistics_of(&stats).average_holding_period, dec!(15)); // (10 + 20) / 2
530
531        stats.update(take_profit(dec!(50.0), 30)).unwrap();
532        assert_eq!(statistics_of(&stats).average_holding_period, dec!(20)); // (10 + 20 + 30) / 3
533    }
534
535    #[test]
536    fn test_best_pnl_updates_correctly() {
537        let mut stats = SimulationStats::new();
538
539        stats.update(take_profit(dec!(50.0), 10)).unwrap();
540        assert_eq!(statistics_of(&stats).best_pnl, dec!(50.0));
541
542        stats.update(take_profit(dec!(100.0), 10)).unwrap();
543        assert_eq!(statistics_of(&stats).best_pnl, dec!(100.0));
544
545        // Lower profit should not update the best
546        stats.update(take_profit(dec!(75.0), 10)).unwrap();
547        assert_eq!(statistics_of(&stats).best_pnl, dec!(100.0));
548    }
549
550    #[test]
551    fn test_worst_pnl_updates_correctly() {
552        let mut stats = SimulationStats::new();
553
554        stats.update(stop_loss(dec!(-50.0), 10)).unwrap();
555        assert_eq!(statistics_of(&stats).worst_pnl, dec!(-50.0));
556
557        stats.update(stop_loss(dec!(-100.0), 10)).unwrap();
558        assert_eq!(statistics_of(&stats).worst_pnl, dec!(-100.0));
559
560        // Smaller loss should not update the worst
561        stats.update(stop_loss(dec!(-75.0), 10)).unwrap();
562        assert_eq!(statistics_of(&stats).worst_pnl, dec!(-100.0));
563    }
564
565    #[test]
566    fn test_clone_trait() {
567        let mut stats = SimulationStats::new();
568        stats.update(take_profit(dec!(50.0), 10)).unwrap();
569
570        let cloned = stats.clone();
571
572        assert_eq!(cloned.total_simulations(), stats.total_simulations());
573        assert_eq!(cloned.profitable_closes, stats.profitable_closes);
574        assert_eq!(cloned.outcomes, stats.outcomes);
575        assert_eq!(cloned.results.len(), stats.results.len());
576    }
577
578    #[test]
579    fn test_update_with_complex_exit_policy() {
580        let mut stats = SimulationStats::new();
581
582        let complex_exit = ExitPolicy::Or(vec![
583            ExitPolicy::ProfitPercent(dec!(0.5)),
584            ExitPolicy::Expiration,
585        ]);
586
587        stats
588            .update(create_test_result(
589                dec!(50.0),
590                10,
591                true,
592                false,
593                false,
594                complex_exit.clone(),
595            ))
596            .unwrap();
597
598        assert_eq!(stats.exit_reasons.len(), 1);
599        assert_eq!(*stats.exit_reasons.get(&complex_exit).unwrap(), 1);
600    }
601
602    #[test]
603    fn test_total_pnl_accumulation() {
604        let mut stats = SimulationStats::new();
605
606        stats.update(take_profit(dec!(50.0), 10)).unwrap();
607        assert_eq!(total_pnl_of(&stats), dec!(50.0));
608
609        stats.update(take_profit(dec!(30.0), 10)).unwrap();
610        assert_eq!(total_pnl_of(&stats), dec!(80.0));
611
612        stats.update(stop_loss(dec!(-20.0), 10)).unwrap();
613        assert_eq!(total_pnl_of(&stats), dec!(60.0));
614    }
615
616    /// No running total is kept (#691 decision 2), so a P&L set whose sum
617    /// leaves the `Decimal` range is accepted and the overflow is reported
618    /// where the sum is taken, with the backtest call-site label, and by
619    /// `statistics`, whose mean needs the same sum.
620    #[test]
621    fn test_total_pnl_overflow_is_reported_when_read() {
622        let mut stats = SimulationStats::new();
623        assert!(
624            stats
625                .update(create_test_result(
626                    Decimal::MAX,
627                    5,
628                    true,
629                    false,
630                    false,
631                    ExitPolicy::Expiration,
632                ))
633                .is_ok()
634        );
635        assert!(stats.update(stop_loss(dec!(1.0), 7)).is_ok());
636        assert_eq!(stats.total_simulations(), 2);
637
638        match stats.total_pnl() {
639            Err(BacktestError::Decimal(error)) => {
640                assert!(error.to_string().contains("backtesting::stats::total_pnl"));
641            }
642            other => panic!("expected a Decimal overflow, got {other:?}"),
643        }
644        assert!(matches!(
645            stats.statistics(),
646            Err(BacktestError::Simulation(_))
647        ));
648    }
649
650    /// Each run counter reports its own name when it cannot advance, and the
651    /// rejected outcome leaves every field as it was.
652    #[test]
653    fn test_update_outcome_counter_overflow_reports_backtest_error() {
654        let take_profit = PathOutcome {
655            pnl: Some(dec!(1.0)),
656            exit_reason: ExitPolicy::ProfitPercent(dec!(0.5)),
657            hit_take_profit: true,
658            ..PathOutcome::default()
659        };
660        let stop_loss = PathOutcome {
661            pnl: Some(dec!(-1.0)),
662            exit_reason: ExitPolicy::LossPercent(dec!(1.0)),
663            hit_stop_loss: true,
664            ..PathOutcome::default()
665        };
666        let expired = PathOutcome {
667            pnl: Some(dec!(0.5)),
668            exit_reason: ExitPolicy::Expiration,
669            expired: true,
670            ..PathOutcome::default()
671        };
672
673        /// Puts one counter at the top of its range.
674        type Saturate = fn(&mut SimulationStats);
675        let cases: [(&str, Saturate, &PathOutcome); 4] = [
676            (
677                "profitable_closes",
678                |stats| stats.profitable_closes = usize::MAX,
679                &take_profit,
680            ),
681            (
682                "loss_closes",
683                |stats| stats.loss_closes = usize::MAX,
684                &stop_loss,
685            ),
686            (
687                "expired_trades",
688                |stats| stats.expired_trades = usize::MAX,
689                &expired,
690            ),
691            (
692                "exit_reasons",
693                |stats| {
694                    stats
695                        .exit_reasons
696                        .insert(ExitPolicy::Expiration, usize::MAX);
697                },
698                &expired,
699            ),
700        ];
701
702        for (counter, saturate, outcome) in cases {
703            let mut stats = SimulationStats::new();
704            saturate(&mut stats);
705            let before = stats.clone();
706            match stats.update_outcome(outcome) {
707                Err(BacktestError::CounterOverflow { counter: reported }) => {
708                    assert_eq!(reported, counter);
709                }
710                other => panic!("expected `{counter}` to overflow, got {other:?}"),
711            }
712            assert_untouched(&stats, &before);
713        }
714    }
715
716    /// `update` reports the counter overflow too, and stores neither the
717    /// outcome nor the result.
718    #[test]
719    fn test_update_counter_overflow_stores_no_result() {
720        let mut stats = SimulationStats::new();
721        stats.profitable_closes = usize::MAX;
722        let before = stats.clone();
723        assert!(matches!(
724            stats.update(take_profit(dec!(50.0), 10)),
725            Err(BacktestError::CounterOverflow {
726                counter: "profitable_closes"
727            })
728        ));
729        assert_untouched(&stats, &before);
730        assert_eq!(stats.total_simulations(), 0);
731    }
732
733    /// Shapes of the P&L the library builds: an early exit (realized, no
734    /// unrealized leg) and an expiry (realized plus a zero unrealized leg,
735    /// as `Position::calculate_pnl_at_expiration` returns it).
736    fn library_shaped_results() -> Vec<SimulationResult> {
737        let mut expiry =
738            create_test_result(dec!(25.0), 15, false, false, true, ExitPolicy::Expiration);
739        expiry.pnl.unrealized = Some(Decimal::ZERO);
740        vec![
741            take_profit(dec!(50.0), 10),
742            stop_loss(dec!(-100.0), 20),
743            expiry,
744        ]
745    }
746
747    /// Asserts that the accumulator over `results` reports the
748    /// `SimulationStatsResult` aggregate over the same results.
749    fn assert_agrees_with_simulation_stats_result(results: Vec<SimulationResult>) {
750        let stats = match accumulate(&results) {
751            Ok(stats) => stats,
752            Err(error) => panic!("the results fit: {error}"),
753        };
754        let aggregate = match SimulationStatsResult::from_results(results) {
755            Ok(aggregate) => aggregate,
756            Err(error) => panic!("the results fit: {error}"),
757        };
758        let statistics = statistics_of(&stats);
759
760        assert_eq!(stats.total_simulations(), aggregate.total_simulations);
761        assert_eq!(statistics.total_paths, aggregate.total_simulations);
762        assert_eq!(statistics.profitable_count, aggregate.profitable_count);
763        assert_eq!(statistics.loss_count, aggregate.loss_count);
764        assert_eq!(statistics.average_pnl, aggregate.average_pnl);
765        assert_eq!(statistics.median_pnl, aggregate.median_pnl);
766        assert_eq!(statistics.std_dev_pnl, aggregate.std_dev_pnl);
767        assert_eq!(statistics.best_pnl, aggregate.best_pnl);
768        assert_eq!(statistics.worst_pnl, aggregate.worst_pnl);
769        assert_eq!(statistics.win_rate, aggregate.win_rate);
770        assert_eq!(
771            statistics.average_holding_period,
772            aggregate.average_holding_period
773        );
774    }
775
776    /// On results the library builds, the statistics are exactly the
777    /// `PathStatistics` aggregate `SimulationStatsResult` reports.
778    #[test]
779    fn test_update_agrees_with_simulation_stats_result() {
780        let results = library_shaped_results();
781        assert_agrees_with_simulation_stats_result(results.clone());
782
783        let stats = match accumulate(&results) {
784            Ok(stats) => stats,
785            Err(error) => panic!("the results fit: {error}"),
786        };
787        let statistics = statistics_of(&stats);
788        assert_eq!(stats.total_simulations(), 3);
789        assert_eq!(total_pnl_of(&stats), dec!(-25.0));
790        assert_eq!(statistics.average_pnl, dec!(-25.0) / dec!(3));
791        assert_eq!(statistics.best_pnl, dec!(50.0));
792        assert_eq!(statistics.worst_pnl, dec!(-100.0));
793        assert_eq!(statistics.average_holding_period, dec!(15));
794        assert_eq!(statistics.profitable_count, 2);
795        assert_eq!(statistics.loss_count, 1);
796        assert_eq!(statistics.median_pnl, dec!(25.0));
797        assert_eq!(statistics.win_rate, dec!(2) / dec!(3) * dec!(100.0));
798    }
799
800    /// No outcome yet: the statistics are the all-zero empty summary.
801    #[test]
802    fn test_statistics_empty_is_all_zero() {
803        match SimulationStats::new().statistics() {
804            Ok(statistics) => assert_eq!(statistics, PathStatistics::default()),
805            Err(error) => panic!("an empty set has statistics: {error}"),
806        }
807    }
808
809    /// `update_outcome` feeds the statistics as `update` does, and a P&L
810    /// set whose variance leaves the `Decimal` range is reported as the
811    /// simulation error `PathStatistics::from_outcomes` raises.
812    #[test]
813    fn test_statistics_variance_overflow_reports_simulation_error() {
814        let mut stats = SimulationStats::new();
815        for pnl in [dec!(1e20), dec!(-1e20)] {
816            let outcome = PathOutcome {
817                pnl: Some(pnl),
818                ..PathOutcome::default()
819            };
820            assert!(stats.update_outcome(&outcome).is_ok());
821        }
822        assert_eq!(total_pnl_of(&stats), Decimal::ZERO);
823        assert!(matches!(
824            stats.statistics(),
825            Err(BacktestError::Simulation(_))
826        ));
827    }
828
829    /// A result whose P&L reports a non-zero unrealized leg counts with that
830    /// leg, as in `SimulationStatsResult`. #691 decision 1 re-baselines this
831    /// case: the accumulator used to sum the realized leg alone (10 here)
832    /// while the aggregate summed the total (12.5).
833    #[test]
834    fn test_update_sums_total_pnl_with_unrealized_leg() {
835        let mut result = take_profit(dec!(10.0), 4);
836        result.pnl.unrealized = Some(dec!(2.5));
837        let mut unrealized_only = take_profit(dec!(0.0), 6);
838        unrealized_only.pnl.realized = None;
839        unrealized_only.pnl.unrealized = Some(dec!(-3.0));
840        let results = vec![result, unrealized_only];
841
842        assert_agrees_with_simulation_stats_result(results.clone());
843
844        let stats = match accumulate(&results) {
845            Ok(stats) => stats,
846            Err(error) => panic!("the results fit: {error}"),
847        };
848        assert_eq!(total_pnl_of(&stats), dec!(9.5));
849        let statistics = statistics_of(&stats);
850        assert_eq!(statistics.best_pnl, dec!(12.5));
851        assert_eq!(statistics.worst_pnl, dec!(-3.0));
852    }
853
854    /// `update` folds exactly the adapter's projection: it leaves the
855    /// accumulator as `update_outcome` on that projection does, apart from
856    /// the stored result.
857    #[test]
858    fn test_update_matches_update_outcome_on_the_adapter_projection() {
859        let mut via_result = SimulationStats::new();
860        let mut via_outcome = SimulationStats::new();
861        let mut results = library_shaped_results();
862        let mut unrealized = take_profit(dec!(10.0), 4);
863        unrealized.pnl.unrealized = Some(dec!(2.5));
864        results.push(unrealized);
865        for result in results {
866            let outcome = PathOutcome::from(&result);
867            assert!(via_outcome.update_outcome(&outcome).is_ok());
868            assert!(via_result.update(result).is_ok());
869        }
870        assert_eq!(via_result.profitable_closes, via_outcome.profitable_closes);
871        assert_eq!(via_result.loss_closes, via_outcome.loss_closes);
872        assert_eq!(via_result.expired_trades, via_outcome.expired_trades);
873        assert_eq!(via_result.exit_reasons, via_outcome.exit_reasons);
874        assert_eq!(via_result.outcomes, via_outcome.outcomes);
875        assert_eq!(via_result.results.len(), 4);
876        assert!(via_outcome.results.is_empty());
877    }
878
879    #[test]
880    fn test_results_vector_grows() {
881        let mut stats = SimulationStats::new();
882
883        for i in 0..10 {
884            stats.update(take_profit(dec!(50.0), i)).unwrap();
885        }
886
887        assert_eq!(stats.results.len(), 10);
888        assert_eq!(stats.total_simulations(), 10);
889    }
890
891    /// The read accessors report exactly the folded counters and totals, so
892    /// a renderer outside this crate sees what `update` accumulated.
893    #[test]
894    fn test_accessors_report_the_folded_state() {
895        let empty = SimulationStats::new();
896        assert_eq!(empty.total_simulations(), 0);
897        assert!(empty.results().is_empty());
898        assert!(empty.exit_reasons().is_empty());
899
900        let mut stats = SimulationStats::new();
901        stats.update(take_profit(dec!(40.0), 4)).unwrap();
902        stats.update(stop_loss(dec!(-10.0), 8)).unwrap();
903        stats
904            .update(create_test_result(
905                dec!(5.0),
906                12,
907                false,
908                false,
909                true,
910                ExitPolicy::Expiration,
911            ))
912            .unwrap();
913
914        assert_eq!(stats.total_simulations(), 3);
915        assert_eq!(stats.profitable_closes(), 1);
916        assert_eq!(stats.loss_closes(), 1);
917        assert_eq!(stats.expired_trades(), 1);
918        assert_eq!(total_pnl_of(&stats), dec!(35.0));
919        assert_eq!(stats.exit_reasons().len(), 3);
920        assert_eq!(stats.exit_reasons().get(&ExitPolicy::Expiration), Some(&1));
921        assert_eq!(stats.results().len(), 3);
922    }
923}