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 || self.num_simulations == 0 {
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 (total_return, max_dd, sharpe, pf) =
218 run_simulation(&sim_buf, initial_capital, position_size, periods_per_year);
219
220 sim_returns.push(total_return);
221 sim_drawdowns.push(max_dd);
222 sim_sharpes.push(sharpe);
223 sim_pfs.push(pf);
224 }
225
226 MonteCarloResult {
227 num_simulations: self.num_simulations,
228 method: self.method.clone(),
229 total_return: PercentileStats::from_sorted(&mut sim_returns),
230 max_drawdown: PercentileStats::from_sorted(&mut sim_drawdowns),
231 sharpe_ratio: PercentileStats::from_sorted(&mut sim_sharpes),
232 profit_factor: PercentileStats::from_sorted(&mut sim_pfs),
233 }
234 }
235}
236
237#[non_exhaustive]
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct PercentileStats {
243 pub p5: f64,
245 pub p25: f64,
247 pub p50: f64,
249 pub p75: f64,
251 pub p95: f64,
253 pub mean: f64,
255}
256
257impl PercentileStats {
258 fn from_sorted(values: &mut [f64]) -> Self {
260 values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
261 let n = values.len();
262
263 let percentile = |p: f64| {
264 let idx = ((p / 100.0) * (n - 1) as f64).round() as usize;
265 values[idx.min(n - 1)]
266 };
267
268 let mean = values.iter().sum::<f64>() / n as f64;
269
270 Self {
271 p5: percentile(5.0),
272 p25: percentile(25.0),
273 p50: percentile(50.0),
274 p75: percentile(75.0),
275 p95: percentile(95.0),
276 mean,
277 }
278 }
279}
280
281#[non_exhaustive]
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct MonteCarloResult {
287 pub num_simulations: usize,
289
290 pub method: MonteCarloMethod,
292
293 pub total_return: PercentileStats,
295
296 pub max_drawdown: PercentileStats,
298
299 pub sharpe_ratio: PercentileStats,
311
312 pub profit_factor: PercentileStats,
314}
315
316pub(crate) struct Xorshift64 {
323 state: u64,
324}
325
326impl Xorshift64 {
327 pub(crate) fn new(seed: u64) -> Self {
328 Self {
330 state: if seed == 0 { 1 } else { seed },
331 }
332 }
333
334 pub(crate) fn next(&mut self) -> u64 {
336 let mut x = self.state;
337 x ^= x << 13;
338 x ^= x >> 7;
339 x ^= x << 17;
340 self.state = x;
341 x
342 }
343
344 pub(crate) fn next_usize(&mut self, n: usize) -> usize {
350 let n64 = n as u64;
351 let threshold = u64::MAX - (u64::MAX % n64);
354 loop {
355 let x = self.next();
356 if x < threshold {
357 return (x % n64) as usize;
358 }
359 }
360 }
361
362 pub(crate) fn next_f64_positive(&mut self) -> f64 {
367 ((self.next() >> 11) + 1) as f64 * (1.0 / (1u64 << 53) as f64)
370 }
371}
372
373fn fisher_yates_shuffle(slice: &mut [f64], rng: &mut Xorshift64) {
377 for i in (1..slice.len()).rev() {
378 let j = rng.next_usize(i + 1);
379 slice.swap(i, j);
380 }
381}
382
383fn block_bootstrap_into(trades: &[f64], block_size: usize, rng: &mut Xorshift64, out: &mut [f64]) {
388 let n = trades.len();
389 let block_size = block_size.max(1);
390 let mut filled = 0;
391 while filled < n {
392 let start = rng.next_usize(n);
393 let take = block_size.min(n - filled);
394 for i in 0..take {
395 out[filled + i] = trades[(start + i) % n];
396 }
397 filled += take;
398 }
399}
400
401fn stationary_bootstrap_into(
408 trades: &[f64],
409 mean_block_size: usize,
410 rng: &mut Xorshift64,
411 out: &mut [f64],
412) {
413 let n = trades.len();
414 let mean_block_size = mean_block_size.max(1);
415 let mut pos = rng.next_usize(n);
416 for slot in out.iter_mut() {
417 *slot = trades[pos % n];
418 if rng.next_usize(mean_block_size) == 0 {
419 pos = rng.next_usize(n);
421 } else {
422 pos += 1;
423 }
424 }
425}
426
427fn parametric_sample_into(trades: &[f64], rng: &mut Xorshift64, out: &mut [f64]) {
434 let n = trades.len();
435 let mean = trades.iter().sum::<f64>() / n as f64;
436 let variance = if n > 1 {
437 trades.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0)
438 } else {
439 0.0
440 };
441 let std_dev = variance.sqrt();
442
443 if std_dev == 0.0 {
445 out.iter_mut().for_each(|v| *v = mean);
446 return;
447 }
448
449 let mut i = 0;
450 while i < n {
451 let u1 = rng.next_f64_positive();
453 let u2 = rng.next_f64_positive();
454 let mag = (-2.0 * u1.ln()).sqrt();
455 let angle = std::f64::consts::TAU * u2;
456 let z0 = mag * angle.cos();
457 let z1 = mag * angle.sin();
458
459 out[i] = mean + std_dev * z0;
460 if i + 1 < n {
461 out[i + 1] = mean + std_dev * z1;
462 }
463 i += 2;
464 }
465}
466
467fn run_simulation(
480 trade_returns: &[f64],
481 initial_capital: f64,
482 position_size_pct: f64,
483 periods_per_year: f64,
484) -> (f64, f64, f64, f64) {
485 let exposure = position_size_pct.max(0.0);
486 let mut equity = initial_capital;
487 let mut peak = initial_capital;
488 let mut max_dd = 0.0f64;
489 let mut w_count = 0usize;
491 let mut w_mean = 0.0f64;
492 let mut w_m2 = 0.0f64;
493 let mut gross_profit = 0.0f64;
495 let mut gross_loss = 0.0f64;
496
497 let mut prev_equity = initial_capital;
498 for &ret in trade_returns {
499 equity *= 1.0 + ret * exposure;
500
501 if equity > peak {
503 peak = equity;
504 }
505 if peak > 0.0 {
506 let dd = (peak - equity) / peak;
507 if dd > max_dd {
508 max_dd = dd;
509 }
510 }
511
512 let bar_ret = if prev_equity > 0.0 {
514 (equity - prev_equity) / prev_equity
515 } else {
516 0.0
517 };
518 prev_equity = equity;
519 w_count += 1;
520 let delta = bar_ret - w_mean;
521 w_mean += delta / w_count as f64;
522 w_m2 += delta * (bar_ret - w_mean);
523
524 if ret > 0.0 {
526 gross_profit += ret;
527 } else if ret < 0.0 {
528 gross_loss += -ret;
529 }
530 }
531
532 let total_return = ((equity / initial_capital) - 1.0) * 100.0;
533
534 let sharpe = if w_count >= 2 {
535 let variance = w_m2 / (w_count - 1) as f64;
536 let std_dev = variance.sqrt();
537 if std_dev > 0.0 {
538 (w_mean / std_dev) * periods_per_year.sqrt()
539 } else {
540 0.0
541 }
542 } else {
543 0.0
544 };
545
546 let pf = if gross_loss > 0.0 {
547 gross_profit / gross_loss
548 } else if gross_profit > 0.0 {
549 f64::MAX
550 } else {
551 0.0
552 };
553
554 (total_return, max_dd, sharpe, pf)
555}
556
557#[cfg(test)]
560mod tests {
561 use super::*;
562 use crate::backtesting::result::{BacktestResult, EquityPoint, PerformanceMetrics};
563 use crate::backtesting::signal::Signal;
564 use crate::backtesting::{BacktestConfig, PositionSide, Trade};
565
566 fn make_signal() -> Signal {
567 Signal::long(0, 100.0)
568 }
569
570 fn make_trade(entry: f64, exit: f64, qty: f64) -> Trade {
571 Trade {
572 side: PositionSide::Long,
573 entry_timestamp: 0,
574 exit_timestamp: 86400,
575 entry_price: entry,
576 exit_price: exit,
577 quantity: qty,
578 entry_quantity: qty,
579 commission: 0.0,
580 transaction_tax: 0.0,
581 pnl: (exit - entry) * qty,
582 return_pct: ((exit / entry) - 1.0) * 100.0,
583 dividend_income: 0.0,
584 unreinvested_dividends: 0.0,
585 financing_cost: 0.0,
586 tags: Vec::new(),
587 is_partial: false,
588 scale_sequence: 0,
589 entry_signal: make_signal(),
590 exit_signal: Signal::exit(86400, exit),
591 }
592 }
593
594 fn make_equity_point(ts: i64, equity: f64) -> EquityPoint {
595 EquityPoint {
596 timestamp: ts,
597 equity,
598 drawdown_pct: 0.0,
599 }
600 }
601
602 fn minimal_result(trades: Vec<Trade>) -> BacktestResult {
603 let n_candles = 252;
604 let equity_curve: Vec<EquityPoint> = (0..n_candles)
605 .map(|i| make_equity_point(i as i64, 10_000.0))
606 .collect();
607
608 BacktestResult {
609 symbol: "TEST".into(),
610 strategy_name: "test".into(),
611 config: BacktestConfig::default(),
612 start_timestamp: 0,
613 end_timestamp: n_candles as i64,
614 initial_capital: 10_000.0,
615 final_equity: 10_000.0,
616 metrics: PerformanceMetrics::calculate(
617 &trades,
618 &equity_curve,
619 10_000.0,
620 0,
621 0,
622 0.0,
623 252.0,
624 ),
625 trades,
626 equity_curve,
627 signals: vec![],
628 open_position: None,
629 benchmark: None,
630 diagnostics: vec![],
631 max_leverage_used: 0.0,
632 }
633 }
634
635 fn mixed_trades() -> Vec<Trade> {
636 vec![
637 make_trade(100.0, 110.0, 10.0),
638 make_trade(100.0, 90.0, 10.0),
639 make_trade(100.0, 115.0, 10.0),
640 make_trade(100.0, 95.0, 10.0),
641 ]
642 }
643
644 #[test]
647 fn test_reproducible_with_seed() {
648 let result = minimal_result(mixed_trades());
649 let config = MonteCarloConfig::default().seed(42);
650 let mc1 = config.run(&result);
651 let mc2 = config.run(&result);
652
653 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
654 assert!((mc1.max_drawdown.p50 - mc2.max_drawdown.p50).abs() < f64::EPSILON);
655 }
656
657 #[test]
658 fn test_percentile_ordering() {
659 let trades = vec![
660 make_trade(100.0, 120.0, 10.0),
661 make_trade(100.0, 80.0, 10.0),
662 make_trade(100.0, 130.0, 10.0),
663 make_trade(100.0, 75.0, 10.0),
664 make_trade(100.0, 110.0, 10.0),
665 ];
666 let result = minimal_result(trades);
667 let mc = MonteCarloConfig::default()
668 .num_simulations(500)
669 .seed(1)
670 .run(&result);
671
672 assert!(mc.total_return.p5 <= mc.total_return.p25);
673 assert!(mc.total_return.p25 <= mc.total_return.p50);
674 assert!(mc.total_return.p50 <= mc.total_return.p75);
675 assert!(mc.total_return.p75 <= mc.total_return.p95);
676 assert!(mc.max_drawdown.p5 <= mc.max_drawdown.p95);
677 }
678
679 #[test]
680 fn test_degenerate_single_trade() {
681 let result = minimal_result(vec![make_trade(100.0, 110.0, 10.0)]);
682 let mc = MonteCarloConfig::default().run(&result);
683
684 assert_eq!(mc.total_return.p5, mc.total_return.p50);
686 assert_eq!(mc.total_return.p50, mc.total_return.p95);
687 }
688
689 #[test]
690 fn test_all_winning_trades_tight_distribution() {
691 let trades: Vec<Trade> = (0..20).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
692 let result = minimal_result(trades);
693 let mc = MonteCarloConfig::default().seed(99).run(&result);
694
695 let spread = mc.total_return.p95 - mc.total_return.p5;
696 assert!(
697 spread < 1e-6,
698 "expected tight spread for identical trades, got {spread}"
699 );
700 }
701
702 #[test]
703 fn test_zero_simulations_returns_trivial_stats() {
704 let result = minimal_result(mixed_trades());
705 let mc = MonteCarloConfig::default().num_simulations(0).run(&result);
706
707 assert_eq!(mc.num_simulations, 0);
708 assert_eq!(mc.total_return.p5, mc.total_return.p50);
709 assert_eq!(mc.total_return.p50, mc.total_return.p95);
710 assert_eq!(mc.total_return.p50, result.metrics.total_return_pct);
711 }
712
713 #[test]
716 fn test_block_bootstrap_percentile_ordering() {
717 let trades = vec![
718 make_trade(100.0, 120.0, 10.0),
719 make_trade(100.0, 80.0, 10.0),
720 make_trade(100.0, 130.0, 10.0),
721 make_trade(100.0, 75.0, 10.0),
722 make_trade(100.0, 110.0, 10.0),
723 make_trade(100.0, 95.0, 10.0),
724 ];
725 let result = minimal_result(trades);
726 let mc = MonteCarloConfig::default()
727 .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
728 .num_simulations(500)
729 .seed(7)
730 .run(&result);
731
732 assert!(mc.total_return.p5 <= mc.total_return.p50);
733 assert!(mc.total_return.p50 <= mc.total_return.p95);
734 }
735
736 #[test]
737 fn test_block_bootstrap_reproducible() {
738 let result = minimal_result(mixed_trades());
739 let config = MonteCarloConfig::default()
740 .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
741 .seed(13);
742 let mc1 = config.run(&result);
743 let mc2 = config.run(&result);
744
745 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
746 }
747
748 #[test]
749 fn test_block_bootstrap_block_size_one_matches_iid_distribution() {
750 let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
754 let result = minimal_result(trades);
755
756 let iid = MonteCarloConfig::default().seed(1).run(&result);
757 let bb = MonteCarloConfig::default()
758 .method(MonteCarloMethod::BlockBootstrap { block_size: 1 })
759 .seed(1)
760 .run(&result);
761
762 let iid_spread = iid.total_return.p95 - iid.total_return.p5;
764 let bb_spread = bb.total_return.p95 - bb.total_return.p5;
765 assert!(iid_spread < 1e-6, "iid spread {iid_spread}");
766 assert!(bb_spread < 1e-6, "bb spread {bb_spread}");
767 }
768
769 #[test]
772 fn test_stationary_bootstrap_percentile_ordering() {
773 let trades = vec![
774 make_trade(100.0, 120.0, 10.0),
775 make_trade(100.0, 80.0, 10.0),
776 make_trade(100.0, 130.0, 10.0),
777 make_trade(100.0, 75.0, 10.0),
778 make_trade(100.0, 110.0, 10.0),
779 make_trade(100.0, 95.0, 10.0),
780 ];
781 let result = minimal_result(trades);
782 let mc = MonteCarloConfig::default()
783 .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
784 .num_simulations(500)
785 .seed(5)
786 .run(&result);
787
788 assert!(mc.total_return.p5 <= mc.total_return.p50);
789 assert!(mc.total_return.p50 <= mc.total_return.p95);
790 }
791
792 #[test]
793 fn test_stationary_bootstrap_reproducible() {
794 let result = minimal_result(mixed_trades());
795 let config = MonteCarloConfig::default()
796 .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
797 .seed(77);
798 let mc1 = config.run(&result);
799 let mc2 = config.run(&result);
800
801 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
802 }
803
804 #[test]
807 fn test_parametric_percentile_ordering() {
808 let trades = vec![
809 make_trade(100.0, 120.0, 10.0),
810 make_trade(100.0, 80.0, 10.0),
811 make_trade(100.0, 130.0, 10.0),
812 make_trade(100.0, 75.0, 10.0),
813 make_trade(100.0, 110.0, 10.0),
814 ];
815 let result = minimal_result(trades);
816 let mc = MonteCarloConfig::default()
817 .method(MonteCarloMethod::Parametric)
818 .num_simulations(1000)
819 .seed(3)
820 .run(&result);
821
822 assert!(mc.total_return.p5 <= mc.total_return.p25);
823 assert!(mc.total_return.p25 <= mc.total_return.p50);
824 assert!(mc.total_return.p50 <= mc.total_return.p75);
825 assert!(mc.total_return.p75 <= mc.total_return.p95);
826 }
827
828 #[test]
829 fn test_parametric_reproducible() {
830 let result = minimal_result(mixed_trades());
831 let config = MonteCarloConfig::default()
832 .method(MonteCarloMethod::Parametric)
833 .seed(99);
834 let mc1 = config.run(&result);
835 let mc2 = config.run(&result);
836
837 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
838 }
839
840 #[test]
841 fn test_parametric_identical_trades_tight_distribution() {
842 let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
844 let result = minimal_result(trades);
845 let mc = MonteCarloConfig::default()
846 .method(MonteCarloMethod::Parametric)
847 .seed(1)
848 .run(&result);
849
850 let spread = mc.total_return.p95 - mc.total_return.p5;
851 assert!(
852 spread < 1e-6,
853 "expected tight spread for zero-variance trades, got {spread}"
854 );
855 }
856
857 #[test]
860 fn test_xorshift_never_zero() {
861 let mut rng = Xorshift64::new(0); for _ in 0..1000 {
863 assert_ne!(rng.next(), 0);
864 }
865 }
866
867 #[test]
868 fn test_next_f64_positive_in_range() {
869 let mut rng = Xorshift64::new(42);
870 for _ in 0..10_000 {
871 let v = rng.next_f64_positive();
872 assert!(v > 0.0 && v <= 1.0, "out of range: {v}");
873 }
874 }
875
876 #[test]
879 fn test_profit_factor_all_wins_is_f64_max() {
880 let (_, _, _, pf) = run_simulation(&[0.01, 0.02, 0.03], 10_000.0, 1.0, 252.0);
881 assert_eq!(pf, f64::MAX);
882 }
883
884 #[test]
885 fn test_result_carries_method() {
886 let result = minimal_result(mixed_trades());
887 let mc = MonteCarloConfig::default()
888 .method(MonteCarloMethod::BlockBootstrap { block_size: 3 })
889 .run(&result);
890 assert!(matches!(
891 mc.method,
892 MonteCarloMethod::BlockBootstrap { block_size: 3 }
893 ));
894 }
895}