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 (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 tags: Vec::new(),
586 is_partial: false,
587 scale_sequence: 0,
588 entry_signal: make_signal(),
589 exit_signal: Signal::exit(86400, exit),
590 }
591 }
592
593 fn make_equity_point(ts: i64, equity: f64) -> EquityPoint {
594 EquityPoint {
595 timestamp: ts,
596 equity,
597 drawdown_pct: 0.0,
598 }
599 }
600
601 fn minimal_result(trades: Vec<Trade>) -> BacktestResult {
602 let n_candles = 252;
603 let equity_curve: Vec<EquityPoint> = (0..n_candles)
604 .map(|i| make_equity_point(i as i64, 10_000.0))
605 .collect();
606
607 BacktestResult {
608 symbol: "TEST".into(),
609 strategy_name: "test".into(),
610 config: BacktestConfig::default(),
611 start_timestamp: 0,
612 end_timestamp: n_candles as i64,
613 initial_capital: 10_000.0,
614 final_equity: 10_000.0,
615 metrics: PerformanceMetrics::calculate(
616 &trades,
617 &equity_curve,
618 10_000.0,
619 0,
620 0,
621 0.0,
622 252.0,
623 ),
624 trades,
625 equity_curve,
626 signals: vec![],
627 open_position: None,
628 benchmark: None,
629 diagnostics: vec![],
630 }
631 }
632
633 fn mixed_trades() -> Vec<Trade> {
634 vec![
635 make_trade(100.0, 110.0, 10.0),
636 make_trade(100.0, 90.0, 10.0),
637 make_trade(100.0, 115.0, 10.0),
638 make_trade(100.0, 95.0, 10.0),
639 ]
640 }
641
642 #[test]
645 fn test_reproducible_with_seed() {
646 let result = minimal_result(mixed_trades());
647 let config = MonteCarloConfig::default().seed(42);
648 let mc1 = config.run(&result);
649 let mc2 = config.run(&result);
650
651 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
652 assert!((mc1.max_drawdown.p50 - mc2.max_drawdown.p50).abs() < f64::EPSILON);
653 }
654
655 #[test]
656 fn test_percentile_ordering() {
657 let trades = vec![
658 make_trade(100.0, 120.0, 10.0),
659 make_trade(100.0, 80.0, 10.0),
660 make_trade(100.0, 130.0, 10.0),
661 make_trade(100.0, 75.0, 10.0),
662 make_trade(100.0, 110.0, 10.0),
663 ];
664 let result = minimal_result(trades);
665 let mc = MonteCarloConfig::default()
666 .num_simulations(500)
667 .seed(1)
668 .run(&result);
669
670 assert!(mc.total_return.p5 <= mc.total_return.p25);
671 assert!(mc.total_return.p25 <= mc.total_return.p50);
672 assert!(mc.total_return.p50 <= mc.total_return.p75);
673 assert!(mc.total_return.p75 <= mc.total_return.p95);
674 assert!(mc.max_drawdown.p5 <= mc.max_drawdown.p95);
675 }
676
677 #[test]
678 fn test_degenerate_single_trade() {
679 let result = minimal_result(vec![make_trade(100.0, 110.0, 10.0)]);
680 let mc = MonteCarloConfig::default().run(&result);
681
682 assert_eq!(mc.total_return.p5, mc.total_return.p50);
684 assert_eq!(mc.total_return.p50, mc.total_return.p95);
685 }
686
687 #[test]
688 fn test_all_winning_trades_tight_distribution() {
689 let trades: Vec<Trade> = (0..20).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
690 let result = minimal_result(trades);
691 let mc = MonteCarloConfig::default().seed(99).run(&result);
692
693 let spread = mc.total_return.p95 - mc.total_return.p5;
694 assert!(
695 spread < 1e-6,
696 "expected tight spread for identical trades, got {spread}"
697 );
698 }
699
700 #[test]
703 fn test_block_bootstrap_percentile_ordering() {
704 let trades = vec![
705 make_trade(100.0, 120.0, 10.0),
706 make_trade(100.0, 80.0, 10.0),
707 make_trade(100.0, 130.0, 10.0),
708 make_trade(100.0, 75.0, 10.0),
709 make_trade(100.0, 110.0, 10.0),
710 make_trade(100.0, 95.0, 10.0),
711 ];
712 let result = minimal_result(trades);
713 let mc = MonteCarloConfig::default()
714 .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
715 .num_simulations(500)
716 .seed(7)
717 .run(&result);
718
719 assert!(mc.total_return.p5 <= mc.total_return.p50);
720 assert!(mc.total_return.p50 <= mc.total_return.p95);
721 }
722
723 #[test]
724 fn test_block_bootstrap_reproducible() {
725 let result = minimal_result(mixed_trades());
726 let config = MonteCarloConfig::default()
727 .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
728 .seed(13);
729 let mc1 = config.run(&result);
730 let mc2 = config.run(&result);
731
732 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
733 }
734
735 #[test]
736 fn test_block_bootstrap_block_size_one_matches_iid_distribution() {
737 let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
741 let result = minimal_result(trades);
742
743 let iid = MonteCarloConfig::default().seed(1).run(&result);
744 let bb = MonteCarloConfig::default()
745 .method(MonteCarloMethod::BlockBootstrap { block_size: 1 })
746 .seed(1)
747 .run(&result);
748
749 let iid_spread = iid.total_return.p95 - iid.total_return.p5;
751 let bb_spread = bb.total_return.p95 - bb.total_return.p5;
752 assert!(iid_spread < 1e-6, "iid spread {iid_spread}");
753 assert!(bb_spread < 1e-6, "bb spread {bb_spread}");
754 }
755
756 #[test]
759 fn test_stationary_bootstrap_percentile_ordering() {
760 let trades = vec![
761 make_trade(100.0, 120.0, 10.0),
762 make_trade(100.0, 80.0, 10.0),
763 make_trade(100.0, 130.0, 10.0),
764 make_trade(100.0, 75.0, 10.0),
765 make_trade(100.0, 110.0, 10.0),
766 make_trade(100.0, 95.0, 10.0),
767 ];
768 let result = minimal_result(trades);
769 let mc = MonteCarloConfig::default()
770 .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
771 .num_simulations(500)
772 .seed(5)
773 .run(&result);
774
775 assert!(mc.total_return.p5 <= mc.total_return.p50);
776 assert!(mc.total_return.p50 <= mc.total_return.p95);
777 }
778
779 #[test]
780 fn test_stationary_bootstrap_reproducible() {
781 let result = minimal_result(mixed_trades());
782 let config = MonteCarloConfig::default()
783 .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
784 .seed(77);
785 let mc1 = config.run(&result);
786 let mc2 = config.run(&result);
787
788 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
789 }
790
791 #[test]
794 fn test_parametric_percentile_ordering() {
795 let trades = vec![
796 make_trade(100.0, 120.0, 10.0),
797 make_trade(100.0, 80.0, 10.0),
798 make_trade(100.0, 130.0, 10.0),
799 make_trade(100.0, 75.0, 10.0),
800 make_trade(100.0, 110.0, 10.0),
801 ];
802 let result = minimal_result(trades);
803 let mc = MonteCarloConfig::default()
804 .method(MonteCarloMethod::Parametric)
805 .num_simulations(1000)
806 .seed(3)
807 .run(&result);
808
809 assert!(mc.total_return.p5 <= mc.total_return.p25);
810 assert!(mc.total_return.p25 <= mc.total_return.p50);
811 assert!(mc.total_return.p50 <= mc.total_return.p75);
812 assert!(mc.total_return.p75 <= mc.total_return.p95);
813 }
814
815 #[test]
816 fn test_parametric_reproducible() {
817 let result = minimal_result(mixed_trades());
818 let config = MonteCarloConfig::default()
819 .method(MonteCarloMethod::Parametric)
820 .seed(99);
821 let mc1 = config.run(&result);
822 let mc2 = config.run(&result);
823
824 assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
825 }
826
827 #[test]
828 fn test_parametric_identical_trades_tight_distribution() {
829 let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
831 let result = minimal_result(trades);
832 let mc = MonteCarloConfig::default()
833 .method(MonteCarloMethod::Parametric)
834 .seed(1)
835 .run(&result);
836
837 let spread = mc.total_return.p95 - mc.total_return.p5;
838 assert!(
839 spread < 1e-6,
840 "expected tight spread for zero-variance trades, got {spread}"
841 );
842 }
843
844 #[test]
847 fn test_xorshift_never_zero() {
848 let mut rng = Xorshift64::new(0); for _ in 0..1000 {
850 assert_ne!(rng.next(), 0);
851 }
852 }
853
854 #[test]
855 fn test_next_f64_positive_in_range() {
856 let mut rng = Xorshift64::new(42);
857 for _ in 0..10_000 {
858 let v = rng.next_f64_positive();
859 assert!(v > 0.0 && v <= 1.0, "out of range: {v}");
860 }
861 }
862
863 #[test]
866 fn test_profit_factor_all_wins_is_f64_max() {
867 let (_, _, _, pf) = run_simulation(&[0.01, 0.02, 0.03], 10_000.0, 1.0, 252.0);
868 assert_eq!(pf, f64::MAX);
869 }
870
871 #[test]
872 fn test_result_carries_method() {
873 let result = minimal_result(mixed_trades());
874 let mc = MonteCarloConfig::default()
875 .method(MonteCarloMethod::BlockBootstrap { block_size: 3 })
876 .run(&result);
877 assert!(matches!(
878 mc.method,
879 MonteCarloMethod::BlockBootstrap { block_size: 3 }
880 ));
881 }
882}