1use crate::{BacktestError, BacktestResult};
7use rand::SeedableRng;
8use rand::prelude::*;
9use rand::rngs::StdRng;
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(
130 "n_simulations and n_bars_forward must be > 0".into(),
131 ));
132 }
133
134 let returns = extract_bar_returns(result);
135 let initial_cash = *result.stats.get("initial_cash").unwrap_or(&100_000.0);
136
137 if returns.is_empty() {
138 return Ok(MonteCarloPathSummary {
139 var_95: 0.0,
140 cvar_95: 0.0,
141 p5_terminal_equity: initial_cash,
142 p50_terminal_equity: initial_cash,
143 p95_terminal_equity: initial_cash,
144 probability_of_loss: 0.0,
145 });
146 }
147
148 let mut rng = StdRng::seed_from_u64(config.seed);
149 let mut finals = Vec::with_capacity(config.n_simulations);
150
151 for _ in 0..config.n_simulations {
152 let mut equity = initial_cash;
153 for _ in 0..config.n_bars_forward {
154 let idx = rng.gen_range(0..returns.len());
155 equity *= 1.0 + returns[idx];
157 }
158 finals.push(equity);
159 }
160
161 finals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
162 let n = finals.len();
163
164 let p5 = percentile(&finals, 0.05);
165 let p50 = percentile(&finals, 0.50);
166 let p95 = percentile(&finals, 0.95);
167 let prob_loss = finals.iter().filter(|&&e| e < initial_cash).count() as f64 / n as f64;
168
169 let mut pnls: Vec<f64> = finals.iter().map(|&e| e - initial_cash).collect();
170 pnls.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
171
172 let var_95 = percentile(&pnls, 0.05);
173
174 let tail: Vec<f64> = pnls.into_iter().filter(|&pnl| pnl <= var_95).collect();
175 let cvar_95 = if tail.is_empty() {
176 var_95
177 } else {
178 tail.iter().sum::<f64>() / tail.len() as f64
179 };
180
181 Ok(MonteCarloPathSummary {
182 var_95,
183 cvar_95,
184 p5_terminal_equity: p5,
185 p50_terminal_equity: p50,
186 p95_terminal_equity: p95,
187 probability_of_loss: prob_loss,
188 })
189}
190
191fn extract_bar_returns(result: &BacktestResult) -> Vec<f64> {
192 let Ok(col) = result.equity_curve.column("equity") else {
193 return Vec::new();
194 };
195 let Ok(ca) = col.f64() else {
196 return Vec::new();
197 };
198 let eq: Vec<f64> = ca.into_iter().map(|v| v.unwrap_or(0.0)).collect();
199 if eq.len() < 2 {
200 return Vec::new();
201 }
202 let mut rets = Vec::with_capacity(eq.len() - 1);
203 for i in 1..eq.len() {
204 let prev = eq[i - 1];
205 if prev != 0.0 {
206 rets.push((eq[i] - prev) / prev);
207 } else {
208 rets.push(0.0);
209 }
210 }
211 rets
212}
213
214fn extract_trade_pnls(result: &BacktestResult) -> Vec<f64> {
215 let Ok(col) = result.trades.column("pnl_net") else {
216 return Vec::new();
217 };
218 let Ok(ca) = col.f64() else {
219 return Vec::new();
220 };
221 ca.into_iter().map(|v| v.unwrap_or(0.0)).collect()
222}
223
224fn percentile(sorted: &[f64], p: f64) -> f64 {
225 if sorted.is_empty() {
226 return 0.0;
227 }
228 let idx = ((sorted.len() - 1) as f64 * p).round() as usize;
229 sorted[idx.min(sorted.len() - 1)]
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use polars::prelude::*;
236
237 fn result_with_pnls(pnls: &[f64], initial: f64) -> BacktestResult {
238 let trades = DataFrame::new(vec![Column::new("pnl_net".into(), pnls.to_vec())]).unwrap();
239 let equity = DataFrame::new(vec![
240 Column::new("ts".into(), vec![1i64, 2]),
241 Column::new(
242 "equity".into(),
243 vec![initial, initial + pnls.iter().sum::<f64>()],
244 ),
245 Column::new("cash".into(), vec![initial, initial]),
246 Column::new("position".into(), vec![0.0, 0.0]),
247 Column::new("close".into(), vec![100.0, 100.0]),
248 ])
249 .unwrap();
250 BacktestResult {
251 trades,
252 equity_curve: equity,
253 stats: std::collections::HashMap::from([
254 ("initial_cash".to_string(), initial),
255 (
256 "final_equity".to_string(),
257 initial + pnls.iter().sum::<f64>(),
258 ),
259 ]),
260 }
261 }
262
263 #[test]
264 fn test_monte_carlo_deterministic_with_seed() {
265 let result = result_with_pnls(&[100.0, -50.0, 25.0], 100_000.0);
266 let cfg = MonteCarloConfig {
267 n_simulations: 500,
268 seed: 99,
269 };
270 let a = monte_carlo_trade_bootstrap(&result, 100_000.0, &cfg).unwrap();
271 let b = monte_carlo_trade_bootstrap(&result, 100_000.0, &cfg).unwrap();
272 assert_eq!(a, b);
273 assert_eq!(a.n_trades_sampled, 3);
274 }
275
276 #[test]
277 fn test_monte_carlo_zero_trades_flat() {
278 let result = result_with_pnls(&[], 100_000.0);
279 let summary =
280 monte_carlo_trade_bootstrap(&result, 100_000.0, &MonteCarloConfig::default()).unwrap();
281 assert_eq!(summary.mean_final_equity, 100_000.0);
282 assert_eq!(summary.probability_of_loss, 0.0);
283 }
284
285 #[test]
286 fn test_monte_carlo_all_negative_trades_high_prob_loss() {
287 let result = result_with_pnls(&[-10.0, -10.0, -10.0, -10.0], 100_000.0);
288 let summary = monte_carlo_trade_bootstrap(
289 &result,
290 100_000.0,
291 &MonteCarloConfig {
292 n_simulations: 200,
293 seed: 1,
294 },
295 )
296 .unwrap();
297 assert_eq!(summary.probability_of_loss, 1.0);
298 assert!(summary.mean_final_equity < 100_000.0);
299 }
300
301 fn result_with_equity(eqs: &[f64]) -> BacktestResult {
302 let equity = DataFrame::new(vec![Column::new("equity".into(), eqs.to_vec())]).unwrap();
303 BacktestResult {
304 trades: DataFrame::empty(),
305 equity_curve: equity,
306 stats: std::collections::HashMap::from([(
307 "initial_cash".to_string(),
308 eqs.first().copied().unwrap_or(100_000.0),
309 )]),
310 }
311 }
312
313 #[test]
314 fn test_mc_returns_deterministic_seed() {
315 let result = result_with_equity(&[100.0, 105.0, 95.0, 100.0]); let cfg = MonteCarloReturnConfig {
317 n_simulations: 100,
318 seed: 42,
319 n_bars_forward: 50,
320 };
321 let a = monte_carlo_return_paths(&result, &cfg).unwrap();
322 let b = monte_carlo_return_paths(&result, &cfg).unwrap();
323 assert_eq!(a.var_95, b.var_95);
324 assert_eq!(a.cvar_95, b.cvar_95);
325 }
326
327 #[test]
328 fn test_mc_var_cvar_ordering() {
329 let result = result_with_equity(&[100.0, 95.0, 90.0, 85.0]); let cfg = MonteCarloReturnConfig {
331 n_simulations: 1000,
332 seed: 123,
333 n_bars_forward: 10,
334 };
335 let summary = monte_carlo_return_paths(&result, &cfg).unwrap();
336 assert!(summary.cvar_95 <= summary.var_95);
338 }
339
340 #[test]
341 fn test_mc_zero_vol_flat_paths() {
342 let result = result_with_equity(&[100.0, 100.0, 100.0, 100.0]); let cfg = MonteCarloReturnConfig {
344 n_simulations: 100,
345 seed: 1,
346 n_bars_forward: 20,
347 };
348 let summary = monte_carlo_return_paths(&result, &cfg).unwrap();
349 assert_eq!(summary.p5_terminal_equity, 100.0);
350 assert_eq!(summary.p50_terminal_equity, 100.0);
351 assert_eq!(summary.p95_terminal_equity, 100.0);
352 assert_eq!(summary.probability_of_loss, 0.0);
353 assert_eq!(summary.var_95, 0.0);
354 assert_eq!(summary.cvar_95, 0.0);
355 }
356
357 #[test]
358 fn test_mc_integration_after_backtest_with_report() {
359 use crate::{BacktestConfig, BacktestEngine};
360 let mut cfg = BacktestConfig::default();
361 cfg.cost_model.initial_cash = 100_000.0;
362 let engine = BacktestEngine::new(cfg);
363
364 let df = DataFrame::new(vec![
365 Column::new("timestamp".into(), vec![1i64, 2, 3]),
366 Column::new("close".into(), vec![100.0, 105.0, 110.0]),
367 Column::new("signal".into(), vec![1.0, 1.0, 0.0]),
368 ])
369 .unwrap();
370
371 let report = engine.backtest_with_report(df.lazy()).unwrap();
372 let mc_cfg = MonteCarloReturnConfig {
373 n_simulations: 10,
374 seed: 0,
375 n_bars_forward: 5,
376 };
377 let mc_summary = monte_carlo_return_paths(&report.result, &mc_cfg).unwrap();
378 assert!(mc_summary.p50_terminal_equity > 0.0);
379 }
380}