Skip to main content

quantwave_backtest/
monte_carlo.rs

1//! Trade PnL bootstrap Monte Carlo (quantwave-cr6v.14 / quantwave-xibc).
2//!
3//! Clean-room resampling of closed-trade `pnl_net` values (RaptorBT MC concepts;
4//! v1 uses trade bootstrap rather than GBM forward paths).
5
6use crate::{BacktestError, BacktestResult};
7use rand::SeedableRng;
8use rand::prelude::*;
9use rand::rngs::StdRng;
10use serde::{Deserialize, Serialize};
11
12/// Bootstrap simulation settings.
13#[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/// Summary of bootstrap terminal equity distribution.
29#[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
67/// Bootstrap closed-trade PnLs with replacement; return terminal equity stats.
68pub 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            // simple fractional returns: E_t = E_{t-1} * (1 + r)
156            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]); // some volatile returns
316        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]); // all negative
330        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        // CVaR averages the worst 5%, so it should be <= VaR (more negative)
337        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]); // zero vol
343        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}