Skip to main content

finance_query/backtesting/
monte_carlo.rs

1//! Monte Carlo simulation for backtesting results.
2//!
3//! Re-samples trade return sequences to estimate the distribution of outcomes.
4//! Uses an embedded xorshift64 PRNG to avoid adding an external dependency.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use finance_query::backtesting::monte_carlo::{MonteCarloConfig, MonteCarloMethod, MonteCarloResult};
10//!
11//! // `result` is a completed BacktestResult
12//! let mc = MonteCarloConfig::default()
13//!     .method(MonteCarloMethod::BlockBootstrap { block_size: 10 })
14//!     .run(&result);
15//! println!("Median return: {:.2}%", mc.total_return.p50);
16//! println!("5th pct drawdown: {:.2}%", mc.max_drawdown.p5 * 100.0);
17//! ```
18
19use serde::{Deserialize, Serialize};
20
21use super::result::BacktestResult;
22
23// ── Resampling method ─────────────────────────────────────────────────────────
24
25/// Resampling method used for Monte Carlo simulation.
26///
27/// Each method makes different assumptions about trade return structure.
28/// Choose based on your strategy's autocorrelation characteristics.
29#[non_exhaustive]
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub enum MonteCarloMethod {
32    /// Random IID shuffle (Fisher-Yates). Default.
33    ///
34    /// Treats every trade return as an independent, identically distributed draw
35    /// and randomises the sequence. Fast and appropriate for mean-reversion
36    /// strategies whose trades are mostly independent. Destroys autocorrelation,
37    /// which may underestimate the probability of sustained drawdowns for
38    /// trend-following strategies.
39    #[default]
40    IidShuffle,
41
42    /// Fixed-size block bootstrap.
43    ///
44    /// Samples consecutive blocks of `block_size` trades (with circular
45    /// wrap-around) and reassembles them in random order. Preserves short-range
46    /// autocorrelation and regime structure better than IID shuffle. A good
47    /// default block size is `sqrt(n_trades)`. More conservative than IID for
48    /// trending strategies.
49    BlockBootstrap {
50        /// Number of consecutive trades per block.
51        block_size: usize,
52    },
53
54    /// Stationary bootstrap with geometrically-distributed block lengths.
55    ///
56    /// Like `BlockBootstrap` but block length is drawn from
57    /// Geometric(1 / mean_block_size) at each step. Less sensitive to the choice
58    /// of block size than the fixed variant — a good default when you are
59    /// uncertain about the true autocorrelation length.
60    StationaryBootstrap {
61        /// Expected (average) number of trades per block.
62        mean_block_size: usize,
63    },
64
65    /// Parametric simulation assuming normally-distributed trade returns.
66    ///
67    /// Fits N(μ, σ) to the observed trade returns and generates synthetic
68    /// sequences by sampling from that distribution (Box-Muller transform).
69    /// Useful when the observed trade count is very small and non-parametric
70    /// resampling would produce near-identical sequences. Assumes normality,
71    /// which may not hold in fat-tailed markets.
72    Parametric,
73}
74
75// ── Configuration ─────────────────────────────────────────────────────────────
76
77/// Configuration for Monte Carlo simulation.
78#[non_exhaustive]
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct MonteCarloConfig {
81    /// Number of reshuffled equity-curve simulations to run.
82    ///
83    /// Higher values give more stable percentile estimates but take longer.
84    /// Default: `1000`.
85    pub num_simulations: usize,
86
87    /// Optional seed for the PRNG, enabling reproducible results.
88    ///
89    /// `None` (default) uses a fixed internal seed (`12345`). Provide an
90    /// explicit seed when you need deterministic output across runs.
91    pub seed: Option<u64>,
92
93    /// Resampling method. Default: [`MonteCarloMethod::IidShuffle`].
94    pub method: MonteCarloMethod,
95}
96
97impl Default for MonteCarloConfig {
98    fn default() -> Self {
99        Self {
100            num_simulations: 1_000,
101            seed: None,
102            method: MonteCarloMethod::IidShuffle,
103        }
104    }
105}
106
107impl MonteCarloConfig {
108    /// Create a new Monte Carlo configuration with default settings.
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Set the number of simulations.
114    pub fn num_simulations(mut self, n: usize) -> Self {
115        self.num_simulations = n;
116        self
117    }
118
119    /// Set a fixed PRNG seed for reproducible results.
120    pub fn seed(mut self, seed: u64) -> Self {
121        self.seed = Some(seed);
122        self
123    }
124
125    /// Set the resampling method.
126    pub fn method(mut self, method: MonteCarloMethod) -> Self {
127        self.method = method;
128        self
129    }
130
131    /// Run the Monte Carlo simulation against a completed backtest result.
132    ///
133    /// Extracts trade returns, generates `num_simulations` synthetic sequences
134    /// using the configured [`MonteCarloMethod`], rebuilds a synthetic equity
135    /// curve for each, and reports percentile statistics over all outcomes.
136    ///
137    /// If the result has fewer than 2 trades, or `num_simulations` is `0`,
138    /// every percentile is derived from the single observed result.
139    ///
140    /// Use the percentile outputs as a *relative* stress-test tool rather than
141    /// a precise probability statement about future performance.
142    pub fn run(&self, result: &BacktestResult) -> MonteCarloResult {
143        let initial_capital = result.initial_capital;
144        let trade_returns: Vec<f64> = result.trades.iter().map(|t| t.return_pct / 100.0).collect();
145
146        if trade_returns.len() < 2 || self.num_simulations == 0 {
147            // Not enough trades to reshuffle — return degenerate result
148            let obs_return = result.metrics.total_return_pct;
149            let obs_dd = result.metrics.max_drawdown_pct;
150            let obs_sharpe = result.metrics.sharpe_ratio;
151            let obs_pf = result.metrics.profit_factor;
152            let trivial = |v: f64| PercentileStats {
153                p5: v,
154                p25: v,
155                p50: v,
156                p75: v,
157                p95: v,
158                mean: v,
159            };
160            return MonteCarloResult {
161                num_simulations: self.num_simulations,
162                method: self.method.clone(),
163                total_return: trivial(obs_return),
164                max_drawdown: trivial(obs_dd),
165                sharpe_ratio: trivial(obs_sharpe),
166                profit_factor: trivial(obs_pf),
167            };
168        }
169
170        let seed = self.seed.unwrap_or(12345);
171        let mut rng = Xorshift64::new(seed);
172
173        let position_size = result.config.position_size_pct;
174        let num_bars = result.equity_curve.len().saturating_sub(1) as f64;
175        let years = if result.config.bars_per_year > 0.0 {
176            num_bars / result.config.bars_per_year
177        } else {
178            0.0
179        };
180        let periods_per_year = if years > 0.0 {
181            trade_returns.len() as f64 / years
182        } else {
183            trade_returns.len().max(1) as f64
184        };
185
186        let mut sim_returns: Vec<f64> = Vec::with_capacity(self.num_simulations);
187        let mut sim_drawdowns: Vec<f64> = Vec::with_capacity(self.num_simulations);
188        let mut sim_sharpes: Vec<f64> = Vec::with_capacity(self.num_simulations);
189        let mut sim_pfs: Vec<f64> = Vec::with_capacity(self.num_simulations);
190
191        // Single allocation reused across all simulations.
192        let mut sim_buf: Vec<f64> = vec![0.0; trade_returns.len()];
193
194        for _ in 0..self.num_simulations {
195            match &self.method {
196                MonteCarloMethod::IidShuffle => {
197                    sim_buf.copy_from_slice(&trade_returns);
198                    fisher_yates_shuffle(&mut sim_buf, &mut rng);
199                }
200                MonteCarloMethod::BlockBootstrap { block_size } => {
201                    block_bootstrap_into(&trade_returns, *block_size, &mut rng, &mut sim_buf);
202                }
203                MonteCarloMethod::StationaryBootstrap { mean_block_size } => {
204                    stationary_bootstrap_into(
205                        &trade_returns,
206                        *mean_block_size,
207                        &mut rng,
208                        &mut sim_buf,
209                    );
210                }
211                MonteCarloMethod::Parametric => {
212                    parametric_sample_into(&trade_returns, &mut rng, &mut sim_buf);
213                }
214            }
215
216            // Single-pass: equity curve, drawdown, Sharpe (Welford), profit factor — no allocation.
217            let (total_return, max_dd, sharpe, pf) =
218                run_simulation(&sim_buf, initial_capital, position_size, periods_per_year);
219
220            sim_returns.push(total_return);
221            sim_drawdowns.push(max_dd);
222            sim_sharpes.push(sharpe);
223            sim_pfs.push(pf);
224        }
225
226        MonteCarloResult {
227            num_simulations: self.num_simulations,
228            method: self.method.clone(),
229            total_return: PercentileStats::from_sorted(&mut sim_returns),
230            max_drawdown: PercentileStats::from_sorted(&mut sim_drawdowns),
231            sharpe_ratio: PercentileStats::from_sorted(&mut sim_sharpes),
232            profit_factor: PercentileStats::from_sorted(&mut sim_pfs),
233        }
234    }
235}
236
237// ── Output types ──────────────────────────────────────────────────────────────
238
239/// Percentile summary over the Monte Carlo simulations for a single metric.
240#[non_exhaustive]
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct PercentileStats {
243    /// 5th percentile (worst-case tail)
244    pub p5: f64,
245    /// 25th percentile (lower quartile)
246    pub p25: f64,
247    /// 50th percentile (median)
248    pub p50: f64,
249    /// 75th percentile (upper quartile)
250    pub p75: f64,
251    /// 95th percentile (best-case tail)
252    pub p95: f64,
253    /// Mean across all simulations
254    pub mean: f64,
255}
256
257impl PercentileStats {
258    /// Compute percentile stats from a slice (sorts in place for efficiency).
259    fn from_sorted(values: &mut [f64]) -> Self {
260        values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
261        let n = values.len();
262
263        let percentile = |p: f64| {
264            let idx = ((p / 100.0) * (n - 1) as f64).round() as usize;
265            values[idx.min(n - 1)]
266        };
267
268        let mean = values.iter().sum::<f64>() / n as f64;
269
270        Self {
271            p5: percentile(5.0),
272            p25: percentile(25.0),
273            p50: percentile(50.0),
274            p75: percentile(75.0),
275            p95: percentile(95.0),
276            mean,
277        }
278    }
279}
280
281/// Results of the Monte Carlo simulation.
282///
283/// Each field gives the distribution of that metric across all simulations.
284#[non_exhaustive]
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct MonteCarloResult {
287    /// Number of simulations that were run
288    pub num_simulations: usize,
289
290    /// Resampling method used to generate the simulations
291    pub method: MonteCarloMethod,
292
293    /// Distribution of total return (%) across simulations
294    pub total_return: PercentileStats,
295
296    /// Distribution of maximum drawdown (0.0–1.0) across simulations
297    pub max_drawdown: PercentileStats,
298
299    /// Distribution of Sharpe ratio across simulations.
300    ///
301    /// **Interpretation note:** this Sharpe is computed from inter-trade returns
302    /// (one data point per trade), not from bar-by-bar returns as in
303    /// [`PerformanceMetrics::sharpe_ratio`]. Annualisation uses
304    /// `sqrt(bars_per_year)`, which is only correct if trades occur every bar —
305    /// an assumption that is rarely satisfied. Use this field to *rank
306    /// simulations against each other*, not to compare against the
307    /// `PerformanceMetrics` value.
308    ///
309    /// [`PerformanceMetrics::sharpe_ratio`]: super::result::PerformanceMetrics::sharpe_ratio
310    pub sharpe_ratio: PercentileStats,
311
312    /// Distribution of profit factor across simulations
313    pub profit_factor: PercentileStats,
314}
315
316// ── PRNG: xorshift64 ──────────────────────────────────────────────────────────
317
318/// Minimal xorshift64 PRNG — avoids adding a `rand` dependency.
319///
320/// `pub(crate)` so that other backtesting submodules (e.g. `bayesian_search`)
321/// can reuse the same PRNG without duplicating the implementation.
322pub(crate) struct Xorshift64 {
323    state: u64,
324}
325
326impl Xorshift64 {
327    pub(crate) fn new(seed: u64) -> Self {
328        // Ensure state is never zero (xorshift requirement)
329        Self {
330            state: if seed == 0 { 1 } else { seed },
331        }
332    }
333
334    /// Generate the next pseudo-random u64.
335    pub(crate) fn next(&mut self) -> u64 {
336        let mut x = self.state;
337        x ^= x << 13;
338        x ^= x >> 7;
339        x ^= x << 17;
340        self.state = x;
341        x
342    }
343
344    /// Generate a random `usize` in `[0, n)`.
345    ///
346    /// Uses rejection sampling to eliminate modulo bias — otherwise the lower
347    /// values of `[0, n)` would be slightly more probable when `n` is not a
348    /// power of two (since `u64::MAX` is not evenly divisible by arbitrary `n`).
349    pub(crate) fn next_usize(&mut self, n: usize) -> usize {
350        let n64 = n as u64;
351        // The largest multiple of n64 that fits in u64; values in [threshold, u64::MAX]
352        // are rejected to ensure uniform distribution.
353        let threshold = u64::MAX - (u64::MAX % n64);
354        loop {
355            let x = self.next();
356            if x < threshold {
357                return (x % n64) as usize;
358            }
359        }
360    }
361
362    /// Generate a uniform `f64` in `(0, 1]` using 53-bit precision.
363    ///
364    /// Returns a value strictly greater than zero, making it safe for use as
365    /// the argument to `f64::ln()` in Box-Muller transforms.
366    pub(crate) fn next_f64_positive(&mut self) -> f64 {
367        // Take the top 53 bits (full double mantissa precision), add 1 to shift
368        // from [0, 2^53) to [1, 2^53], then scale to (0, 1].
369        ((self.next() >> 11) + 1) as f64 * (1.0 / (1u64 << 53) as f64)
370    }
371}
372
373// ── Sampling helpers ──────────────────────────────────────────────────────────
374
375/// Fisher-Yates in-place shuffle using the provided RNG.
376fn fisher_yates_shuffle(slice: &mut [f64], rng: &mut Xorshift64) {
377    for i in (1..slice.len()).rev() {
378        let j = rng.next_usize(i + 1);
379        slice.swap(i, j);
380    }
381}
382
383/// Block bootstrap sampler — fixed block size, circular wrap-around.
384///
385/// Draws random starting positions and copies `block_size` consecutive
386/// elements (wrapping around the end), filling `out` to exactly its length.
387fn block_bootstrap_into(trades: &[f64], block_size: usize, rng: &mut Xorshift64, out: &mut [f64]) {
388    let n = trades.len();
389    let block_size = block_size.max(1);
390    let mut filled = 0;
391    while filled < n {
392        let start = rng.next_usize(n);
393        let take = block_size.min(n - filled);
394        for i in 0..take {
395            out[filled + i] = trades[(start + i) % n];
396        }
397        filled += take;
398    }
399}
400
401/// Stationary bootstrap sampler — geometrically-distributed block lengths.
402///
403/// At each position, continues the current block with probability
404/// `(mean_block_size - 1) / mean_block_size`, or jumps to a new random start
405/// with probability `1 / mean_block_size`. Implemented without floating-point
406/// division by testing `rng.next_usize(mean_block_size) == 0`.
407fn stationary_bootstrap_into(
408    trades: &[f64],
409    mean_block_size: usize,
410    rng: &mut Xorshift64,
411    out: &mut [f64],
412) {
413    let n = trades.len();
414    let mean_block_size = mean_block_size.max(1);
415    let mut pos = rng.next_usize(n);
416    for slot in out.iter_mut() {
417        *slot = trades[pos % n];
418        if rng.next_usize(mean_block_size) == 0 {
419            // Start a new block at a random position.
420            pos = rng.next_usize(n);
421        } else {
422            pos += 1;
423        }
424    }
425}
426
427/// Parametric sampler — draws from N(μ, σ) fitted to `trades`.
428///
429/// Uses the Box-Muller transform to convert pairs of uniform draws into
430/// standard-normal samples, then shifts and scales by the empirical mean and
431/// standard deviation. When fewer than 2 trades exist (σ undefined), all
432/// samples are set to the empirical mean.
433fn parametric_sample_into(trades: &[f64], rng: &mut Xorshift64, out: &mut [f64]) {
434    let n = trades.len();
435    let mean = trades.iter().sum::<f64>() / n as f64;
436    let variance = if n > 1 {
437        trades.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0)
438    } else {
439        0.0
440    };
441    let std_dev = variance.sqrt();
442
443    // Nothing to sample if variance is zero.
444    if std_dev == 0.0 {
445        out.iter_mut().for_each(|v| *v = mean);
446        return;
447    }
448
449    let mut i = 0;
450    while i < n {
451        // Box-Muller: two uniform (0,1] draws → two independent standard-normal samples.
452        let u1 = rng.next_f64_positive();
453        let u2 = rng.next_f64_positive();
454        let mag = (-2.0 * u1.ln()).sqrt();
455        let angle = std::f64::consts::TAU * u2;
456        let z0 = mag * angle.cos();
457        let z1 = mag * angle.sin();
458
459        out[i] = mean + std_dev * z0;
460        if i + 1 < n {
461            out[i + 1] = mean + std_dev * z1;
462        }
463        i += 2;
464    }
465}
466
467// ── Equity-curve helpers ──────────────────────────────────────────────────────
468
469/// Build a per-trade equity curve from a sequence of trade returns.
470///
471/// Returns `(equity_points, final_equity)`. Each point represents the
472/// portfolio value after applying one trade's return to the previous equity.
473/// Single-pass simulation: computes total return, max drawdown, Sharpe ratio, and profit factor
474/// from a shuffled trade-return sequence without any heap allocations.
475///
476/// Replaces the four separate passes (build_equity_curve → compute_max_drawdown →
477/// compute_sharpe → compute_profit_factor) with one loop using Welford's online
478/// mean/variance algorithm for the Sharpe computation.
479fn run_simulation(
480    trade_returns: &[f64],
481    initial_capital: f64,
482    position_size_pct: f64,
483    periods_per_year: f64,
484) -> (f64, f64, f64, f64) {
485    let exposure = position_size_pct.max(0.0);
486    let mut equity = initial_capital;
487    let mut peak = initial_capital;
488    let mut max_dd = 0.0f64;
489    // Welford's online mean/variance for inter-trade returns (used for Sharpe).
490    let mut w_count = 0usize;
491    let mut w_mean = 0.0f64;
492    let mut w_m2 = 0.0f64;
493    // Profit factor accumulators.
494    let mut gross_profit = 0.0f64;
495    let mut gross_loss = 0.0f64;
496
497    let mut prev_equity = initial_capital;
498    for &ret in trade_returns {
499        equity *= 1.0 + ret * exposure;
500
501        // Drawdown
502        if equity > peak {
503            peak = equity;
504        }
505        if peak > 0.0 {
506            let dd = (peak - equity) / peak;
507            if dd > max_dd {
508                max_dd = dd;
509            }
510        }
511
512        // Inter-trade return for Sharpe (Welford update — no Vec needed)
513        let bar_ret = if prev_equity > 0.0 {
514            (equity - prev_equity) / prev_equity
515        } else {
516            0.0
517        };
518        prev_equity = equity;
519        w_count += 1;
520        let delta = bar_ret - w_mean;
521        w_mean += delta / w_count as f64;
522        w_m2 += delta * (bar_ret - w_mean);
523
524        // Profit factor
525        if ret > 0.0 {
526            gross_profit += ret;
527        } else if ret < 0.0 {
528            gross_loss += -ret;
529        }
530    }
531
532    let total_return = ((equity / initial_capital) - 1.0) * 100.0;
533
534    let sharpe = if w_count >= 2 {
535        let variance = w_m2 / (w_count - 1) as f64;
536        let std_dev = variance.sqrt();
537        if std_dev > 0.0 {
538            (w_mean / std_dev) * periods_per_year.sqrt()
539        } else {
540            0.0
541        }
542    } else {
543        0.0
544    };
545
546    let pf = if gross_loss > 0.0 {
547        gross_profit / gross_loss
548    } else if gross_profit > 0.0 {
549        f64::MAX
550    } else {
551        0.0
552    };
553
554    (total_return, max_dd, sharpe, pf)
555}
556
557// ── Tests ─────────────────────────────────────────────────────────────────────
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use crate::backtesting::result::{BacktestResult, EquityPoint, PerformanceMetrics};
563    use crate::backtesting::signal::Signal;
564    use crate::backtesting::{BacktestConfig, PositionSide, Trade};
565
566    fn make_signal() -> Signal {
567        Signal::long(0, 100.0)
568    }
569
570    fn make_trade(entry: f64, exit: f64, qty: f64) -> Trade {
571        Trade {
572            side: PositionSide::Long,
573            entry_timestamp: 0,
574            exit_timestamp: 86400,
575            entry_price: entry,
576            exit_price: exit,
577            quantity: qty,
578            entry_quantity: qty,
579            commission: 0.0,
580            transaction_tax: 0.0,
581            pnl: (exit - entry) * qty,
582            return_pct: ((exit / entry) - 1.0) * 100.0,
583            dividend_income: 0.0,
584            unreinvested_dividends: 0.0,
585            financing_cost: 0.0,
586            tags: Vec::new(),
587            is_partial: false,
588            scale_sequence: 0,
589            entry_signal: make_signal(),
590            exit_signal: Signal::exit(86400, exit),
591        }
592    }
593
594    fn make_equity_point(ts: i64, equity: f64) -> EquityPoint {
595        EquityPoint {
596            timestamp: ts,
597            equity,
598            drawdown_pct: 0.0,
599        }
600    }
601
602    fn minimal_result(trades: Vec<Trade>) -> BacktestResult {
603        let n_candles = 252;
604        let equity_curve: Vec<EquityPoint> = (0..n_candles)
605            .map(|i| make_equity_point(i as i64, 10_000.0))
606            .collect();
607
608        BacktestResult {
609            symbol: "TEST".into(),
610            strategy_name: "test".into(),
611            config: BacktestConfig::default(),
612            start_timestamp: 0,
613            end_timestamp: n_candles as i64,
614            initial_capital: 10_000.0,
615            final_equity: 10_000.0,
616            metrics: PerformanceMetrics::calculate(
617                &trades,
618                &equity_curve,
619                10_000.0,
620                0,
621                0,
622                0.0,
623                252.0,
624            ),
625            trades,
626            equity_curve,
627            signals: vec![],
628            open_position: None,
629            benchmark: None,
630            diagnostics: vec![],
631            max_leverage_used: 0.0,
632        }
633    }
634
635    fn mixed_trades() -> Vec<Trade> {
636        vec![
637            make_trade(100.0, 110.0, 10.0),
638            make_trade(100.0, 90.0, 10.0),
639            make_trade(100.0, 115.0, 10.0),
640            make_trade(100.0, 95.0, 10.0),
641        ]
642    }
643
644    // ── IidShuffle ──────────────────────────────────────────────────────────
645
646    #[test]
647    fn test_reproducible_with_seed() {
648        let result = minimal_result(mixed_trades());
649        let config = MonteCarloConfig::default().seed(42);
650        let mc1 = config.run(&result);
651        let mc2 = config.run(&result);
652
653        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
654        assert!((mc1.max_drawdown.p50 - mc2.max_drawdown.p50).abs() < f64::EPSILON);
655    }
656
657    #[test]
658    fn test_percentile_ordering() {
659        let trades = vec![
660            make_trade(100.0, 120.0, 10.0),
661            make_trade(100.0, 80.0, 10.0),
662            make_trade(100.0, 130.0, 10.0),
663            make_trade(100.0, 75.0, 10.0),
664            make_trade(100.0, 110.0, 10.0),
665        ];
666        let result = minimal_result(trades);
667        let mc = MonteCarloConfig::default()
668            .num_simulations(500)
669            .seed(1)
670            .run(&result);
671
672        assert!(mc.total_return.p5 <= mc.total_return.p25);
673        assert!(mc.total_return.p25 <= mc.total_return.p50);
674        assert!(mc.total_return.p50 <= mc.total_return.p75);
675        assert!(mc.total_return.p75 <= mc.total_return.p95);
676        assert!(mc.max_drawdown.p5 <= mc.max_drawdown.p95);
677    }
678
679    #[test]
680    fn test_degenerate_single_trade() {
681        let result = minimal_result(vec![make_trade(100.0, 110.0, 10.0)]);
682        let mc = MonteCarloConfig::default().run(&result);
683
684        // With only 1 trade there's nothing to resample — all percentiles equal observed
685        assert_eq!(mc.total_return.p5, mc.total_return.p50);
686        assert_eq!(mc.total_return.p50, mc.total_return.p95);
687    }
688
689    #[test]
690    fn test_all_winning_trades_tight_distribution() {
691        let trades: Vec<Trade> = (0..20).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
692        let result = minimal_result(trades);
693        let mc = MonteCarloConfig::default().seed(99).run(&result);
694
695        let spread = mc.total_return.p95 - mc.total_return.p5;
696        assert!(
697            spread < 1e-6,
698            "expected tight spread for identical trades, got {spread}"
699        );
700    }
701
702    #[test]
703    fn test_zero_simulations_returns_trivial_stats() {
704        let result = minimal_result(mixed_trades());
705        let mc = MonteCarloConfig::default().num_simulations(0).run(&result);
706
707        assert_eq!(mc.num_simulations, 0);
708        assert_eq!(mc.total_return.p5, mc.total_return.p50);
709        assert_eq!(mc.total_return.p50, mc.total_return.p95);
710        assert_eq!(mc.total_return.p50, result.metrics.total_return_pct);
711    }
712
713    // ── BlockBootstrap ──────────────────────────────────────────────────────
714
715    #[test]
716    fn test_block_bootstrap_percentile_ordering() {
717        let trades = vec![
718            make_trade(100.0, 120.0, 10.0),
719            make_trade(100.0, 80.0, 10.0),
720            make_trade(100.0, 130.0, 10.0),
721            make_trade(100.0, 75.0, 10.0),
722            make_trade(100.0, 110.0, 10.0),
723            make_trade(100.0, 95.0, 10.0),
724        ];
725        let result = minimal_result(trades);
726        let mc = MonteCarloConfig::default()
727            .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
728            .num_simulations(500)
729            .seed(7)
730            .run(&result);
731
732        assert!(mc.total_return.p5 <= mc.total_return.p50);
733        assert!(mc.total_return.p50 <= mc.total_return.p95);
734    }
735
736    #[test]
737    fn test_block_bootstrap_reproducible() {
738        let result = minimal_result(mixed_trades());
739        let config = MonteCarloConfig::default()
740            .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
741            .seed(13);
742        let mc1 = config.run(&result);
743        let mc2 = config.run(&result);
744
745        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
746    }
747
748    #[test]
749    fn test_block_bootstrap_block_size_one_matches_iid_distribution() {
750        // block_size=1 is equivalent to IID shuffle in terms of return distribution
751        // (same set of individual values, different orderings). Both should give
752        // the same set of possible total returns.
753        let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
754        let result = minimal_result(trades);
755
756        let iid = MonteCarloConfig::default().seed(1).run(&result);
757        let bb = MonteCarloConfig::default()
758            .method(MonteCarloMethod::BlockBootstrap { block_size: 1 })
759            .seed(1)
760            .run(&result);
761
762        // All identical trades → both should give the same tight distribution
763        let iid_spread = iid.total_return.p95 - iid.total_return.p5;
764        let bb_spread = bb.total_return.p95 - bb.total_return.p5;
765        assert!(iid_spread < 1e-6, "iid spread {iid_spread}");
766        assert!(bb_spread < 1e-6, "bb spread {bb_spread}");
767    }
768
769    // ── StationaryBootstrap ─────────────────────────────────────────────────
770
771    #[test]
772    fn test_stationary_bootstrap_percentile_ordering() {
773        let trades = vec![
774            make_trade(100.0, 120.0, 10.0),
775            make_trade(100.0, 80.0, 10.0),
776            make_trade(100.0, 130.0, 10.0),
777            make_trade(100.0, 75.0, 10.0),
778            make_trade(100.0, 110.0, 10.0),
779            make_trade(100.0, 95.0, 10.0),
780        ];
781        let result = minimal_result(trades);
782        let mc = MonteCarloConfig::default()
783            .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
784            .num_simulations(500)
785            .seed(5)
786            .run(&result);
787
788        assert!(mc.total_return.p5 <= mc.total_return.p50);
789        assert!(mc.total_return.p50 <= mc.total_return.p95);
790    }
791
792    #[test]
793    fn test_stationary_bootstrap_reproducible() {
794        let result = minimal_result(mixed_trades());
795        let config = MonteCarloConfig::default()
796            .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
797            .seed(77);
798        let mc1 = config.run(&result);
799        let mc2 = config.run(&result);
800
801        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
802    }
803
804    // ── Parametric ──────────────────────────────────────────────────────────
805
806    #[test]
807    fn test_parametric_percentile_ordering() {
808        let trades = vec![
809            make_trade(100.0, 120.0, 10.0),
810            make_trade(100.0, 80.0, 10.0),
811            make_trade(100.0, 130.0, 10.0),
812            make_trade(100.0, 75.0, 10.0),
813            make_trade(100.0, 110.0, 10.0),
814        ];
815        let result = minimal_result(trades);
816        let mc = MonteCarloConfig::default()
817            .method(MonteCarloMethod::Parametric)
818            .num_simulations(1000)
819            .seed(3)
820            .run(&result);
821
822        assert!(mc.total_return.p5 <= mc.total_return.p25);
823        assert!(mc.total_return.p25 <= mc.total_return.p50);
824        assert!(mc.total_return.p50 <= mc.total_return.p75);
825        assert!(mc.total_return.p75 <= mc.total_return.p95);
826    }
827
828    #[test]
829    fn test_parametric_reproducible() {
830        let result = minimal_result(mixed_trades());
831        let config = MonteCarloConfig::default()
832            .method(MonteCarloMethod::Parametric)
833            .seed(99);
834        let mc1 = config.run(&result);
835        let mc2 = config.run(&result);
836
837        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
838    }
839
840    #[test]
841    fn test_parametric_identical_trades_tight_distribution() {
842        // σ = 0 → all samples are the mean → tight distribution
843        let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
844        let result = minimal_result(trades);
845        let mc = MonteCarloConfig::default()
846            .method(MonteCarloMethod::Parametric)
847            .seed(1)
848            .run(&result);
849
850        let spread = mc.total_return.p95 - mc.total_return.p5;
851        assert!(
852            spread < 1e-6,
853            "expected tight spread for zero-variance trades, got {spread}"
854        );
855    }
856
857    // ── PRNG ────────────────────────────────────────────────────────────────
858
859    #[test]
860    fn test_xorshift_never_zero() {
861        let mut rng = Xorshift64::new(0); // seed 0 → should become 1 internally
862        for _ in 0..1000 {
863            assert_ne!(rng.next(), 0);
864        }
865    }
866
867    #[test]
868    fn test_next_f64_positive_in_range() {
869        let mut rng = Xorshift64::new(42);
870        for _ in 0..10_000 {
871            let v = rng.next_f64_positive();
872            assert!(v > 0.0 && v <= 1.0, "out of range: {v}");
873        }
874    }
875
876    // ── Helpers ─────────────────────────────────────────────────────────────
877
878    #[test]
879    fn test_profit_factor_all_wins_is_f64_max() {
880        let (_, _, _, pf) = run_simulation(&[0.01, 0.02, 0.03], 10_000.0, 1.0, 252.0);
881        assert_eq!(pf, f64::MAX);
882    }
883
884    #[test]
885    fn test_result_carries_method() {
886        let result = minimal_result(mixed_trades());
887        let mc = MonteCarloConfig::default()
888            .method(MonteCarloMethod::BlockBootstrap { block_size: 3 })
889            .run(&result);
890        assert!(matches!(
891            mc.method,
892            MonteCarloMethod::BlockBootstrap { block_size: 3 }
893        ));
894    }
895}