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::prelude::*;
8use rand::rngs::StdRng;
9use rand::SeedableRng;
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("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            // simple fractional returns: E_t = E_{t-1} * (1 + r)
154            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]); // some volatile returns
305        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]); // all negative
319        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        // CVaR averages the worst 5%, so it should be <= VaR (more negative)
326        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]); // zero vol
332        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}