1use serde::{Deserialize, Serialize};
20
21use super::result::BacktestResult;
22
23#[non_exhaustive]
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub enum MonteCarloMethod {
32 #[default]
40 IidShuffle,
41
42 BlockBootstrap {
50 block_size: usize,
52 },
53
54 StationaryBootstrap {
61 mean_block_size: usize,
63 },
64
65 Parametric,
73}
74
75#[non_exhaustive]
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct MonteCarloConfig {
81 pub num_simulations: usize,
86
87 pub seed: Option<u64>,
92
93 pub method: MonteCarloMethod,
95}
96
97impl Default for MonteCarloConfig {
98 fn default() -> Self {
99 Self {
100 num_simulations: 1_000,
101 seed: None,
102 method: MonteCarloMethod::IidShuffle,
103 }
104 }
105}
106
107impl MonteCarloConfig {
108 pub fn new() -> Self {
110 Self::default()
111 }
112
113 pub fn num_simulations(mut self, n: usize) -> Self {
115 self.num_simulations = n;
116 self
117 }
118
119 pub fn seed(mut self, seed: u64) -> Self {
121 self.seed = Some(seed);
122 self
123 }
124
125 pub fn method(mut self, method: MonteCarloMethod) -> Self {
127 self.method = method;
128 self
129 }
130
131 pub fn run(&self, result: &BacktestResult) -> MonteCarloResult {
143 let initial_capital = result.initial_capital;
144 let trade_returns: Vec<f64> = result.trades.iter().map(|t| t.return_pct / 100.0).collect();
145
146 if trade_returns.len() < 2 {
147 let obs_return = result.metrics.total_return_pct;
149 let obs_dd = result.metrics.max_drawdown_pct;
150 let obs_sharpe = result.metrics.sharpe_ratio;
151 let obs_pf = result.metrics.profit_factor;
152 let trivial = |v: f64| PercentileStats {
153 p5: v,
154 p25: v,
155 p50: v,
156 p75: v,
157 p95: v,
158 mean: v,
159 };
160 return MonteCarloResult {
161 num_simulations: self.num_simulations,
162 method: self.method.clone(),
163 total_return: trivial(obs_return),
164 max_drawdown: trivial(obs_dd),
165 sharpe_ratio: trivial(obs_sharpe),
166 profit_factor: trivial(obs_pf),
167 };
168 }
169
170 let seed = self.seed.unwrap_or(12345);
171 let mut rng = Xorshift64::new(seed);
172
173 let position_size = result.config.position_size_pct;
174 let num_bars = result.equity_curve.len().saturating_sub(1) as f64;
175 let years = if result.config.bars_per_year > 0.0 {
176 num_bars / result.config.bars_per_year
177 } else {
178 0.0
179 };
180 let periods_per_year = if years > 0.0 {
181 trade_returns.len() as f64 / years
182 } else {
183 trade_returns.len().max(1) as f64
184 };
185
186 let mut sim_returns: Vec<f64> = Vec::with_capacity(self.num_simulations);
187 let mut sim_drawdowns: Vec<f64> = Vec::with_capacity(self.num_simulations);
188 let mut sim_sharpes: Vec<f64> = Vec::with_capacity(self.num_simulations);
189 let mut sim_pfs: Vec<f64> = Vec::with_capacity(self.num_simulations);
190
191 let mut sim_buf: Vec<f64> = vec![0.0; trade_returns.len()];
193
194 for _ in 0..self.num_simulations {
195 match &self.method {
196 MonteCarloMethod::IidShuffle => {
197 sim_buf.copy_from_slice(&trade_returns);
198 fisher_yates_shuffle(&mut sim_buf, &mut rng);
199 }
200 MonteCarloMethod::BlockBootstrap { block_size } => {
201 block_bootstrap_into(&trade_returns, *block_size, &mut rng, &mut sim_buf);
202 }
203 MonteCarloMethod::StationaryBootstrap { mean_block_size } => {
204 stationary_bootstrap_into(
205 &trade_returns,
206 *mean_block_size,
207 &mut rng,
208 &mut sim_buf,
209 );
210 }
211 MonteCarloMethod::Parametric => {
212 parametric_sample_into(&trade_returns, &mut rng, &mut sim_buf);
213 }
214 }
215
216 let (equity_curve, final_equity) =
218 build_equity_curve(&sim_buf, initial_capital, position_size);
219
220 let total_return = ((final_equity / initial_capital) - 1.0) * 100.0;
221 let max_dd = compute_max_drawdown(&equity_curve);
222 let sharpe = compute_sharpe(&equity_curve, periods_per_year);
223 let pf = compute_profit_factor(&sim_buf);
224
225 sim_returns.push(total_return);
226 sim_drawdowns.push(max_dd);
227 sim_sharpes.push(sharpe);
228 sim_pfs.push(pf);
229 }
230
231 MonteCarloResult {
232 num_simulations: self.num_simulations,
233 method: self.method.clone(),
234 total_return: PercentileStats::from_sorted(&mut sim_returns),
235 max_drawdown: PercentileStats::from_sorted(&mut sim_drawdowns),
236 sharpe_ratio: PercentileStats::from_sorted(&mut sim_sharpes),
237 profit_factor: PercentileStats::from_sorted(&mut sim_pfs),
238 }
239 }
240}
241
242#[non_exhaustive]
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct PercentileStats {
248 pub p5: f64,
250 pub p25: f64,
252 pub p50: f64,
254 pub p75: f64,
256 pub p95: f64,
258 pub mean: f64,
260}
261
262impl PercentileStats {
263 fn from_sorted(values: &mut [f64]) -> Self {
265 values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
266 let n = values.len();
267
268 let percentile = |p: f64| {
269 let idx = ((p / 100.0) * (n - 1) as f64).round() as usize;
270 values[idx.min(n - 1)]
271 };
272
273 let mean = values.iter().sum::<f64>() / n as f64;
274
275 Self {
276 p5: percentile(5.0),
277 p25: percentile(25.0),
278 p50: percentile(50.0),
279 p75: percentile(75.0),
280 p95: percentile(95.0),
281 mean,
282 }
283 }
284}
285
286#[non_exhaustive]
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct MonteCarloResult {
292 pub num_simulations: usize,
294
295 pub method: MonteCarloMethod,
297
298 pub total_return: PercentileStats,
300
301 pub max_drawdown: PercentileStats,
303
304 pub sharpe_ratio: PercentileStats,
316
317 pub profit_factor: PercentileStats,
319}
320
321pub(crate) struct Xorshift64 {
328 state: u64,
329}
330
331impl Xorshift64 {
332 pub(crate) fn new(seed: u64) -> Self {
333 Self {
335 state: if seed == 0 { 1 } else { seed },
336 }
337 }
338
339 pub(crate) fn next(&mut self) -> u64 {
341 let mut x = self.state;
342 x ^= x << 13;
343 x ^= x >> 7;
344 x ^= x << 17;
345 self.state = x;
346 x
347 }
348
349 pub(crate) fn next_usize(&mut self, n: usize) -> usize {
355 let n64 = n as u64;
356 let threshold = u64::MAX - (u64::MAX % n64);
359 loop {
360 let x = self.next();
361 if x < threshold {
362 return (x % n64) as usize;
363 }
364 }
365 }
366
367 pub(crate) fn next_f64_positive(&mut self) -> f64 {
372 ((self.next() >> 11) + 1) as f64 * (1.0 / (1u64 << 53) as f64)
375 }
376}
377
378fn fisher_yates_shuffle(slice: &mut [f64], rng: &mut Xorshift64) {
382 for i in (1..slice.len()).rev() {
383 let j = rng.next_usize(i + 1);
384 slice.swap(i, j);
385 }
386}
387
388fn block_bootstrap_into(trades: &[f64], block_size: usize, rng: &mut Xorshift64, out: &mut [f64]) {
393 let n = trades.len();
394 let block_size = block_size.max(1);
395 let mut filled = 0;
396 while filled < n {
397 let start = rng.next_usize(n);
398 let take = block_size.min(n - filled);
399 for i in 0..take {
400 out[filled + i] = trades[(start + i) % n];
401 }
402 filled += take;
403 }
404}
405
406fn stationary_bootstrap_into(
413 trades: &[f64],
414 mean_block_size: usize,
415 rng: &mut Xorshift64,
416 out: &mut [f64],
417) {
418 let n = trades.len();
419 let mean_block_size = mean_block_size.max(1);
420 let mut pos = rng.next_usize(n);
421 for slot in out.iter_mut() {
422 *slot = trades[pos % n];
423 if rng.next_usize(mean_block_size) == 0 {
424 pos = rng.next_usize(n);
426 } else {
427 pos += 1;
428 }
429 }
430}
431
432fn parametric_sample_into(trades: &[f64], rng: &mut Xorshift64, out: &mut [f64]) {
439 let n = trades.len();
440 let mean = trades.iter().sum::<f64>() / n as f64;
441 let variance = if n > 1 {
442 trades.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0)
443 } else {
444 0.0
445 };
446 let std_dev = variance.sqrt();
447
448 if std_dev == 0.0 {
450 out.iter_mut().for_each(|v| *v = mean);
451 return;
452 }
453
454 let mut i = 0;
455 while i < n {
456 let u1 = rng.next_f64_positive();
458 let u2 = rng.next_f64_positive();
459 let mag = (-2.0 * u1.ln()).sqrt();
460 let angle = std::f64::consts::TAU * u2;
461 let z0 = mag * angle.cos();
462 let z1 = mag * angle.sin();
463
464 out[i] = mean + std_dev * z0;
465 if i + 1 < n {
466 out[i + 1] = mean + std_dev * z1;
467 }
468 i += 2;
469 }
470}
471
472fn build_equity_curve(
479 trade_returns: &[f64],
480 initial_capital: f64,
481 position_size_pct: f64,
482) -> (Vec<f64>, f64) {
483 let mut curve = Vec::with_capacity(trade_returns.len() + 1);
484 curve.push(initial_capital);
485 let mut equity = initial_capital;
486 let exposure = position_size_pct.max(0.0);
487 for &ret in trade_returns {
488 equity *= 1.0 + ret * exposure;
489 curve.push(equity);
490 }
491 (curve, equity)
492}
493
494fn compute_max_drawdown(equity_curve: &[f64]) -> f64 {
496 let mut peak = f64::NEG_INFINITY;
497 let mut max_dd = 0.0_f64;
498 for &equity in equity_curve {
499 if equity > peak {
500 peak = equity;
501 }
502 if peak > 0.0 {
503 let dd = (peak - equity) / peak;
504 max_dd = max_dd.max(dd);
505 }
506 }
507 max_dd
508}
509
510fn compute_sharpe(equity_curve: &[f64], periods_per_year: f64) -> f64 {
527 if equity_curve.len() < 2 {
528 return 0.0;
529 }
530 let returns: Vec<f64> = equity_curve
531 .windows(2)
532 .map(|w| {
533 if w[0] > 0.0 {
534 (w[1] - w[0]) / w[0]
535 } else {
536 0.0
537 }
538 })
539 .collect();
540
541 if returns.len() < 2 {
542 return 0.0;
543 }
544 let n = returns.len() as f64;
545 let mean = returns.iter().sum::<f64>() / n;
546 let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
548 let std_dev = variance.sqrt();
549 if std_dev == 0.0 {
550 return 0.0;
551 }
552 (mean / std_dev) * periods_per_year.sqrt()
553}
554
555fn compute_profit_factor(trade_returns: &[f64]) -> f64 {
557 let gross_profit: f64 = trade_returns.iter().filter(|&&r| r > 0.0).sum();
558 let gross_loss: f64 = trade_returns
559 .iter()
560 .filter(|&&r| r < 0.0)
561 .map(|r| r.abs())
562 .sum();
563 if gross_loss > 0.0 {
564 gross_profit / gross_loss
565 } else if gross_profit > 0.0 {
566 f64::MAX
567 } else {
568 0.0
569 }
570}
571
572#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::backtesting::result::{BacktestResult, EquityPoint, PerformanceMetrics};
578 use crate::backtesting::signal::Signal;
579 use crate::backtesting::{BacktestConfig, PositionSide, Trade};
580
581 fn make_signal() -> Signal {
582 Signal::long(0, 100.0)
583 }
584
585 fn make_trade(entry: f64, exit: f64, qty: f64) -> Trade {
586 Trade {
587 side: PositionSide::Long,
588 entry_timestamp: 0,
589 exit_timestamp: 86400,
590 entry_price: entry,
591 exit_price: exit,
592 quantity: qty,
593 entry_quantity: qty,
594 commission: 0.0,
595 transaction_tax: 0.0,
596 pnl: (exit - entry) * qty,
597 return_pct: ((exit / entry) - 1.0) * 100.0,
598 dividend_income: 0.0,
599 unreinvested_dividends: 0.0,
600 tags: Vec::new(),
601 is_partial: false,
602 scale_sequence: 0,
603 entry_signal: make_signal(),
604 exit_signal: Signal::exit(86400, exit),
605 }
606 }
607
608 fn make_equity_point(ts: i64, equity: f64) -> EquityPoint {
609 EquityPoint {
610 timestamp: ts,
611 equity,
612 drawdown_pct: 0.0,
613 }
614 }
615
616 fn minimal_result(trades: Vec<Trade>) -> BacktestResult {
617 let n_candles = 252;
618 let equity_curve: Vec<EquityPoint> = (0..n_candles)
619 .map(|i| make_equity_point(i as i64, 10_000.0))
620 .collect();
621
622 BacktestResult {
623 symbol: "TEST".into(),
624 strategy_name: "test".into(),
625 config: BacktestConfig::default(),
626 start_timestamp: 0,
627 end_timestamp: n_candles as i64,
628 initial_capital: 10_000.0,
629 final_equity: 10_000.0,
630 metrics: PerformanceMetrics::calculate(
631 &trades,
632 &equity_curve,
633 10_000.0,
634 0,
635 0,
636 0.0,
637 252.0,
638 ),
639 trades,
640 equity_curve,
641 signals: vec![],
642 open_position: None,
643 benchmark: None,
644 diagnostics: vec![],
645 }
646 }
647
648 fn mixed_trades() -> Vec<Trade> {
649 vec![
650 make_trade(100.0, 110.0, 10.0),
651 make_trade(100.0, 90.0, 10.0),
652 make_trade(100.0, 115.0, 10.0),
653 make_trade(100.0, 95.0, 10.0),
654 ]
655 }
656
657 #[test]
660 fn test_reproducible_with_seed() {
661 let result = minimal_result(mixed_trades());
662 let config = MonteCarloConfig::default().seed(42);
663 let mc1 = config.run(&result);
664 let mc2 = config.run(&result);
665
666 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
667 assert!((mc1.max_drawdown.p50 - mc2.max_drawdown.p50).abs() < f64::EPSILON);
668 }
669
670 #[test]
671 fn test_percentile_ordering() {
672 let trades = vec![
673 make_trade(100.0, 120.0, 10.0),
674 make_trade(100.0, 80.0, 10.0),
675 make_trade(100.0, 130.0, 10.0),
676 make_trade(100.0, 75.0, 10.0),
677 make_trade(100.0, 110.0, 10.0),
678 ];
679 let result = minimal_result(trades);
680 let mc = MonteCarloConfig::default()
681 .num_simulations(500)
682 .seed(1)
683 .run(&result);
684
685 assert!(mc.total_return.p5 <= mc.total_return.p25);
686 assert!(mc.total_return.p25 <= mc.total_return.p50);
687 assert!(mc.total_return.p50 <= mc.total_return.p75);
688 assert!(mc.total_return.p75 <= mc.total_return.p95);
689 assert!(mc.max_drawdown.p5 <= mc.max_drawdown.p95);
690 }
691
692 #[test]
693 fn test_degenerate_single_trade() {
694 let result = minimal_result(vec![make_trade(100.0, 110.0, 10.0)]);
695 let mc = MonteCarloConfig::default().run(&result);
696
697 assert_eq!(mc.total_return.p5, mc.total_return.p50);
699 assert_eq!(mc.total_return.p50, mc.total_return.p95);
700 }
701
702 #[test]
703 fn test_all_winning_trades_tight_distribution() {
704 let trades: Vec<Trade> = (0..20).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
705 let result = minimal_result(trades);
706 let mc = MonteCarloConfig::default().seed(99).run(&result);
707
708 let spread = mc.total_return.p95 - mc.total_return.p5;
709 assert!(
710 spread < 1e-6,
711 "expected tight spread for identical trades, got {spread}"
712 );
713 }
714
715 #[test]
718 fn test_block_bootstrap_percentile_ordering() {
719 let trades = vec![
720 make_trade(100.0, 120.0, 10.0),
721 make_trade(100.0, 80.0, 10.0),
722 make_trade(100.0, 130.0, 10.0),
723 make_trade(100.0, 75.0, 10.0),
724 make_trade(100.0, 110.0, 10.0),
725 make_trade(100.0, 95.0, 10.0),
726 ];
727 let result = minimal_result(trades);
728 let mc = MonteCarloConfig::default()
729 .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
730 .num_simulations(500)
731 .seed(7)
732 .run(&result);
733
734 assert!(mc.total_return.p5 <= mc.total_return.p50);
735 assert!(mc.total_return.p50 <= mc.total_return.p95);
736 }
737
738 #[test]
739 fn test_block_bootstrap_reproducible() {
740 let result = minimal_result(mixed_trades());
741 let config = MonteCarloConfig::default()
742 .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
743 .seed(13);
744 let mc1 = config.run(&result);
745 let mc2 = config.run(&result);
746
747 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
748 }
749
750 #[test]
751 fn test_block_bootstrap_block_size_one_matches_iid_distribution() {
752 let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
756 let result = minimal_result(trades);
757
758 let iid = MonteCarloConfig::default().seed(1).run(&result);
759 let bb = MonteCarloConfig::default()
760 .method(MonteCarloMethod::BlockBootstrap { block_size: 1 })
761 .seed(1)
762 .run(&result);
763
764 let iid_spread = iid.total_return.p95 - iid.total_return.p5;
766 let bb_spread = bb.total_return.p95 - bb.total_return.p5;
767 assert!(iid_spread < 1e-6, "iid spread {iid_spread}");
768 assert!(bb_spread < 1e-6, "bb spread {bb_spread}");
769 }
770
771 #[test]
774 fn test_stationary_bootstrap_percentile_ordering() {
775 let trades = vec![
776 make_trade(100.0, 120.0, 10.0),
777 make_trade(100.0, 80.0, 10.0),
778 make_trade(100.0, 130.0, 10.0),
779 make_trade(100.0, 75.0, 10.0),
780 make_trade(100.0, 110.0, 10.0),
781 make_trade(100.0, 95.0, 10.0),
782 ];
783 let result = minimal_result(trades);
784 let mc = MonteCarloConfig::default()
785 .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
786 .num_simulations(500)
787 .seed(5)
788 .run(&result);
789
790 assert!(mc.total_return.p5 <= mc.total_return.p50);
791 assert!(mc.total_return.p50 <= mc.total_return.p95);
792 }
793
794 #[test]
795 fn test_stationary_bootstrap_reproducible() {
796 let result = minimal_result(mixed_trades());
797 let config = MonteCarloConfig::default()
798 .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
799 .seed(77);
800 let mc1 = config.run(&result);
801 let mc2 = config.run(&result);
802
803 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
804 }
805
806 #[test]
809 fn test_parametric_percentile_ordering() {
810 let trades = vec![
811 make_trade(100.0, 120.0, 10.0),
812 make_trade(100.0, 80.0, 10.0),
813 make_trade(100.0, 130.0, 10.0),
814 make_trade(100.0, 75.0, 10.0),
815 make_trade(100.0, 110.0, 10.0),
816 ];
817 let result = minimal_result(trades);
818 let mc = MonteCarloConfig::default()
819 .method(MonteCarloMethod::Parametric)
820 .num_simulations(1000)
821 .seed(3)
822 .run(&result);
823
824 assert!(mc.total_return.p5 <= mc.total_return.p25);
825 assert!(mc.total_return.p25 <= mc.total_return.p50);
826 assert!(mc.total_return.p50 <= mc.total_return.p75);
827 assert!(mc.total_return.p75 <= mc.total_return.p95);
828 }
829
830 #[test]
831 fn test_parametric_reproducible() {
832 let result = minimal_result(mixed_trades());
833 let config = MonteCarloConfig::default()
834 .method(MonteCarloMethod::Parametric)
835 .seed(99);
836 let mc1 = config.run(&result);
837 let mc2 = config.run(&result);
838
839 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
840 }
841
842 #[test]
843 fn test_parametric_identical_trades_tight_distribution() {
844 let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
846 let result = minimal_result(trades);
847 let mc = MonteCarloConfig::default()
848 .method(MonteCarloMethod::Parametric)
849 .seed(1)
850 .run(&result);
851
852 let spread = mc.total_return.p95 - mc.total_return.p5;
853 assert!(
854 spread < 1e-6,
855 "expected tight spread for zero-variance trades, got {spread}"
856 );
857 }
858
859 #[test]
862 fn test_xorshift_never_zero() {
863 let mut rng = Xorshift64::new(0); for _ in 0..1000 {
865 assert_ne!(rng.next(), 0);
866 }
867 }
868
869 #[test]
870 fn test_next_f64_positive_in_range() {
871 let mut rng = Xorshift64::new(42);
872 for _ in 0..10_000 {
873 let v = rng.next_f64_positive();
874 assert!(v > 0.0 && v <= 1.0, "out of range: {v}");
875 }
876 }
877
878 #[test]
881 fn test_profit_factor_all_wins_is_f64_max() {
882 let pf = compute_profit_factor(&[0.01, 0.02, 0.03]);
883 assert_eq!(pf, f64::MAX);
884 }
885
886 #[test]
887 fn test_result_carries_method() {
888 let result = minimal_result(mixed_trades());
889 let mc = MonteCarloConfig::default()
890 .method(MonteCarloMethod::BlockBootstrap { block_size: 3 })
891 .run(&result);
892 assert!(matches!(
893 mc.method,
894 MonteCarloMethod::BlockBootstrap { block_size: 3 }
895 ));
896 }
897}