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 chrono::Utc;
257 use optionstratlib_core::pos_or_panic;
258 use std::collections::HashMap;
259
260 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 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 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 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)); 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)); 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 #[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)); stats.update(take_profit(dec!(50.0), 30)).unwrap();
533 assert_eq!(statistics_of(&stats).average_holding_period, dec!(20)); }
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 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 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 #[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 #[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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}