use crate::{BacktestError, BacktestResult};
use rand::prelude::*;
use rand::rngs::StdRng;
use rand::SeedableRng;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MonteCarloConfig {
pub n_simulations: usize,
pub seed: u64,
}
impl Default for MonteCarloConfig {
fn default() -> Self {
Self {
n_simulations: 1_000,
seed: 42,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MonteCarloSummary {
pub mean_final_equity: f64,
pub p5_final_equity: f64,
pub p50_final_equity: f64,
pub p95_final_equity: f64,
pub probability_of_loss: f64,
pub n_simulations: usize,
pub n_trades_sampled: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MonteCarloReturnConfig {
pub n_simulations: usize,
pub seed: u64,
pub n_bars_forward: usize,
}
impl Default for MonteCarloReturnConfig {
fn default() -> Self {
Self {
n_simulations: 1_000,
seed: 42,
n_bars_forward: 252,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MonteCarloPathSummary {
pub var_95: f64,
pub cvar_95: f64,
pub p5_terminal_equity: f64,
pub p50_terminal_equity: f64,
pub p95_terminal_equity: f64,
pub probability_of_loss: f64,
}
pub fn monte_carlo_trade_bootstrap(
result: &BacktestResult,
initial_cash: f64,
config: &MonteCarloConfig,
) -> Result<MonteCarloSummary, BacktestError> {
if config.n_simulations == 0 {
return Err(BacktestError::InvalidInput(
"n_simulations must be > 0".into(),
));
}
let pnls = extract_trade_pnls(result);
if pnls.is_empty() {
return Ok(MonteCarloSummary {
mean_final_equity: initial_cash,
p5_final_equity: initial_cash,
p50_final_equity: initial_cash,
p95_final_equity: initial_cash,
probability_of_loss: 0.0,
n_simulations: config.n_simulations,
n_trades_sampled: 0,
});
}
let mut rng = StdRng::seed_from_u64(config.seed);
let n_trades = pnls.len();
let mut finals = Vec::with_capacity(config.n_simulations);
for _ in 0..config.n_simulations {
let mut equity = initial_cash;
for _ in 0..n_trades {
let idx = rng.gen_range(0..n_trades);
equity += pnls[idx];
}
finals.push(equity);
}
finals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = finals.len();
let mean = finals.iter().sum::<f64>() / n as f64;
let p5 = percentile(&finals, 0.05);
let p50 = percentile(&finals, 0.50);
let p95 = percentile(&finals, 0.95);
let prob_loss = finals.iter().filter(|&&e| e < initial_cash).count() as f64 / n as f64;
Ok(MonteCarloSummary {
mean_final_equity: mean,
p5_final_equity: p5,
p50_final_equity: p50,
p95_final_equity: p95,
probability_of_loss: prob_loss,
n_simulations: config.n_simulations,
n_trades_sampled: n_trades,
})
}
pub fn monte_carlo_return_paths(
result: &BacktestResult,
config: &MonteCarloReturnConfig,
) -> Result<MonteCarloPathSummary, BacktestError> {
if config.n_simulations == 0 || config.n_bars_forward == 0 {
return Err(BacktestError::InvalidInput("n_simulations and n_bars_forward must be > 0".into()));
}
let returns = extract_bar_returns(result);
let initial_cash = *result.stats.get("initial_cash").unwrap_or(&100_000.0);
if returns.is_empty() {
return Ok(MonteCarloPathSummary {
var_95: 0.0,
cvar_95: 0.0,
p5_terminal_equity: initial_cash,
p50_terminal_equity: initial_cash,
p95_terminal_equity: initial_cash,
probability_of_loss: 0.0,
});
}
let mut rng = StdRng::seed_from_u64(config.seed);
let mut finals = Vec::with_capacity(config.n_simulations);
for _ in 0..config.n_simulations {
let mut equity = initial_cash;
for _ in 0..config.n_bars_forward {
let idx = rng.gen_range(0..returns.len());
equity *= 1.0 + returns[idx];
}
finals.push(equity);
}
finals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = finals.len();
let p5 = percentile(&finals, 0.05);
let p50 = percentile(&finals, 0.50);
let p95 = percentile(&finals, 0.95);
let prob_loss = finals.iter().filter(|&&e| e < initial_cash).count() as f64 / n as f64;
let mut pnls: Vec<f64> = finals.iter().map(|&e| e - initial_cash).collect();
pnls.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let var_95 = percentile(&pnls, 0.05);
let tail: Vec<f64> = pnls.into_iter().filter(|&pnl| pnl <= var_95).collect();
let cvar_95 = if tail.is_empty() {
var_95
} else {
tail.iter().sum::<f64>() / tail.len() as f64
};
Ok(MonteCarloPathSummary {
var_95,
cvar_95,
p5_terminal_equity: p5,
p50_terminal_equity: p50,
p95_terminal_equity: p95,
probability_of_loss: prob_loss,
})
}
fn extract_bar_returns(result: &BacktestResult) -> Vec<f64> {
let Ok(col) = result.equity_curve.column("equity") else { return Vec::new(); };
let Ok(ca) = col.f64() else { return Vec::new(); };
let eq: Vec<f64> = ca.into_iter().map(|v| v.unwrap_or(0.0)).collect();
if eq.len() < 2 {
return Vec::new();
}
let mut rets = Vec::with_capacity(eq.len() - 1);
for i in 1..eq.len() {
let prev = eq[i - 1];
if prev != 0.0 {
rets.push((eq[i] - prev) / prev);
} else {
rets.push(0.0);
}
}
rets
}
fn extract_trade_pnls(result: &BacktestResult) -> Vec<f64> {
let Ok(col) = result.trades.column("pnl_net") else {
return Vec::new();
};
let Ok(ca) = col.f64() else {
return Vec::new();
};
ca.into_iter().map(|v| v.unwrap_or(0.0)).collect()
}
fn percentile(sorted: &[f64], p: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let idx = ((sorted.len() - 1) as f64 * p).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
#[cfg(test)]
mod tests {
use super::*;
use polars::prelude::*;
fn result_with_pnls(pnls: &[f64], initial: f64) -> BacktestResult {
let trades = DataFrame::new(vec![Column::new("pnl_net".into(), pnls.to_vec())]).unwrap();
let equity = DataFrame::new(vec![
Column::new("ts".into(), vec![1i64, 2]),
Column::new("equity".into(), vec![initial, initial + pnls.iter().sum::<f64>()]),
Column::new("cash".into(), vec![initial, initial]),
Column::new("position".into(), vec![0.0, 0.0]),
Column::new("close".into(), vec![100.0, 100.0]),
])
.unwrap();
BacktestResult {
trades,
equity_curve: equity,
stats: std::collections::HashMap::from([
("initial_cash".to_string(), initial),
("final_equity".to_string(), initial + pnls.iter().sum::<f64>()),
]),
}
}
#[test]
fn test_monte_carlo_deterministic_with_seed() {
let result = result_with_pnls(&[100.0, -50.0, 25.0], 100_000.0);
let cfg = MonteCarloConfig {
n_simulations: 500,
seed: 99,
};
let a = monte_carlo_trade_bootstrap(&result, 100_000.0, &cfg).unwrap();
let b = monte_carlo_trade_bootstrap(&result, 100_000.0, &cfg).unwrap();
assert_eq!(a, b);
assert_eq!(a.n_trades_sampled, 3);
}
#[test]
fn test_monte_carlo_zero_trades_flat() {
let result = result_with_pnls(&[], 100_000.0);
let summary =
monte_carlo_trade_bootstrap(&result, 100_000.0, &MonteCarloConfig::default()).unwrap();
assert_eq!(summary.mean_final_equity, 100_000.0);
assert_eq!(summary.probability_of_loss, 0.0);
}
#[test]
fn test_monte_carlo_all_negative_trades_high_prob_loss() {
let result = result_with_pnls(&[-10.0, -10.0, -10.0, -10.0], 100_000.0);
let summary = monte_carlo_trade_bootstrap(
&result,
100_000.0,
&MonteCarloConfig {
n_simulations: 200,
seed: 1,
},
)
.unwrap();
assert_eq!(summary.probability_of_loss, 1.0);
assert!(summary.mean_final_equity < 100_000.0);
}
fn result_with_equity(eqs: &[f64]) -> BacktestResult {
let equity = DataFrame::new(vec![
Column::new("equity".into(), eqs.to_vec()),
]).unwrap();
BacktestResult {
trades: DataFrame::empty(),
equity_curve: equity,
stats: std::collections::HashMap::from([
("initial_cash".to_string(), eqs.first().copied().unwrap_or(100_000.0)),
]),
}
}
#[test]
fn test_mc_returns_deterministic_seed() {
let result = result_with_equity(&[100.0, 105.0, 95.0, 100.0]); let cfg = MonteCarloReturnConfig {
n_simulations: 100,
seed: 42,
n_bars_forward: 50,
};
let a = monte_carlo_return_paths(&result, &cfg).unwrap();
let b = monte_carlo_return_paths(&result, &cfg).unwrap();
assert_eq!(a.var_95, b.var_95);
assert_eq!(a.cvar_95, b.cvar_95);
}
#[test]
fn test_mc_var_cvar_ordering() {
let result = result_with_equity(&[100.0, 95.0, 90.0, 85.0]); let cfg = MonteCarloReturnConfig {
n_simulations: 1000,
seed: 123,
n_bars_forward: 10,
};
let summary = monte_carlo_return_paths(&result, &cfg).unwrap();
assert!(summary.cvar_95 <= summary.var_95);
}
#[test]
fn test_mc_zero_vol_flat_paths() {
let result = result_with_equity(&[100.0, 100.0, 100.0, 100.0]); let cfg = MonteCarloReturnConfig {
n_simulations: 100,
seed: 1,
n_bars_forward: 20,
};
let summary = monte_carlo_return_paths(&result, &cfg).unwrap();
assert_eq!(summary.p5_terminal_equity, 100.0);
assert_eq!(summary.p50_terminal_equity, 100.0);
assert_eq!(summary.p95_terminal_equity, 100.0);
assert_eq!(summary.probability_of_loss, 0.0);
assert_eq!(summary.var_95, 0.0);
assert_eq!(summary.cvar_95, 0.0);
}
#[test]
fn test_mc_integration_after_backtest_with_report() {
use crate::{BacktestEngine, BacktestConfig};
let mut cfg = BacktestConfig::default();
cfg.cost_model.initial_cash = 100_000.0;
let engine = BacktestEngine::new(cfg);
let df = DataFrame::new(vec![
Column::new("timestamp".into(), vec![1i64, 2, 3]),
Column::new("close".into(), vec![100.0, 105.0, 110.0]),
Column::new("signal".into(), vec![1.0, 1.0, 0.0]),
]).unwrap();
let report = engine.backtest_with_report(df.lazy()).unwrap();
let mc_cfg = MonteCarloReturnConfig {
n_simulations: 10,
seed: 0,
n_bars_forward: 5,
};
let mc_summary = monte_carlo_return_paths(&report.result, &mc_cfg).unwrap();
assert!(mc_summary.p50_terminal_equity > 0.0);
}
}