1use crate::{BacktestError, BacktestResult};
7use rand::prelude::*;
8use rand::rngs::StdRng;
9use rand::SeedableRng;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct MonteCarloConfig {
15 pub n_simulations: usize,
16 pub seed: u64,
17}
18
19impl Default for MonteCarloConfig {
20 fn default() -> Self {
21 Self {
22 n_simulations: 1_000,
23 seed: 42,
24 }
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct MonteCarloSummary {
31 pub mean_final_equity: f64,
32 pub p5_final_equity: f64,
33 pub p50_final_equity: f64,
34 pub p95_final_equity: f64,
35 pub probability_of_loss: f64,
36 pub n_simulations: usize,
37 pub n_trades_sampled: usize,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41pub struct MonteCarloReturnConfig {
42 pub n_simulations: usize,
43 pub seed: u64,
44 pub n_bars_forward: usize,
45}
46
47impl Default for MonteCarloReturnConfig {
48 fn default() -> Self {
49 Self {
50 n_simulations: 1_000,
51 seed: 42,
52 n_bars_forward: 252,
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct MonteCarloPathSummary {
59 pub var_95: f64,
60 pub cvar_95: f64,
61 pub p5_terminal_equity: f64,
62 pub p50_terminal_equity: f64,
63 pub p95_terminal_equity: f64,
64 pub probability_of_loss: f64,
65}
66
67pub fn monte_carlo_trade_bootstrap(
69 result: &BacktestResult,
70 initial_cash: f64,
71 config: &MonteCarloConfig,
72) -> Result<MonteCarloSummary, BacktestError> {
73 if config.n_simulations == 0 {
74 return Err(BacktestError::InvalidInput(
75 "n_simulations must be > 0".into(),
76 ));
77 }
78
79 let pnls = extract_trade_pnls(result);
80 if pnls.is_empty() {
81 return Ok(MonteCarloSummary {
82 mean_final_equity: initial_cash,
83 p5_final_equity: initial_cash,
84 p50_final_equity: initial_cash,
85 p95_final_equity: initial_cash,
86 probability_of_loss: 0.0,
87 n_simulations: config.n_simulations,
88 n_trades_sampled: 0,
89 });
90 }
91
92 let mut rng = StdRng::seed_from_u64(config.seed);
93 let n_trades = pnls.len();
94 let mut finals = Vec::with_capacity(config.n_simulations);
95
96 for _ in 0..config.n_simulations {
97 let mut equity = initial_cash;
98 for _ in 0..n_trades {
99 let idx = rng.gen_range(0..n_trades);
100 equity += pnls[idx];
101 }
102 finals.push(equity);
103 }
104
105 finals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
106 let n = finals.len();
107 let mean = finals.iter().sum::<f64>() / n as f64;
108 let p5 = percentile(&finals, 0.05);
109 let p50 = percentile(&finals, 0.50);
110 let p95 = percentile(&finals, 0.95);
111 let prob_loss = finals.iter().filter(|&&e| e < initial_cash).count() as f64 / n as f64;
112
113 Ok(MonteCarloSummary {
114 mean_final_equity: mean,
115 p5_final_equity: p5,
116 p50_final_equity: p50,
117 p95_final_equity: p95,
118 probability_of_loss: prob_loss,
119 n_simulations: config.n_simulations,
120 n_trades_sampled: n_trades,
121 })
122}
123
124pub fn monte_carlo_return_paths(
125 result: &BacktestResult,
126 config: &MonteCarloReturnConfig,
127) -> Result<MonteCarloPathSummary, BacktestError> {
128 if config.n_simulations == 0 || config.n_bars_forward == 0 {
129 return Err(BacktestError::InvalidInput("n_simulations and n_bars_forward must be > 0".into()));
130 }
131
132 let returns = extract_bar_returns(result);
133 let initial_cash = *result.stats.get("initial_cash").unwrap_or(&100_000.0);
134
135 if returns.is_empty() {
136 return Ok(MonteCarloPathSummary {
137 var_95: 0.0,
138 cvar_95: 0.0,
139 p5_terminal_equity: initial_cash,
140 p50_terminal_equity: initial_cash,
141 p95_terminal_equity: initial_cash,
142 probability_of_loss: 0.0,
143 });
144 }
145
146 let mut rng = StdRng::seed_from_u64(config.seed);
147 let mut finals = Vec::with_capacity(config.n_simulations);
148
149 for _ in 0..config.n_simulations {
150 let mut equity = initial_cash;
151 for _ in 0..config.n_bars_forward {
152 let idx = rng.gen_range(0..returns.len());
153 equity *= 1.0 + returns[idx];
155 }
156 finals.push(equity);
157 }
158
159 finals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
160 let n = finals.len();
161
162 let p5 = percentile(&finals, 0.05);
163 let p50 = percentile(&finals, 0.50);
164 let p95 = percentile(&finals, 0.95);
165 let prob_loss = finals.iter().filter(|&&e| e < initial_cash).count() as f64 / n as f64;
166
167 let mut pnls: Vec<f64> = finals.iter().map(|&e| e - initial_cash).collect();
168 pnls.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
169
170 let var_95 = percentile(&pnls, 0.05);
171
172 let tail: Vec<f64> = pnls.into_iter().filter(|&pnl| pnl <= var_95).collect();
173 let cvar_95 = if tail.is_empty() {
174 var_95
175 } else {
176 tail.iter().sum::<f64>() / tail.len() as f64
177 };
178
179 Ok(MonteCarloPathSummary {
180 var_95,
181 cvar_95,
182 p5_terminal_equity: p5,
183 p50_terminal_equity: p50,
184 p95_terminal_equity: p95,
185 probability_of_loss: prob_loss,
186 })
187}
188
189fn extract_bar_returns(result: &BacktestResult) -> Vec<f64> {
190 let Ok(col) = result.equity_curve.column("equity") else { return Vec::new(); };
191 let Ok(ca) = col.f64() else { return Vec::new(); };
192 let eq: Vec<f64> = ca.into_iter().map(|v| v.unwrap_or(0.0)).collect();
193 if eq.len() < 2 {
194 return Vec::new();
195 }
196 let mut rets = Vec::with_capacity(eq.len() - 1);
197 for i in 1..eq.len() {
198 let prev = eq[i - 1];
199 if prev != 0.0 {
200 rets.push((eq[i] - prev) / prev);
201 } else {
202 rets.push(0.0);
203 }
204 }
205 rets
206}
207
208fn extract_trade_pnls(result: &BacktestResult) -> Vec<f64> {
209 let Ok(col) = result.trades.column("pnl_net") else {
210 return Vec::new();
211 };
212 let Ok(ca) = col.f64() else {
213 return Vec::new();
214 };
215 ca.into_iter().map(|v| v.unwrap_or(0.0)).collect()
216}
217
218fn percentile(sorted: &[f64], p: f64) -> f64 {
219 if sorted.is_empty() {
220 return 0.0;
221 }
222 let idx = ((sorted.len() - 1) as f64 * p).round() as usize;
223 sorted[idx.min(sorted.len() - 1)]
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use polars::prelude::*;
230
231 fn result_with_pnls(pnls: &[f64], initial: f64) -> BacktestResult {
232 let trades = DataFrame::new(vec![Column::new("pnl_net".into(), pnls.to_vec())]).unwrap();
233 let equity = DataFrame::new(vec![
234 Column::new("ts".into(), vec![1i64, 2]),
235 Column::new("equity".into(), vec![initial, initial + pnls.iter().sum::<f64>()]),
236 Column::new("cash".into(), vec![initial, initial]),
237 Column::new("position".into(), vec![0.0, 0.0]),
238 Column::new("close".into(), vec![100.0, 100.0]),
239 ])
240 .unwrap();
241 BacktestResult {
242 trades,
243 equity_curve: equity,
244 stats: std::collections::HashMap::from([
245 ("initial_cash".to_string(), initial),
246 ("final_equity".to_string(), initial + pnls.iter().sum::<f64>()),
247 ]),
248 }
249 }
250
251 #[test]
252 fn test_monte_carlo_deterministic_with_seed() {
253 let result = result_with_pnls(&[100.0, -50.0, 25.0], 100_000.0);
254 let cfg = MonteCarloConfig {
255 n_simulations: 500,
256 seed: 99,
257 };
258 let a = monte_carlo_trade_bootstrap(&result, 100_000.0, &cfg).unwrap();
259 let b = monte_carlo_trade_bootstrap(&result, 100_000.0, &cfg).unwrap();
260 assert_eq!(a, b);
261 assert_eq!(a.n_trades_sampled, 3);
262 }
263
264 #[test]
265 fn test_monte_carlo_zero_trades_flat() {
266 let result = result_with_pnls(&[], 100_000.0);
267 let summary =
268 monte_carlo_trade_bootstrap(&result, 100_000.0, &MonteCarloConfig::default()).unwrap();
269 assert_eq!(summary.mean_final_equity, 100_000.0);
270 assert_eq!(summary.probability_of_loss, 0.0);
271 }
272
273 #[test]
274 fn test_monte_carlo_all_negative_trades_high_prob_loss() {
275 let result = result_with_pnls(&[-10.0, -10.0, -10.0, -10.0], 100_000.0);
276 let summary = monte_carlo_trade_bootstrap(
277 &result,
278 100_000.0,
279 &MonteCarloConfig {
280 n_simulations: 200,
281 seed: 1,
282 },
283 )
284 .unwrap();
285 assert_eq!(summary.probability_of_loss, 1.0);
286 assert!(summary.mean_final_equity < 100_000.0);
287 }
288
289 fn result_with_equity(eqs: &[f64]) -> BacktestResult {
290 let equity = DataFrame::new(vec![
291 Column::new("equity".into(), eqs.to_vec()),
292 ]).unwrap();
293 BacktestResult {
294 trades: DataFrame::empty(),
295 equity_curve: equity,
296 stats: std::collections::HashMap::from([
297 ("initial_cash".to_string(), eqs.first().copied().unwrap_or(100_000.0)),
298 ]),
299 }
300 }
301
302 #[test]
303 fn test_mc_returns_deterministic_seed() {
304 let result = result_with_equity(&[100.0, 105.0, 95.0, 100.0]); let cfg = MonteCarloReturnConfig {
306 n_simulations: 100,
307 seed: 42,
308 n_bars_forward: 50,
309 };
310 let a = monte_carlo_return_paths(&result, &cfg).unwrap();
311 let b = monte_carlo_return_paths(&result, &cfg).unwrap();
312 assert_eq!(a.var_95, b.var_95);
313 assert_eq!(a.cvar_95, b.cvar_95);
314 }
315
316 #[test]
317 fn test_mc_var_cvar_ordering() {
318 let result = result_with_equity(&[100.0, 95.0, 90.0, 85.0]); let cfg = MonteCarloReturnConfig {
320 n_simulations: 1000,
321 seed: 123,
322 n_bars_forward: 10,
323 };
324 let summary = monte_carlo_return_paths(&result, &cfg).unwrap();
325 assert!(summary.cvar_95 <= summary.var_95);
327 }
328
329 #[test]
330 fn test_mc_zero_vol_flat_paths() {
331 let result = result_with_equity(&[100.0, 100.0, 100.0, 100.0]); let cfg = MonteCarloReturnConfig {
333 n_simulations: 100,
334 seed: 1,
335 n_bars_forward: 20,
336 };
337 let summary = monte_carlo_return_paths(&result, &cfg).unwrap();
338 assert_eq!(summary.p5_terminal_equity, 100.0);
339 assert_eq!(summary.p50_terminal_equity, 100.0);
340 assert_eq!(summary.p95_terminal_equity, 100.0);
341 assert_eq!(summary.probability_of_loss, 0.0);
342 assert_eq!(summary.var_95, 0.0);
343 assert_eq!(summary.cvar_95, 0.0);
344 }
345
346 #[test]
347 fn test_mc_integration_after_backtest_with_report() {
348 use crate::{BacktestEngine, BacktestConfig};
349 let mut cfg = BacktestConfig::default();
350 cfg.cost_model.initial_cash = 100_000.0;
351 let engine = BacktestEngine::new(cfg);
352
353 let df = DataFrame::new(vec![
354 Column::new("timestamp".into(), vec![1i64, 2, 3]),
355 Column::new("close".into(), vec![100.0, 105.0, 110.0]),
356 Column::new("signal".into(), vec![1.0, 1.0, 0.0]),
357 ]).unwrap();
358
359 let report = engine.backtest_with_report(df.lazy()).unwrap();
360 let mc_cfg = MonteCarloReturnConfig {
361 n_simulations: 10,
362 seed: 0,
363 n_bars_forward: 5,
364 };
365 let mc_summary = monte_carlo_return_paths(&report.result, &mc_cfg).unwrap();
366 assert!(mc_summary.p50_terminal_equity > 0.0);
367 }
368}