use crate::metrics;
struct SplitMix64(u64);
impl SplitMix64 {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BootstrapCi {
pub p05: f64,
pub p50: f64,
pub p95: f64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BootstrapSummary {
pub n_samples: usize,
pub block_len: usize,
pub sharpe: BootstrapCi,
pub cagr: BootstrapCi,
pub max_drawdown: BootstrapCi,
}
fn percentile(sorted: &[f64], q: f64) -> f64 {
if sorted.is_empty() {
return f64::NAN;
}
let idx = (q * (sorted.len() - 1) as f64).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn ci(mut samples: Vec<f64>) -> BootstrapCi {
samples.retain(|x| !x.is_nan());
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
BootstrapCi {
p05: percentile(&samples, 0.05),
p50: percentile(&samples, 0.50),
p95: percentile(&samples, 0.95),
}
}
pub fn bootstrap(
dates: &[i32],
equity: &[f64],
n_samples: usize,
block_len: usize,
seed: u64,
) -> Option<BootstrapSummary> {
if n_samples == 0 || equity.len() < 3 || dates.len() != equity.len() {
return None;
}
let rets: Vec<f64> = (1..equity.len())
.map(|i| equity[i] / equity[i - 1] - 1.0)
.collect();
let n = rets.len();
let block = if block_len == 0 {
((n as f64).sqrt().floor() as usize).max(1)
} else {
block_len.max(1)
};
let mut rng = SplitMix64(seed);
let mut sharpes = Vec::with_capacity(n_samples);
let mut cagrs = Vec::with_capacity(n_samples);
let mut mdds = Vec::with_capacity(n_samples);
let mut eq = vec![0.0_f64; equity.len()];
for _ in 0..n_samples {
eq.clear();
eq.push(1.0);
while eq.len() < equity.len() {
let start = rng.below(n);
for j in 0..block {
if eq.len() >= equity.len() {
break;
}
let r = rets[(start + j) % n]; let prev = *eq.last().unwrap();
eq.push(prev * (1.0 + r));
}
}
sharpes.push(metrics::sharpe(&eq));
cagrs.push(metrics::cagr(&eq, dates));
mdds.push(metrics::max_drawdown(&eq));
}
Some(BootstrapSummary {
n_samples,
block_len: block,
sharpe: ci(sharpes),
cagr: ci(cagrs),
max_drawdown: ci(mdds),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn yyyymmdd(i: usize) -> i32 {
let d = chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap() + chrono::Days::new(i as u64);
use chrono::Datelike;
d.year() * 10000 + d.month() as i32 * 100 + d.day() as i32
}
fn geometric_equity(n: usize, daily: f64) -> (Vec<i32>, Vec<f64>) {
let mut eq = vec![1.0];
let mut dates = vec![yyyymmdd(0)];
for i in 1..n {
let prev = *eq.last().unwrap();
eq.push(prev * (1.0 + daily));
dates.push(yyyymmdd(i));
}
(dates, eq)
}
#[test]
fn deterministic_and_gated() {
let (dates, eq) = geometric_equity(300, 0.001);
assert!(bootstrap(&dates, &eq, 0, 0, 42).is_none()); assert!(bootstrap(&dates, &eq[..2], 100, 0, 42).is_none()); let a = bootstrap(&dates, &eq, 200, 0, 42).unwrap();
let b = bootstrap(&dates, &eq, 200, 0, 42).unwrap();
assert_eq!(a.sharpe.p50, b.sharpe.p50); assert_eq!(a.block_len, 17); }
#[test]
fn constant_returns_collapse_the_band() {
let (dates, eq) = geometric_equity(100, 0.001);
let s = bootstrap(&dates, &eq, 50, 5, 7).unwrap();
assert!((s.cagr.p05 - s.cagr.p95).abs() < 1e-12);
assert!(s.max_drawdown.p50.abs() < 1e-12);
assert!(s.sharpe.p50 > 0.0);
}
#[test]
fn band_orders_and_brackets_reality() {
let mut eq = vec![1.0];
let mut dates = vec![yyyymmdd(0)];
for i in 1..260 {
let r = if i % 2 == 0 { 0.02 } else { -0.01 };
let prev = *eq.last().unwrap();
eq.push(prev * (1.0 + r));
dates.push(yyyymmdd(i));
}
let s = bootstrap(&dates, &eq, 300, 10, 1).unwrap();
assert!(s.sharpe.p05 <= s.sharpe.p50 && s.sharpe.p50 <= s.sharpe.p95);
assert!(s.max_drawdown.p05 <= s.max_drawdown.p95);
assert!(s.sharpe.p95 - s.sharpe.p05 > 0.0);
}
}