use serde::{Deserialize, Serialize};
use super::result::BacktestResult;
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum MonteCarloMethod {
#[default]
IidShuffle,
BlockBootstrap {
block_size: usize,
},
StationaryBootstrap {
mean_block_size: usize,
},
Parametric,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonteCarloConfig {
pub num_simulations: usize,
pub seed: Option<u64>,
pub method: MonteCarloMethod,
}
impl Default for MonteCarloConfig {
fn default() -> Self {
Self {
num_simulations: 1_000,
seed: None,
method: MonteCarloMethod::IidShuffle,
}
}
}
impl MonteCarloConfig {
pub fn new() -> Self {
Self::default()
}
pub fn num_simulations(mut self, n: usize) -> Self {
self.num_simulations = n;
self
}
pub fn seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
pub fn method(mut self, method: MonteCarloMethod) -> Self {
self.method = method;
self
}
pub fn run(&self, result: &BacktestResult) -> MonteCarloResult {
let initial_capital = result.initial_capital;
let trade_returns: Vec<f64> = result.trades.iter().map(|t| t.return_pct / 100.0).collect();
if trade_returns.len() < 2 {
let obs_return = result.metrics.total_return_pct;
let obs_dd = result.metrics.max_drawdown_pct;
let obs_sharpe = result.metrics.sharpe_ratio;
let obs_pf = result.metrics.profit_factor;
let trivial = |v: f64| PercentileStats {
p5: v,
p25: v,
p50: v,
p75: v,
p95: v,
mean: v,
};
return MonteCarloResult {
num_simulations: self.num_simulations,
method: self.method.clone(),
total_return: trivial(obs_return),
max_drawdown: trivial(obs_dd),
sharpe_ratio: trivial(obs_sharpe),
profit_factor: trivial(obs_pf),
};
}
let seed = self.seed.unwrap_or(12345);
let mut rng = Xorshift64::new(seed);
let position_size = result.config.position_size_pct;
let num_bars = result.equity_curve.len().saturating_sub(1) as f64;
let years = if result.config.bars_per_year > 0.0 {
num_bars / result.config.bars_per_year
} else {
0.0
};
let periods_per_year = if years > 0.0 {
trade_returns.len() as f64 / years
} else {
trade_returns.len().max(1) as f64
};
let mut sim_returns: Vec<f64> = Vec::with_capacity(self.num_simulations);
let mut sim_drawdowns: Vec<f64> = Vec::with_capacity(self.num_simulations);
let mut sim_sharpes: Vec<f64> = Vec::with_capacity(self.num_simulations);
let mut sim_pfs: Vec<f64> = Vec::with_capacity(self.num_simulations);
let mut sim_buf: Vec<f64> = vec![0.0; trade_returns.len()];
for _ in 0..self.num_simulations {
match &self.method {
MonteCarloMethod::IidShuffle => {
sim_buf.copy_from_slice(&trade_returns);
fisher_yates_shuffle(&mut sim_buf, &mut rng);
}
MonteCarloMethod::BlockBootstrap { block_size } => {
block_bootstrap_into(&trade_returns, *block_size, &mut rng, &mut sim_buf);
}
MonteCarloMethod::StationaryBootstrap { mean_block_size } => {
stationary_bootstrap_into(
&trade_returns,
*mean_block_size,
&mut rng,
&mut sim_buf,
);
}
MonteCarloMethod::Parametric => {
parametric_sample_into(&trade_returns, &mut rng, &mut sim_buf);
}
}
let (total_return, max_dd, sharpe, pf) =
run_simulation(&sim_buf, initial_capital, position_size, periods_per_year);
sim_returns.push(total_return);
sim_drawdowns.push(max_dd);
sim_sharpes.push(sharpe);
sim_pfs.push(pf);
}
MonteCarloResult {
num_simulations: self.num_simulations,
method: self.method.clone(),
total_return: PercentileStats::from_sorted(&mut sim_returns),
max_drawdown: PercentileStats::from_sorted(&mut sim_drawdowns),
sharpe_ratio: PercentileStats::from_sorted(&mut sim_sharpes),
profit_factor: PercentileStats::from_sorted(&mut sim_pfs),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PercentileStats {
pub p5: f64,
pub p25: f64,
pub p50: f64,
pub p75: f64,
pub p95: f64,
pub mean: f64,
}
impl PercentileStats {
fn from_sorted(values: &mut [f64]) -> Self {
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = values.len();
let percentile = |p: f64| {
let idx = ((p / 100.0) * (n - 1) as f64).round() as usize;
values[idx.min(n - 1)]
};
let mean = values.iter().sum::<f64>() / n as f64;
Self {
p5: percentile(5.0),
p25: percentile(25.0),
p50: percentile(50.0),
p75: percentile(75.0),
p95: percentile(95.0),
mean,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonteCarloResult {
pub num_simulations: usize,
pub method: MonteCarloMethod,
pub total_return: PercentileStats,
pub max_drawdown: PercentileStats,
pub sharpe_ratio: PercentileStats,
pub profit_factor: PercentileStats,
}
pub(crate) struct Xorshift64 {
state: u64,
}
impl Xorshift64 {
pub(crate) fn new(seed: u64) -> Self {
Self {
state: if seed == 0 { 1 } else { seed },
}
}
pub(crate) fn next(&mut self) -> u64 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.state = x;
x
}
pub(crate) fn next_usize(&mut self, n: usize) -> usize {
let n64 = n as u64;
let threshold = u64::MAX - (u64::MAX % n64);
loop {
let x = self.next();
if x < threshold {
return (x % n64) as usize;
}
}
}
pub(crate) fn next_f64_positive(&mut self) -> f64 {
((self.next() >> 11) + 1) as f64 * (1.0 / (1u64 << 53) as f64)
}
}
fn fisher_yates_shuffle(slice: &mut [f64], rng: &mut Xorshift64) {
for i in (1..slice.len()).rev() {
let j = rng.next_usize(i + 1);
slice.swap(i, j);
}
}
fn block_bootstrap_into(trades: &[f64], block_size: usize, rng: &mut Xorshift64, out: &mut [f64]) {
let n = trades.len();
let block_size = block_size.max(1);
let mut filled = 0;
while filled < n {
let start = rng.next_usize(n);
let take = block_size.min(n - filled);
for i in 0..take {
out[filled + i] = trades[(start + i) % n];
}
filled += take;
}
}
fn stationary_bootstrap_into(
trades: &[f64],
mean_block_size: usize,
rng: &mut Xorshift64,
out: &mut [f64],
) {
let n = trades.len();
let mean_block_size = mean_block_size.max(1);
let mut pos = rng.next_usize(n);
for slot in out.iter_mut() {
*slot = trades[pos % n];
if rng.next_usize(mean_block_size) == 0 {
pos = rng.next_usize(n);
} else {
pos += 1;
}
}
}
fn parametric_sample_into(trades: &[f64], rng: &mut Xorshift64, out: &mut [f64]) {
let n = trades.len();
let mean = trades.iter().sum::<f64>() / n as f64;
let variance = if n > 1 {
trades.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0)
} else {
0.0
};
let std_dev = variance.sqrt();
if std_dev == 0.0 {
out.iter_mut().for_each(|v| *v = mean);
return;
}
let mut i = 0;
while i < n {
let u1 = rng.next_f64_positive();
let u2 = rng.next_f64_positive();
let mag = (-2.0 * u1.ln()).sqrt();
let angle = std::f64::consts::TAU * u2;
let z0 = mag * angle.cos();
let z1 = mag * angle.sin();
out[i] = mean + std_dev * z0;
if i + 1 < n {
out[i + 1] = mean + std_dev * z1;
}
i += 2;
}
}
fn run_simulation(
trade_returns: &[f64],
initial_capital: f64,
position_size_pct: f64,
periods_per_year: f64,
) -> (f64, f64, f64, f64) {
let exposure = position_size_pct.max(0.0);
let mut equity = initial_capital;
let mut peak = initial_capital;
let mut max_dd = 0.0f64;
let mut w_count = 0usize;
let mut w_mean = 0.0f64;
let mut w_m2 = 0.0f64;
let mut gross_profit = 0.0f64;
let mut gross_loss = 0.0f64;
let mut prev_equity = initial_capital;
for &ret in trade_returns {
equity *= 1.0 + ret * exposure;
if equity > peak {
peak = equity;
}
if peak > 0.0 {
let dd = (peak - equity) / peak;
if dd > max_dd {
max_dd = dd;
}
}
let bar_ret = if prev_equity > 0.0 {
(equity - prev_equity) / prev_equity
} else {
0.0
};
prev_equity = equity;
w_count += 1;
let delta = bar_ret - w_mean;
w_mean += delta / w_count as f64;
w_m2 += delta * (bar_ret - w_mean);
if ret > 0.0 {
gross_profit += ret;
} else if ret < 0.0 {
gross_loss += -ret;
}
}
let total_return = ((equity / initial_capital) - 1.0) * 100.0;
let sharpe = if w_count >= 2 {
let variance = w_m2 / (w_count - 1) as f64;
let std_dev = variance.sqrt();
if std_dev > 0.0 {
(w_mean / std_dev) * periods_per_year.sqrt()
} else {
0.0
}
} else {
0.0
};
let pf = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::MAX
} else {
0.0
};
(total_return, max_dd, sharpe, pf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backtesting::result::{BacktestResult, EquityPoint, PerformanceMetrics};
use crate::backtesting::signal::Signal;
use crate::backtesting::{BacktestConfig, PositionSide, Trade};
fn make_signal() -> Signal {
Signal::long(0, 100.0)
}
fn make_trade(entry: f64, exit: f64, qty: f64) -> Trade {
Trade {
side: PositionSide::Long,
entry_timestamp: 0,
exit_timestamp: 86400,
entry_price: entry,
exit_price: exit,
quantity: qty,
entry_quantity: qty,
commission: 0.0,
transaction_tax: 0.0,
pnl: (exit - entry) * qty,
return_pct: ((exit / entry) - 1.0) * 100.0,
dividend_income: 0.0,
unreinvested_dividends: 0.0,
tags: Vec::new(),
is_partial: false,
scale_sequence: 0,
entry_signal: make_signal(),
exit_signal: Signal::exit(86400, exit),
}
}
fn make_equity_point(ts: i64, equity: f64) -> EquityPoint {
EquityPoint {
timestamp: ts,
equity,
drawdown_pct: 0.0,
}
}
fn minimal_result(trades: Vec<Trade>) -> BacktestResult {
let n_candles = 252;
let equity_curve: Vec<EquityPoint> = (0..n_candles)
.map(|i| make_equity_point(i as i64, 10_000.0))
.collect();
BacktestResult {
symbol: "TEST".into(),
strategy_name: "test".into(),
config: BacktestConfig::default(),
start_timestamp: 0,
end_timestamp: n_candles as i64,
initial_capital: 10_000.0,
final_equity: 10_000.0,
metrics: PerformanceMetrics::calculate(
&trades,
&equity_curve,
10_000.0,
0,
0,
0.0,
252.0,
),
trades,
equity_curve,
signals: vec![],
open_position: None,
benchmark: None,
diagnostics: vec![],
}
}
fn mixed_trades() -> Vec<Trade> {
vec![
make_trade(100.0, 110.0, 10.0),
make_trade(100.0, 90.0, 10.0),
make_trade(100.0, 115.0, 10.0),
make_trade(100.0, 95.0, 10.0),
]
}
#[test]
fn test_reproducible_with_seed() {
let result = minimal_result(mixed_trades());
let config = MonteCarloConfig::default().seed(42);
let mc1 = config.run(&result);
let mc2 = config.run(&result);
assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
assert!((mc1.max_drawdown.p50 - mc2.max_drawdown.p50).abs() < f64::EPSILON);
}
#[test]
fn test_percentile_ordering() {
let trades = vec![
make_trade(100.0, 120.0, 10.0),
make_trade(100.0, 80.0, 10.0),
make_trade(100.0, 130.0, 10.0),
make_trade(100.0, 75.0, 10.0),
make_trade(100.0, 110.0, 10.0),
];
let result = minimal_result(trades);
let mc = MonteCarloConfig::default()
.num_simulations(500)
.seed(1)
.run(&result);
assert!(mc.total_return.p5 <= mc.total_return.p25);
assert!(mc.total_return.p25 <= mc.total_return.p50);
assert!(mc.total_return.p50 <= mc.total_return.p75);
assert!(mc.total_return.p75 <= mc.total_return.p95);
assert!(mc.max_drawdown.p5 <= mc.max_drawdown.p95);
}
#[test]
fn test_degenerate_single_trade() {
let result = minimal_result(vec![make_trade(100.0, 110.0, 10.0)]);
let mc = MonteCarloConfig::default().run(&result);
assert_eq!(mc.total_return.p5, mc.total_return.p50);
assert_eq!(mc.total_return.p50, mc.total_return.p95);
}
#[test]
fn test_all_winning_trades_tight_distribution() {
let trades: Vec<Trade> = (0..20).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
let result = minimal_result(trades);
let mc = MonteCarloConfig::default().seed(99).run(&result);
let spread = mc.total_return.p95 - mc.total_return.p5;
assert!(
spread < 1e-6,
"expected tight spread for identical trades, got {spread}"
);
}
#[test]
fn test_block_bootstrap_percentile_ordering() {
let trades = vec![
make_trade(100.0, 120.0, 10.0),
make_trade(100.0, 80.0, 10.0),
make_trade(100.0, 130.0, 10.0),
make_trade(100.0, 75.0, 10.0),
make_trade(100.0, 110.0, 10.0),
make_trade(100.0, 95.0, 10.0),
];
let result = minimal_result(trades);
let mc = MonteCarloConfig::default()
.method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
.num_simulations(500)
.seed(7)
.run(&result);
assert!(mc.total_return.p5 <= mc.total_return.p50);
assert!(mc.total_return.p50 <= mc.total_return.p95);
}
#[test]
fn test_block_bootstrap_reproducible() {
let result = minimal_result(mixed_trades());
let config = MonteCarloConfig::default()
.method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
.seed(13);
let mc1 = config.run(&result);
let mc2 = config.run(&result);
assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
}
#[test]
fn test_block_bootstrap_block_size_one_matches_iid_distribution() {
let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
let result = minimal_result(trades);
let iid = MonteCarloConfig::default().seed(1).run(&result);
let bb = MonteCarloConfig::default()
.method(MonteCarloMethod::BlockBootstrap { block_size: 1 })
.seed(1)
.run(&result);
let iid_spread = iid.total_return.p95 - iid.total_return.p5;
let bb_spread = bb.total_return.p95 - bb.total_return.p5;
assert!(iid_spread < 1e-6, "iid spread {iid_spread}");
assert!(bb_spread < 1e-6, "bb spread {bb_spread}");
}
#[test]
fn test_stationary_bootstrap_percentile_ordering() {
let trades = vec![
make_trade(100.0, 120.0, 10.0),
make_trade(100.0, 80.0, 10.0),
make_trade(100.0, 130.0, 10.0),
make_trade(100.0, 75.0, 10.0),
make_trade(100.0, 110.0, 10.0),
make_trade(100.0, 95.0, 10.0),
];
let result = minimal_result(trades);
let mc = MonteCarloConfig::default()
.method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
.num_simulations(500)
.seed(5)
.run(&result);
assert!(mc.total_return.p5 <= mc.total_return.p50);
assert!(mc.total_return.p50 <= mc.total_return.p95);
}
#[test]
fn test_stationary_bootstrap_reproducible() {
let result = minimal_result(mixed_trades());
let config = MonteCarloConfig::default()
.method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
.seed(77);
let mc1 = config.run(&result);
let mc2 = config.run(&result);
assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
}
#[test]
fn test_parametric_percentile_ordering() {
let trades = vec![
make_trade(100.0, 120.0, 10.0),
make_trade(100.0, 80.0, 10.0),
make_trade(100.0, 130.0, 10.0),
make_trade(100.0, 75.0, 10.0),
make_trade(100.0, 110.0, 10.0),
];
let result = minimal_result(trades);
let mc = MonteCarloConfig::default()
.method(MonteCarloMethod::Parametric)
.num_simulations(1000)
.seed(3)
.run(&result);
assert!(mc.total_return.p5 <= mc.total_return.p25);
assert!(mc.total_return.p25 <= mc.total_return.p50);
assert!(mc.total_return.p50 <= mc.total_return.p75);
assert!(mc.total_return.p75 <= mc.total_return.p95);
}
#[test]
fn test_parametric_reproducible() {
let result = minimal_result(mixed_trades());
let config = MonteCarloConfig::default()
.method(MonteCarloMethod::Parametric)
.seed(99);
let mc1 = config.run(&result);
let mc2 = config.run(&result);
assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
}
#[test]
fn test_parametric_identical_trades_tight_distribution() {
let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
let result = minimal_result(trades);
let mc = MonteCarloConfig::default()
.method(MonteCarloMethod::Parametric)
.seed(1)
.run(&result);
let spread = mc.total_return.p95 - mc.total_return.p5;
assert!(
spread < 1e-6,
"expected tight spread for zero-variance trades, got {spread}"
);
}
#[test]
fn test_xorshift_never_zero() {
let mut rng = Xorshift64::new(0); for _ in 0..1000 {
assert_ne!(rng.next(), 0);
}
}
#[test]
fn test_next_f64_positive_in_range() {
let mut rng = Xorshift64::new(42);
for _ in 0..10_000 {
let v = rng.next_f64_positive();
assert!(v > 0.0 && v <= 1.0, "out of range: {v}");
}
}
#[test]
fn test_profit_factor_all_wins_is_f64_max() {
let (_, _, _, pf) = run_simulation(&[0.01, 0.02, 0.03], 10_000.0, 1.0, 252.0);
assert_eq!(pf, f64::MAX);
}
#[test]
fn test_result_carries_method() {
let result = minimal_result(mixed_trades());
let mc = MonteCarloConfig::default()
.method(MonteCarloMethod::BlockBootstrap { block_size: 3 })
.run(&result);
assert!(matches!(
mc.method,
MonteCarloMethod::BlockBootstrap { block_size: 3 }
));
}
}