1use 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#[derive(Debug, Clone)]
37pub struct SimulationStats {
38 profitable_closes: usize,
40 loss_closes: usize,
42 expired_trades: usize,
44 exit_reasons: HashMap<ExitPolicy, usize>,
46 results: Vec<SimulationResult>,
48 outcomes: Vec<PathOutcome>,
51}
52
53#[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 #[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 pub fn update(&mut self, result: SimulationResult) -> Result<(), BacktestError> {
112 self.fold(PathOutcome::from(&result))?;
113 self.results.push(result);
115 Ok(())
116 }
117
118 pub fn update_outcome(&mut self, outcome: &PathOutcome) -> Result<(), BacktestError> {
129 self.fold(outcome.clone())
130 }
131
132 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 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 #[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 #[must_use]
187 #[inline]
188 pub fn total_simulations(&self) -> usize {
189 self.outcomes.len()
190 }
191
192 #[must_use]
194 #[inline]
195 pub fn profitable_closes(&self) -> usize {
196 self.profitable_closes
197 }
198
199 #[must_use]
201 #[inline]
202 pub fn loss_closes(&self) -> usize {
203 self.loss_closes
204 }
205
206 #[must_use]
208 #[inline]
209 pub fn expired_trades(&self) -> usize {
210 self.expired_trades
211 }
212
213 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 #[must_use]
234 #[inline]
235 pub fn exit_reasons(&self) -> &HashMap<ExitPolicy, usize> {
236 &self.exit_reasons
237 }
238
239 #[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 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 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 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 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)); 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)); 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 #[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)); stats.update(take_profit(dec!(50.0), 30)).unwrap();
532 assert_eq!(statistics_of(&stats).average_holding_period, dec!(20)); }
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 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 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 #[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 #[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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}