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            // 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            tags: Vec::new(),
586            is_partial: false,
587            scale_sequence: 0,
588            entry_signal: make_signal(),
589            exit_signal: Signal::exit(86400, exit),
590        }
591    }
592
593    fn make_equity_point(ts: i64, equity: f64) -> EquityPoint {
594        EquityPoint {
595            timestamp: ts,
596            equity,
597            drawdown_pct: 0.0,
598        }
599    }
600
601    fn minimal_result(trades: Vec<Trade>) -> BacktestResult {
602        let n_candles = 252;
603        let equity_curve: Vec<EquityPoint> = (0..n_candles)
604            .map(|i| make_equity_point(i as i64, 10_000.0))
605            .collect();
606
607        BacktestResult {
608            symbol: "TEST".into(),
609            strategy_name: "test".into(),
610            config: BacktestConfig::default(),
611            start_timestamp: 0,
612            end_timestamp: n_candles as i64,
613            initial_capital: 10_000.0,
614            final_equity: 10_000.0,
615            metrics: PerformanceMetrics::calculate(
616                &trades,
617                &equity_curve,
618                10_000.0,
619                0,
620                0,
621                0.0,
622                252.0,
623            ),
624            trades,
625            equity_curve,
626            signals: vec![],
627            open_position: None,
628            benchmark: None,
629            diagnostics: vec![],
630        }
631    }
632
633    fn mixed_trades() -> Vec<Trade> {
634        vec![
635            make_trade(100.0, 110.0, 10.0),
636            make_trade(100.0, 90.0, 10.0),
637            make_trade(100.0, 115.0, 10.0),
638            make_trade(100.0, 95.0, 10.0),
639        ]
640    }
641
642    // ── IidShuffle ──────────────────────────────────────────────────────────
643
644    #[test]
645    fn test_reproducible_with_seed() {
646        let result = minimal_result(mixed_trades());
647        let config = MonteCarloConfig::default().seed(42);
648        let mc1 = config.run(&result);
649        let mc2 = config.run(&result);
650
651        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
652        assert!((mc1.max_drawdown.p50 - mc2.max_drawdown.p50).abs() < f64::EPSILON);
653    }
654
655    #[test]
656    fn test_percentile_ordering() {
657        let trades = vec![
658            make_trade(100.0, 120.0, 10.0),
659            make_trade(100.0, 80.0, 10.0),
660            make_trade(100.0, 130.0, 10.0),
661            make_trade(100.0, 75.0, 10.0),
662            make_trade(100.0, 110.0, 10.0),
663        ];
664        let result = minimal_result(trades);
665        let mc = MonteCarloConfig::default()
666            .num_simulations(500)
667            .seed(1)
668            .run(&result);
669
670        assert!(mc.total_return.p5 <= mc.total_return.p25);
671        assert!(mc.total_return.p25 <= mc.total_return.p50);
672        assert!(mc.total_return.p50 <= mc.total_return.p75);
673        assert!(mc.total_return.p75 <= mc.total_return.p95);
674        assert!(mc.max_drawdown.p5 <= mc.max_drawdown.p95);
675    }
676
677    #[test]
678    fn test_degenerate_single_trade() {
679        let result = minimal_result(vec![make_trade(100.0, 110.0, 10.0)]);
680        let mc = MonteCarloConfig::default().run(&result);
681
682        // With only 1 trade there's nothing to resample — all percentiles equal observed
683        assert_eq!(mc.total_return.p5, mc.total_return.p50);
684        assert_eq!(mc.total_return.p50, mc.total_return.p95);
685    }
686
687    #[test]
688    fn test_all_winning_trades_tight_distribution() {
689        let trades: Vec<Trade> = (0..20).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
690        let result = minimal_result(trades);
691        let mc = MonteCarloConfig::default().seed(99).run(&result);
692
693        let spread = mc.total_return.p95 - mc.total_return.p5;
694        assert!(
695            spread < 1e-6,
696            "expected tight spread for identical trades, got {spread}"
697        );
698    }
699
700    // ── BlockBootstrap ──────────────────────────────────────────────────────
701
702    #[test]
703    fn test_block_bootstrap_percentile_ordering() {
704        let trades = vec![
705            make_trade(100.0, 120.0, 10.0),
706            make_trade(100.0, 80.0, 10.0),
707            make_trade(100.0, 130.0, 10.0),
708            make_trade(100.0, 75.0, 10.0),
709            make_trade(100.0, 110.0, 10.0),
710            make_trade(100.0, 95.0, 10.0),
711        ];
712        let result = minimal_result(trades);
713        let mc = MonteCarloConfig::default()
714            .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
715            .num_simulations(500)
716            .seed(7)
717            .run(&result);
718
719        assert!(mc.total_return.p5 <= mc.total_return.p50);
720        assert!(mc.total_return.p50 <= mc.total_return.p95);
721    }
722
723    #[test]
724    fn test_block_bootstrap_reproducible() {
725        let result = minimal_result(mixed_trades());
726        let config = MonteCarloConfig::default()
727            .method(MonteCarloMethod::BlockBootstrap { block_size: 2 })
728            .seed(13);
729        let mc1 = config.run(&result);
730        let mc2 = config.run(&result);
731
732        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
733    }
734
735    #[test]
736    fn test_block_bootstrap_block_size_one_matches_iid_distribution() {
737        // block_size=1 is equivalent to IID shuffle in terms of return distribution
738        // (same set of individual values, different orderings). Both should give
739        // the same set of possible total returns.
740        let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
741        let result = minimal_result(trades);
742
743        let iid = MonteCarloConfig::default().seed(1).run(&result);
744        let bb = MonteCarloConfig::default()
745            .method(MonteCarloMethod::BlockBootstrap { block_size: 1 })
746            .seed(1)
747            .run(&result);
748
749        // All identical trades → both should give the same tight distribution
750        let iid_spread = iid.total_return.p95 - iid.total_return.p5;
751        let bb_spread = bb.total_return.p95 - bb.total_return.p5;
752        assert!(iid_spread < 1e-6, "iid spread {iid_spread}");
753        assert!(bb_spread < 1e-6, "bb spread {bb_spread}");
754    }
755
756    // ── StationaryBootstrap ─────────────────────────────────────────────────
757
758    #[test]
759    fn test_stationary_bootstrap_percentile_ordering() {
760        let trades = vec![
761            make_trade(100.0, 120.0, 10.0),
762            make_trade(100.0, 80.0, 10.0),
763            make_trade(100.0, 130.0, 10.0),
764            make_trade(100.0, 75.0, 10.0),
765            make_trade(100.0, 110.0, 10.0),
766            make_trade(100.0, 95.0, 10.0),
767        ];
768        let result = minimal_result(trades);
769        let mc = MonteCarloConfig::default()
770            .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
771            .num_simulations(500)
772            .seed(5)
773            .run(&result);
774
775        assert!(mc.total_return.p5 <= mc.total_return.p50);
776        assert!(mc.total_return.p50 <= mc.total_return.p95);
777    }
778
779    #[test]
780    fn test_stationary_bootstrap_reproducible() {
781        let result = minimal_result(mixed_trades());
782        let config = MonteCarloConfig::default()
783            .method(MonteCarloMethod::StationaryBootstrap { mean_block_size: 2 })
784            .seed(77);
785        let mc1 = config.run(&result);
786        let mc2 = config.run(&result);
787
788        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
789    }
790
791    // ── Parametric ──────────────────────────────────────────────────────────
792
793    #[test]
794    fn test_parametric_percentile_ordering() {
795        let trades = vec![
796            make_trade(100.0, 120.0, 10.0),
797            make_trade(100.0, 80.0, 10.0),
798            make_trade(100.0, 130.0, 10.0),
799            make_trade(100.0, 75.0, 10.0),
800            make_trade(100.0, 110.0, 10.0),
801        ];
802        let result = minimal_result(trades);
803        let mc = MonteCarloConfig::default()
804            .method(MonteCarloMethod::Parametric)
805            .num_simulations(1000)
806            .seed(3)
807            .run(&result);
808
809        assert!(mc.total_return.p5 <= mc.total_return.p25);
810        assert!(mc.total_return.p25 <= mc.total_return.p50);
811        assert!(mc.total_return.p50 <= mc.total_return.p75);
812        assert!(mc.total_return.p75 <= mc.total_return.p95);
813    }
814
815    #[test]
816    fn test_parametric_reproducible() {
817        let result = minimal_result(mixed_trades());
818        let config = MonteCarloConfig::default()
819            .method(MonteCarloMethod::Parametric)
820            .seed(99);
821        let mc1 = config.run(&result);
822        let mc2 = config.run(&result);
823
824        assert!((mc1.total_return.p50 - mc2.total_return.p50).abs() < f64::EPSILON);
825    }
826
827    #[test]
828    fn test_parametric_identical_trades_tight_distribution() {
829        // σ = 0 → all samples are the mean → tight distribution
830        let trades: Vec<Trade> = (0..10).map(|_| make_trade(100.0, 110.0, 10.0)).collect();
831        let result = minimal_result(trades);
832        let mc = MonteCarloConfig::default()
833            .method(MonteCarloMethod::Parametric)
834            .seed(1)
835            .run(&result);
836
837        let spread = mc.total_return.p95 - mc.total_return.p5;
838        assert!(
839            spread < 1e-6,
840            "expected tight spread for zero-variance trades, got {spread}"
841        );
842    }
843
844    // ── PRNG ────────────────────────────────────────────────────────────────
845
846    #[test]
847    fn test_xorshift_never_zero() {
848        let mut rng = Xorshift64::new(0); // seed 0 → should become 1 internally
849        for _ in 0..1000 {
850            assert_ne!(rng.next(), 0);
851        }
852    }
853
854    #[test]
855    fn test_next_f64_positive_in_range() {
856        let mut rng = Xorshift64::new(42);
857        for _ in 0..10_000 {
858            let v = rng.next_f64_positive();
859            assert!(v > 0.0 && v <= 1.0, "out of range: {v}");
860        }
861    }
862
863    // ── Helpers ─────────────────────────────────────────────────────────────
864
865    #[test]
866    fn test_profit_factor_all_wins_is_f64_max() {
867        let (_, _, _, pf) = run_simulation(&[0.01, 0.02, 0.03], 10_000.0, 1.0, 252.0);
868        assert_eq!(pf, f64::MAX);
869    }
870
871    #[test]
872    fn test_result_carries_method() {
873        let result = minimal_result(mixed_trades());
874        let mc = MonteCarloConfig::default()
875            .method(MonteCarloMethod::BlockBootstrap { block_size: 3 })
876            .run(&result);
877        assert!(matches!(
878            mc.method,
879            MonteCarloMethod::BlockBootstrap { block_size: 3 }
880        ));
881    }
882}