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, every percentile is derived from
138    /// 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 {
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            // Build synthetic equity curve from the sampled trade returns.
217            let (equity_curve, final_equity) =
218                build_equity_curve(&sim_buf, initial_capital, position_size);
219
220            let total_return = ((final_equity / initial_capital) - 1.0) * 100.0;
221            let max_dd = compute_max_drawdown(&equity_curve);
222            let sharpe = compute_sharpe(&equity_curve, periods_per_year);
223            let pf = compute_profit_factor(&sim_buf);
224
225            sim_returns.push(total_return);
226            sim_drawdowns.push(max_dd);
227            sim_sharpes.push(sharpe);
228            sim_pfs.push(pf);
229        }
230
231        MonteCarloResult {
232            num_simulations: self.num_simulations,
233            method: self.method.clone(),
234            total_return: PercentileStats::from_sorted(&mut sim_returns),
235            max_drawdown: PercentileStats::from_sorted(&mut sim_drawdowns),
236            sharpe_ratio: PercentileStats::from_sorted(&mut sim_sharpes),
237            profit_factor: PercentileStats::from_sorted(&mut sim_pfs),
238        }
239    }
240}
241
242// ── Output types ──────────────────────────────────────────────────────────────
243
244/// Percentile summary over the Monte Carlo simulations for a single metric.
245#[non_exhaustive]
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct PercentileStats {
248    /// 5th percentile (worst-case tail)
249    pub p5: f64,
250    /// 25th percentile (lower quartile)
251    pub p25: f64,
252    /// 50th percentile (median)
253    pub p50: f64,
254    /// 75th percentile (upper quartile)
255    pub p75: f64,
256    /// 95th percentile (best-case tail)
257    pub p95: f64,
258    /// Mean across all simulations
259    pub mean: f64,
260}
261
262impl PercentileStats {
263    /// Compute percentile stats from a slice (sorts in place for efficiency).
264    fn from_sorted(values: &mut [f64]) -> Self {
265        values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
266        let n = values.len();
267
268        let percentile = |p: f64| {
269            let idx = ((p / 100.0) * (n - 1) as f64).round() as usize;
270            values[idx.min(n - 1)]
271        };
272
273        let mean = values.iter().sum::<f64>() / n as f64;
274
275        Self {
276            p5: percentile(5.0),
277            p25: percentile(25.0),
278            p50: percentile(50.0),
279            p75: percentile(75.0),
280            p95: percentile(95.0),
281            mean,
282        }
283    }
284}
285
286/// Results of the Monte Carlo simulation.
287///
288/// Each field gives the distribution of that metric across all simulations.
289#[non_exhaustive]
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct MonteCarloResult {
292    /// Number of simulations that were run
293    pub num_simulations: usize,
294
295    /// Resampling method used to generate the simulations
296    pub method: MonteCarloMethod,
297
298    /// Distribution of total return (%) across simulations
299    pub total_return: PercentileStats,
300
301    /// Distribution of maximum drawdown (0.0–1.0) across simulations
302    pub max_drawdown: PercentileStats,
303
304    /// Distribution of Sharpe ratio across simulations.
305    ///
306    /// **Interpretation note:** this Sharpe is computed from inter-trade returns
307    /// (one data point per trade), not from bar-by-bar returns as in
308    /// [`PerformanceMetrics::sharpe_ratio`]. Annualisation uses
309    /// `sqrt(bars_per_year)`, which is only correct if trades occur every bar —
310    /// an assumption that is rarely satisfied. Use this field to *rank
311    /// simulations against each other*, not to compare against the
312    /// `PerformanceMetrics` value.
313    ///
314    /// [`PerformanceMetrics::sharpe_ratio`]: super::result::PerformanceMetrics::sharpe_ratio
315    pub sharpe_ratio: PercentileStats,
316
317    /// Distribution of profit factor across simulations
318    pub profit_factor: PercentileStats,
319}
320
321// ── PRNG: xorshift64 ──────────────────────────────────────────────────────────
322
323/// Minimal xorshift64 PRNG — avoids adding a `rand` dependency.
324///
325/// `pub(crate)` so that other backtesting submodules (e.g. `bayesian_search`)
326/// can reuse the same PRNG without duplicating the implementation.
327pub(crate) struct Xorshift64 {
328    state: u64,
329}
330
331impl Xorshift64 {
332    pub(crate) fn new(seed: u64) -> Self {
333        // Ensure state is never zero (xorshift requirement)
334        Self {
335            state: if seed == 0 { 1 } else { seed },
336        }
337    }
338
339    /// Generate the next pseudo-random u64.
340    pub(crate) fn next(&mut self) -> u64 {
341        let mut x = self.state;
342        x ^= x << 13;
343        x ^= x >> 7;
344        x ^= x << 17;
345        self.state = x;
346        x
347    }
348
349    /// Generate a random `usize` in `[0, n)`.
350    ///
351    /// Uses rejection sampling to eliminate modulo bias — otherwise the lower
352    /// values of `[0, n)` would be slightly more probable when `n` is not a
353    /// power of two (since `u64::MAX` is not evenly divisible by arbitrary `n`).
354    pub(crate) fn next_usize(&mut self, n: usize) -> usize {
355        let n64 = n as u64;
356        // The largest multiple of n64 that fits in u64; values in [threshold, u64::MAX]
357        // are rejected to ensure uniform distribution.
358        let threshold = u64::MAX - (u64::MAX % n64);
359        loop {
360            let x = self.next();
361            if x < threshold {
362                return (x % n64) as usize;
363            }
364        }
365    }
366
367    /// Generate a uniform `f64` in `(0, 1]` using 53-bit precision.
368    ///
369    /// Returns a value strictly greater than zero, making it safe for use as
370    /// the argument to `f64::ln()` in Box-Muller transforms.
371    pub(crate) fn next_f64_positive(&mut self) -> f64 {
372        // Take the top 53 bits (full double mantissa precision), add 1 to shift
373        // from [0, 2^53) to [1, 2^53], then scale to (0, 1].
374        ((self.next() >> 11) + 1) as f64 * (1.0 / (1u64 << 53) as f64)
375    }
376}
377
378// ── Sampling helpers ──────────────────────────────────────────────────────────
379
380/// Fisher-Yates in-place shuffle using the provided RNG.
381fn fisher_yates_shuffle(slice: &mut [f64], rng: &mut Xorshift64) {
382    for i in (1..slice.len()).rev() {
383        let j = rng.next_usize(i + 1);
384        slice.swap(i, j);
385    }
386}
387
388/// Block bootstrap sampler — fixed block size, circular wrap-around.
389///
390/// Draws random starting positions and copies `block_size` consecutive
391/// elements (wrapping around the end), filling `out` to exactly its length.
392fn block_bootstrap_into(trades: &[f64], block_size: usize, rng: &mut Xorshift64, out: &mut [f64]) {
393    let n = trades.len();
394    let block_size = block_size.max(1);
395    let mut filled = 0;
396    while filled < n {
397        let start = rng.next_usize(n);
398        let take = block_size.min(n - filled);
399        for i in 0..take {
400            out[filled + i] = trades[(start + i) % n];
401        }
402        filled += take;
403    }
404}
405
406/// Stationary bootstrap sampler — geometrically-distributed block lengths.
407///
408/// At each position, continues the current block with probability
409/// `(mean_block_size - 1) / mean_block_size`, or jumps to a new random start
410/// with probability `1 / mean_block_size`. Implemented without floating-point
411/// division by testing `rng.next_usize(mean_block_size) == 0`.
412fn stationary_bootstrap_into(
413    trades: &[f64],
414    mean_block_size: usize,
415    rng: &mut Xorshift64,
416    out: &mut [f64],
417) {
418    let n = trades.len();
419    let mean_block_size = mean_block_size.max(1);
420    let mut pos = rng.next_usize(n);
421    for slot in out.iter_mut() {
422        *slot = trades[pos % n];
423        if rng.next_usize(mean_block_size) == 0 {
424            // Start a new block at a random position.
425            pos = rng.next_usize(n);
426        } else {
427            pos += 1;
428        }
429    }
430}
431
432/// Parametric sampler — draws from N(μ, σ) fitted to `trades`.
433///
434/// Uses the Box-Muller transform to convert pairs of uniform draws into
435/// standard-normal samples, then shifts and scales by the empirical mean and
436/// standard deviation. When fewer than 2 trades exist (σ undefined), all
437/// samples are set to the empirical mean.
438fn parametric_sample_into(trades: &[f64], rng: &mut Xorshift64, out: &mut [f64]) {
439    let n = trades.len();
440    let mean = trades.iter().sum::<f64>() / n as f64;
441    let variance = if n > 1 {
442        trades.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0)
443    } else {
444        0.0
445    };
446    let std_dev = variance.sqrt();
447
448    // Nothing to sample if variance is zero.
449    if std_dev == 0.0 {
450        out.iter_mut().for_each(|v| *v = mean);
451        return;
452    }
453
454    let mut i = 0;
455    while i < n {
456        // Box-Muller: two uniform (0,1] draws → two independent standard-normal samples.
457        let u1 = rng.next_f64_positive();
458        let u2 = rng.next_f64_positive();
459        let mag = (-2.0 * u1.ln()).sqrt();
460        let angle = std::f64::consts::TAU * u2;
461        let z0 = mag * angle.cos();
462        let z1 = mag * angle.sin();
463
464        out[i] = mean + std_dev * z0;
465        if i + 1 < n {
466            out[i + 1] = mean + std_dev * z1;
467        }
468        i += 2;
469    }
470}
471
472// ── Equity-curve helpers ──────────────────────────────────────────────────────
473
474/// Build a per-trade equity curve from a sequence of trade returns.
475///
476/// Returns `(equity_points, final_equity)`. Each point represents the
477/// portfolio value after applying one trade's return to the previous equity.
478fn build_equity_curve(
479    trade_returns: &[f64],
480    initial_capital: f64,
481    position_size_pct: f64,
482) -> (Vec<f64>, f64) {
483    let mut curve = Vec::with_capacity(trade_returns.len() + 1);
484    curve.push(initial_capital);
485    let mut equity = initial_capital;
486    let exposure = position_size_pct.max(0.0);
487    for &ret in trade_returns {
488        equity *= 1.0 + ret * exposure;
489        curve.push(equity);
490    }
491    (curve, equity)
492}
493
494/// Compute maximum drawdown (fraction, 0.0–1.0) from an equity curve.
495fn compute_max_drawdown(equity_curve: &[f64]) -> f64 {
496    let mut peak = f64::NEG_INFINITY;
497    let mut max_dd = 0.0_f64;
498    for &equity in equity_curve {
499        if equity > peak {
500            peak = equity;
501        }
502        if peak > 0.0 {
503            let dd = (peak - equity) / peak;
504            max_dd = max_dd.max(dd);
505        }
506    }
507    max_dd
508}
509
510/// Compute a simplified Sharpe ratio from the Monte Carlo equity curve.
511///
512/// Uses bar-to-bar returns with no risk-free rate adjustment. Uses sample
513/// standard deviation (n-1) and annualises by `sqrt(bars_per_year)`, consistent
514/// with [`PerformanceMetrics`].
515///
516/// **Important**: the equity curve passed here is built from shuffled *trade*
517/// returns (one equity point per trade), **not** from bar-by-bar values.  For
518/// a backtest with N trades over M bars (N << M), the resulting Sharpe is
519/// computed from N−1 inter-trade returns rather than the M−1 daily returns used
520/// by `PerformanceMetrics`.  This produces a different annualisation baseline
521/// and should be interpreted as a *relative* metric for comparing simulations
522/// against each other rather than compared directly to the bar-by-bar Sharpe in
523/// the original `BacktestResult`.
524///
525/// [`PerformanceMetrics`]: super::result::PerformanceMetrics
526fn compute_sharpe(equity_curve: &[f64], periods_per_year: f64) -> f64 {
527    if equity_curve.len() < 2 {
528        return 0.0;
529    }
530    let returns: Vec<f64> = equity_curve
531        .windows(2)
532        .map(|w| {
533            if w[0] > 0.0 {
534                (w[1] - w[0]) / w[0]
535            } else {
536                0.0
537            }
538        })
539        .collect();
540
541    if returns.len() < 2 {
542        return 0.0;
543    }
544    let n = returns.len() as f64;
545    let mean = returns.iter().sum::<f64>() / n;
546    // Sample variance (n-1)
547    let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
548    let std_dev = variance.sqrt();
549    if std_dev == 0.0 {
550        return 0.0;
551    }
552    (mean / std_dev) * periods_per_year.sqrt()
553}
554
555/// Compute profit factor from a sequence of trade return fractions.
556fn compute_profit_factor(trade_returns: &[f64]) -> f64 {
557    let gross_profit: f64 = trade_returns.iter().filter(|&&r| r > 0.0).sum();
558    let gross_loss: f64 = trade_returns
559        .iter()
560        .filter(|&&r| r < 0.0)
561        .map(|r| r.abs())
562        .sum();
563    if gross_loss > 0.0 {
564        gross_profit / gross_loss
565    } else if gross_profit > 0.0 {
566        f64::MAX
567    } else {
568        0.0
569    }
570}
571
572// ── Tests ─────────────────────────────────────────────────────────────────────
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use crate::backtesting::result::{BacktestResult, EquityPoint, PerformanceMetrics};
578    use crate::backtesting::signal::Signal;
579    use crate::backtesting::{BacktestConfig, PositionSide, Trade};
580
581    fn make_signal() -> Signal {
582        Signal::long(0, 100.0)
583    }
584
585    fn make_trade(entry: f64, exit: f64, qty: f64) -> Trade {
586        Trade {
587            side: PositionSide::Long,
588            entry_timestamp: 0,
589            exit_timestamp: 86400,
590            entry_price: entry,
591            exit_price: exit,
592            quantity: qty,
593            entry_quantity: qty,
594            commission: 0.0,
595            transaction_tax: 0.0,
596            pnl: (exit - entry) * qty,
597            return_pct: ((exit / entry) - 1.0) * 100.0,
598            dividend_income: 0.0,
599            unreinvested_dividends: 0.0,
600            tags: Vec::new(),
601            is_partial: false,
602            scale_sequence: 0,
603            entry_signal: make_signal(),
604            exit_signal: Signal::exit(86400, exit),
605        }
606    }
607
608    fn make_equity_point(ts: i64, equity: f64) -> EquityPoint {
609        EquityPoint {
610            timestamp: ts,
611            equity,
612            drawdown_pct: 0.0,
613        }
614    }
615
616    fn minimal_result(trades: Vec<Trade>) -> BacktestResult {
617        let n_candles = 252;
618        let equity_curve: Vec<EquityPoint> = (0..n_candles)
619            .map(|i| make_equity_point(i as i64, 10_000.0))
620            .collect();
621
622        BacktestResult {
623            symbol: "TEST".into(),
624            strategy_name: "test".into(),
625            config: BacktestConfig::default(),
626            start_timestamp: 0,
627            end_timestamp: n_candles as i64,
628            initial_capital: 10_000.0,
629            final_equity: 10_000.0,
630            metrics: PerformanceMetrics::calculate(
631                &trades,
632                &equity_curve,
633                10_000.0,
634                0,
635                0,
636                0.0,
637                252.0,
638            ),
639            trades,
640            equity_curve,
641            signals: vec![],
642            open_position: None,
643            benchmark: None,
644            diagnostics: vec![],
645        }
646    }
647
648    fn mixed_trades() -> Vec<Trade> {
649        vec![
650            make_trade(100.0, 110.0, 10.0),
651            make_trade(100.0, 90.0, 10.0),
652            make_trade(100.0, 115.0, 10.0),
653            make_trade(100.0, 95.0, 10.0),
654        ]
655    }
656
657    // ── IidShuffle ──────────────────────────────────────────────────────────
658
659    #[test]
660    fn test_reproducible_with_seed() {
661        let result = minimal_result(mixed_trades());
662        let config = MonteCarloConfig::default().seed(42);
663        let mc1 = config.run(&result);
664        let mc2 = config.run(&result);
665
666        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
667        assert!((mc1.max_drawdown.p50 - mc2.max_drawdown.p50).abs() < f64::EPSILON);
668    }
669
670    #[test]
671    fn test_percentile_ordering() {
672        let trades = vec![
673            make_trade(100.0, 120.0, 10.0),
674            make_trade(100.0, 80.0, 10.0),
675            make_trade(100.0, 130.0, 10.0),
676            make_trade(100.0, 75.0, 10.0),
677            make_trade(100.0, 110.0, 10.0),
678        ];
679        let result = minimal_result(trades);
680        let mc = MonteCarloConfig::default()
681            .num_simulations(500)
682            .seed(1)
683            .run(&result);
684
685        assert!(mc.total_return.p5 <= mc.total_return.p25);
686        assert!(mc.total_return.p25 <= mc.total_return.p50);
687        assert!(mc.total_return.p50 <= mc.total_return.p75);
688        assert!(mc.total_return.p75 <= mc.total_return.p95);
689        assert!(mc.max_drawdown.p5 <= mc.max_drawdown.p95);
690    }
691
692    #[test]
693    fn test_degenerate_single_trade() {
694        let result = minimal_result(vec![make_trade(100.0, 110.0, 10.0)]);
695        let mc = MonteCarloConfig::default().run(&result);
696
697        // With only 1 trade there's nothing to resample — all percentiles equal observed
698        assert_eq!(mc.total_return.p5, mc.total_return.p50);
699        assert_eq!(mc.total_return.p50, mc.total_return.p95);
700    }
701
702    #[test]
703    fn test_all_winning_trades_tight_distribution() {
704        let trades: Vec<Trade> = (0..20).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
705        let result = minimal_result(trades);
706        let mc = MonteCarloConfig::default().seed(99).run(&result);
707
708        let spread = mc.total_return.p95 - mc.total_return.p5;
709        assert!(
710            spread < 1e-6,
711            "expected tight spread for identical trades, got {spread}"
712        );
713    }
714
715    // ── BlockBootstrap ──────────────────────────────────────────────────────
716
717    #[test]
718    fn test_block_bootstrap_percentile_ordering() {
719        let trades = vec![
720            make_trade(100.0, 120.0, 10.0),
721            make_trade(100.0, 80.0, 10.0),
722            make_trade(100.0, 130.0, 10.0),
723            make_trade(100.0, 75.0, 10.0),
724            make_trade(100.0, 110.0, 10.0),
725            make_trade(100.0, 95.0, 10.0),
726        ];
727        let result = minimal_result(trades);
728        let mc = MonteCarloConfig::default()
729            .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
730            .num_simulations(500)
731            .seed(7)
732            .run(&result);
733
734        assert!(mc.total_return.p5 <= mc.total_return.p50);
735        assert!(mc.total_return.p50 <= mc.total_return.p95);
736    }
737
738    #[test]
739    fn test_block_bootstrap_reproducible() {
740        let result = minimal_result(mixed_trades());
741        let config = MonteCarloConfig::default()
742            .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
743            .seed(13);
744        let mc1 = config.run(&result);
745        let mc2 = config.run(&result);
746
747        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
748    }
749
750    #[test]
751    fn test_block_bootstrap_block_size_one_matches_iid_distribution() {
752        // block_size=1 is equivalent to IID shuffle in terms of return distribution
753        // (same set of individual values, different orderings). Both should give
754        // the same set of possible total returns.
755        let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
756        let result = minimal_result(trades);
757
758        let iid = MonteCarloConfig::default().seed(1).run(&result);
759        let bb = MonteCarloConfig::default()
760            .method(MonteCarloMethod::BlockBootstrap { block_size: 1 })
761            .seed(1)
762            .run(&result);
763
764        // All identical trades → both should give the same tight distribution
765        let iid_spread = iid.total_return.p95 - iid.total_return.p5;
766        let bb_spread = bb.total_return.p95 - bb.total_return.p5;
767        assert!(iid_spread < 1e-6, "iid spread {iid_spread}");
768        assert!(bb_spread < 1e-6, "bb spread {bb_spread}");
769    }
770
771    // ── StationaryBootstrap ─────────────────────────────────────────────────
772
773    #[test]
774    fn test_stationary_bootstrap_percentile_ordering() {
775        let trades = vec![
776            make_trade(100.0, 120.0, 10.0),
777            make_trade(100.0, 80.0, 10.0),
778            make_trade(100.0, 130.0, 10.0),
779            make_trade(100.0, 75.0, 10.0),
780            make_trade(100.0, 110.0, 10.0),
781            make_trade(100.0, 95.0, 10.0),
782        ];
783        let result = minimal_result(trades);
784        let mc = MonteCarloConfig::default()
785            .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
786            .num_simulations(500)
787            .seed(5)
788            .run(&result);
789
790        assert!(mc.total_return.p5 <= mc.total_return.p50);
791        assert!(mc.total_return.p50 <= mc.total_return.p95);
792    }
793
794    #[test]
795    fn test_stationary_bootstrap_reproducible() {
796        let result = minimal_result(mixed_trades());
797        let config = MonteCarloConfig::default()
798            .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
799            .seed(77);
800        let mc1 = config.run(&result);
801        let mc2 = config.run(&result);
802
803        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
804    }
805
806    // ── Parametric ──────────────────────────────────────────────────────────
807
808    #[test]
809    fn test_parametric_percentile_ordering() {
810        let trades = vec![
811            make_trade(100.0, 120.0, 10.0),
812            make_trade(100.0, 80.0, 10.0),
813            make_trade(100.0, 130.0, 10.0),
814            make_trade(100.0, 75.0, 10.0),
815            make_trade(100.0, 110.0, 10.0),
816        ];
817        let result = minimal_result(trades);
818        let mc = MonteCarloConfig::default()
819            .method(MonteCarloMethod::Parametric)
820            .num_simulations(1000)
821            .seed(3)
822            .run(&result);
823
824        assert!(mc.total_return.p5 <= mc.total_return.p25);
825        assert!(mc.total_return.p25 <= mc.total_return.p50);
826        assert!(mc.total_return.p50 <= mc.total_return.p75);
827        assert!(mc.total_return.p75 <= mc.total_return.p95);
828    }
829
830    #[test]
831    fn test_parametric_reproducible() {
832        let result = minimal_result(mixed_trades());
833        let config = MonteCarloConfig::default()
834            .method(MonteCarloMethod::Parametric)
835            .seed(99);
836        let mc1 = config.run(&result);
837        let mc2 = config.run(&result);
838
839        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
840    }
841
842    #[test]
843    fn test_parametric_identical_trades_tight_distribution() {
844        // σ = 0 → all samples are the mean → tight distribution
845        let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
846        let result = minimal_result(trades);
847        let mc = MonteCarloConfig::default()
848            .method(MonteCarloMethod::Parametric)
849            .seed(1)
850            .run(&result);
851
852        let spread = mc.total_return.p95 - mc.total_return.p5;
853        assert!(
854            spread < 1e-6,
855            "expected tight spread for zero-variance trades, got {spread}"
856        );
857    }
858
859    // ── PRNG ────────────────────────────────────────────────────────────────
860
861    #[test]
862    fn test_xorshift_never_zero() {
863        let mut rng = Xorshift64::new(0); // seed 0 → should become 1 internally
864        for _ in 0..1000 {
865            assert_ne!(rng.next(), 0);
866        }
867    }
868
869    #[test]
870    fn test_next_f64_positive_in_range() {
871        let mut rng = Xorshift64::new(42);
872        for _ in 0..10_000 {
873            let v = rng.next_f64_positive();
874            assert!(v > 0.0 && v <= 1.0, "out of range: {v}");
875        }
876    }
877
878    // ── Helpers ─────────────────────────────────────────────────────────────
879
880    #[test]
881    fn test_profit_factor_all_wins_is_f64_max() {
882        let pf = compute_profit_factor(&[0.01, 0.02, 0.03]);
883        assert_eq!(pf, f64::MAX);
884    }
885
886    #[test]
887    fn test_result_carries_method() {
888        let result = minimal_result(mixed_trades());
889        let mc = MonteCarloConfig::default()
890            .method(MonteCarloMethod::BlockBootstrap { block_size: 3 })
891            .run(&result);
892        assert!(matches!(
893            mc.method,
894            MonteCarloMethod::BlockBootstrap { block_size: 3 }
895        ));
896    }
897}