use serde::{Deserialize, Serialize};
pub const TRIALS_SR_STD_DEFAULT: f64 = 0.5;
pub const KERNEL_BASE_TRIALS: u32 = 50;
fn mean(xs: &[f64]) -> f64 {
if xs.is_empty() {
return 0.0;
}
xs.iter().sum::<f64>() / xs.len() as f64
}
fn std_dev(xs: &[f64]) -> f64 {
let n = xs.len();
if n < 2 {
return 0.0;
}
let m = mean(xs);
let ss: f64 = xs.iter().map(|x| (x - m) * (x - m)).sum();
(ss / (n as f64 - 1.0)).sqrt()
}
fn skewness(xs: &[f64]) -> f64 {
let n = xs.len();
if n < 3 {
return 0.0;
}
let m = mean(xs);
let s = std_dev(xs);
if s == 0.0 {
return 0.0;
}
let sum: f64 = xs.iter().map(|x| ((x - m) / s).powi(3)).sum();
sum / n as f64
}
fn kurtosis(xs: &[f64]) -> f64 {
let n = xs.len();
if n < 4 {
return 3.0;
}
let m = mean(xs);
let s = std_dev(xs);
if s == 0.0 {
return 3.0;
}
let sum: f64 = xs.iter().map(|x| ((x - m) / s).powi(4)).sum();
sum / n as f64
}
fn erf(x: f64) -> f64 {
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let t = 1.0 / (1.0 + 0.327_591_1 * x);
let y = 1.0
- (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t
+ 0.254_829_592)
* t
* (-x * x).exp();
sign * y
}
fn norm_cdf(x: f64) -> f64 {
0.5 * (1.0 + erf(x / std::f64::consts::SQRT_2))
}
fn norm_ppf(p: f64) -> f64 {
if p <= 0.0 {
return f64::NEG_INFINITY;
}
if p >= 1.0 {
return f64::INFINITY;
}
const A: [f64; 6] = [
-3.969_683_028_665_376e1,
2.209_460_984_245_205e2,
-2.759_285_104_469_687e2,
1.383_577_518_672_69e2,
-3.066_479_806_614_716e1,
2.506_628_277_459_239e0,
];
const B: [f64; 5] = [
-5.447_609_879_822_406e1,
1.615_858_368_580_409e2,
-1.556_989_798_598_866e2,
6.680_131_188_771_972e1,
-1.328_068_155_288_572e1,
];
const C: [f64; 6] = [
-7.784_894_002_430_293e-3,
-3.223_964_580_411_365e-1,
-2.400_758_277_161_838e0,
-2.549_732_539_343_734e0,
4.374_664_141_464_968e0,
2.938_163_982_698_783e0,
];
const D: [f64; 4] = [
7.784_695_709_041_462e-3,
3.224_671_290_700_398e-1,
2.445_134_137_142_996e0,
3.754_408_661_907_416e0,
];
const P_LOW: f64 = 0.02425;
const P_HIGH: f64 = 1.0 - P_LOW;
if p < P_LOW {
let q = (-2.0 * p.ln()).sqrt();
(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
/ ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
} else if p <= P_HIGH {
let q = p - 0.5;
let r = q * q;
(((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q
/ (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0)
} else {
let q = (-2.0 * (1.0 - p).ln()).sqrt();
-(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
/ ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
}
}
pub fn sharpe_ratio(returns: &[f64]) -> f64 {
let s = std_dev(returns);
if s == 0.0 {
return 0.0;
}
mean(returns) / s
}
fn probabilistic_sharpe_ratio(returns: &[f64], sr_benchmark: f64) -> f64 {
let n = returns.len();
if n < 2 {
return 0.0;
}
let sr = sharpe_ratio(returns);
let g3 = skewness(returns);
let g4 = kurtosis(returns);
let denom = (1.0 - g3 * sr + ((g4 - 1.0) / 4.0) * sr * sr)
.max(1e-12)
.sqrt();
let z = (sr - sr_benchmark) * (n as f64 - 1.0).sqrt() / denom;
norm_cdf(z)
}
fn expected_max_sharpe(trials_sr_std: f64, n_trials: u32) -> f64 {
let n = n_trials.max(1) as f64;
if n <= 1.0 || trials_sr_std <= 0.0 {
return 0.0;
}
const GAMMA: f64 = 0.577_215_664_901_532_9; let e = std::f64::consts::E;
let z1 = norm_ppf(1.0 - 1.0 / n);
let z2 = norm_ppf(1.0 - 1.0 / (n * e));
trials_sr_std * ((1.0 - GAMMA) * z1 + GAMMA * z2)
}
pub fn deflated_sharpe(returns: &[f64], n_trials: u32, trials_sr_std: f64) -> f64 {
let sr_star = expected_max_sharpe(trials_sr_std, n_trials);
probabilistic_sharpe_ratio(returns, sr_star)
}
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 {
debug_assert!(n > 0);
((self.next_u64() as u128 * n as u128) >> 64) as usize
}
}
fn quantile(values: &[f64], q: f64) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut v = values.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
if v.len() == 1 {
return v[0];
}
let pos = q.clamp(0.0, 1.0) * (v.len() - 1) as f64;
let lo = pos.floor() as usize;
let hi = pos.ceil() as usize;
let frac = pos - lo as f64;
v[lo] + (v[hi] - v[lo]) * frac
}
fn pool_selected(per_seed: &[Vec<f64>], idx: &[usize]) -> Vec<f64> {
let mut pooled = Vec::new();
for &i in idx {
pooled.extend_from_slice(&per_seed[i]);
}
pooled
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DsrCi {
pub point: f64,
pub lo: f64,
pub hi: f64,
pub width: f64,
pub confidence: f64,
pub n_boot: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PairedDiff {
pub point_diff: f64,
pub lo: f64,
pub hi: f64,
pub p_value: f64,
pub confidence: f64,
pub significant: bool,
pub verdict: String,
pub n_boot: usize,
}
pub fn bootstrap_dsr_ci(
per_seed: &[Vec<f64>],
n_trials: u32,
trials_sr_std: f64,
n_boot: usize,
resample_seed: u64,
alpha: f64,
) -> DsrCi {
let n = per_seed.len();
let full: Vec<f64> = per_seed.iter().flatten().copied().collect();
let point = deflated_sharpe(&full, n_trials, trials_sr_std);
let confidence = 1.0 - alpha;
if n == 0 || n_boot == 0 {
return DsrCi {
point,
lo: point,
hi: point,
width: 0.0,
confidence,
n_boot,
};
}
let mut rng = SplitMix64(resample_seed);
let mut samples = Vec::with_capacity(n_boot);
let mut idx = vec![0usize; n];
for _ in 0..n_boot {
for slot in idx.iter_mut() {
*slot = rng.below(n);
}
let pooled = pool_selected(per_seed, &idx);
samples.push(deflated_sharpe(&pooled, n_trials, trials_sr_std));
}
let lo = quantile(&samples, alpha / 2.0);
let hi = quantile(&samples, 1.0 - alpha / 2.0);
DsrCi {
point,
lo,
hi,
width: hi - lo,
confidence,
n_boot,
}
}
pub fn paired_dsr_diff(
a_per_seed: &[Vec<f64>],
b_per_seed: &[Vec<f64>],
n_trials: u32,
trials_sr_std: f64,
n_boot: usize,
resample_seed: u64,
alpha: f64,
) -> PairedDiff {
let n = a_per_seed.len().min(b_per_seed.len());
let a_full: Vec<f64> = a_per_seed[..n].iter().flatten().copied().collect();
let b_full: Vec<f64> = b_per_seed[..n].iter().flatten().copied().collect();
let point_diff = deflated_sharpe(&a_full, n_trials, trials_sr_std)
- deflated_sharpe(&b_full, n_trials, trials_sr_std);
let confidence = 1.0 - alpha;
if n == 0 || n_boot == 0 {
let significant = point_diff != 0.0;
return PairedDiff {
point_diff,
lo: point_diff,
hi: point_diff,
p_value: if significant { 0.0 } else { 1.0 },
confidence,
significant,
verdict: verdict_for(point_diff, significant),
n_boot,
};
}
let mut rng = SplitMix64(resample_seed);
let mut diffs = Vec::with_capacity(n_boot);
let mut idx = vec![0usize; n];
let mut n_le = 0usize; let mut n_ge = 0usize; for _ in 0..n_boot {
for slot in idx.iter_mut() {
*slot = rng.below(n);
}
let a_pool = pool_selected(&a_per_seed[..n], &idx);
let b_pool = pool_selected(&b_per_seed[..n], &idx);
let d = deflated_sharpe(&a_pool, n_trials, trials_sr_std)
- deflated_sharpe(&b_pool, n_trials, trials_sr_std);
if d <= 0.0 {
n_le += 1;
}
if d >= 0.0 {
n_ge += 1;
}
diffs.push(d);
}
let lo = quantile(&diffs, alpha / 2.0);
let hi = quantile(&diffs, 1.0 - alpha / 2.0);
let tail = n_le.min(n_ge) as f64 / n_boot as f64;
let p_value = (2.0 * tail).min(1.0);
let significant = lo > 0.0 || hi < 0.0;
PairedDiff {
point_diff,
lo,
hi,
p_value,
confidence,
significant,
verdict: verdict_for(point_diff, significant),
n_boot,
}
}
fn verdict_for(point_diff: f64, significant: bool) -> String {
if !significant {
"tied".to_string()
} else if point_diff > 0.0 {
"a_better".to_string()
} else {
"b_better".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn steady_seed(offset: usize, len: usize) -> Vec<f64> {
(0..len)
.map(|i| 0.001 + 0.0002 * (((i + offset) % 7) as f64 - 3.0))
.collect()
}
fn seed_with_sharpe(target: f64, len: usize, phase: f64) -> Vec<f64> {
let base: Vec<f64> = (0..len).map(|i| (i as f64 + phase).sin()).collect();
let m = base.iter().sum::<f64>() / len as f64;
let var = base.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / (len as f64 - 1.0);
let sd = var.sqrt();
let scale = 0.01; base.iter()
.map(|x| target * scale + ((x - m) / sd) * scale)
.collect()
}
fn ci_default(per_seed: &[Vec<f64>]) -> DsrCi {
bootstrap_dsr_ci(per_seed, 56, TRIALS_SR_STD_DEFAULT, 2000, 0x00C1, 0.05)
}
#[test]
fn ci_brackets_the_point_dsr() {
let seeds: Vec<Vec<f64>> = (0..10).map(|s| steady_seed(s, 120)).collect();
let ci = ci_default(&seeds);
assert!(
ci.lo <= ci.point + 1e-12 && ci.point <= ci.hi + 1e-12,
"point {} must lie within [{}, {}]",
ci.point,
ci.lo,
ci.hi
);
assert!(ci.width >= 0.0);
}
#[test]
fn ci_is_wider_for_a_noisier_shorter_track() {
let stable: Vec<Vec<f64>> = (0..12)
.map(|s| seed_with_sharpe(0.42 + 0.01 * (s as f64 % 3.0), 40, s as f64))
.collect();
let noisy: Vec<Vec<f64>> = [0.05, 0.45, 0.9]
.iter()
.enumerate()
.map(|(s, &t)| seed_with_sharpe(t, 16, s as f64))
.collect();
let stable_ci = bootstrap_dsr_ci(&stable, 3, TRIALS_SR_STD_DEFAULT, 2000, 0x00C1, 0.05);
let noisy_ci = bootstrap_dsr_ci(&noisy, 3, TRIALS_SR_STD_DEFAULT, 2000, 0x00C1, 0.05);
assert!(
noisy_ci.width > stable_ci.width,
"noisy/short width {} should exceed stable/long width {}",
noisy_ci.width,
stable_ci.width
);
}
#[test]
fn paired_flags_close_entries_as_tied() {
let a: Vec<Vec<f64>> = (0..8).map(|s| steady_seed(s, 120)).collect();
let b: Vec<Vec<f64>> = (0..8)
.map(|s| {
let sign = if s % 2 == 0 { 1.0 } else { -1.0 };
steady_seed(s, 120)
.iter()
.map(|r| r + sign * 0.00003)
.collect()
})
.collect();
let d = paired_dsr_diff(&a, &b, 56, TRIALS_SR_STD_DEFAULT, 2000, 0x5EED, 0.05);
assert!(!d.significant, "close entries should be tied, got {d:?}");
assert_eq!(d.verdict, "tied");
assert!(d.lo <= 0.0 && d.hi >= 0.0, "tied CI must straddle 0: {d:?}");
}
#[test]
fn paired_separates_clearly_different_skill() {
let a: Vec<Vec<f64>> = (0..8).map(|s| steady_seed(s, 120)).collect();
let b: Vec<Vec<f64>> = (0..8)
.map(|s| steady_seed(s, 120).iter().map(|r| -r).collect())
.collect();
let d = paired_dsr_diff(&a, &b, 56, TRIALS_SR_STD_DEFAULT, 2000, 0x5EED, 0.05);
assert!(
d.significant,
"clear skill gap should be significant: {d:?}"
);
assert_eq!(d.verdict, "a_better");
assert!(
d.lo > 0.0,
"CI must exclude zero on the positive side: {d:?}"
);
assert!(d.p_value < 0.05, "p-value should be small: {d:?}");
}
#[test]
fn identical_entries_are_tied_with_zero_diff() {
let a: Vec<Vec<f64>> = (0..6).map(|s| steady_seed(s, 90)).collect();
let d = paired_dsr_diff(&a, &a, 56, TRIALS_SR_STD_DEFAULT, 500, 0x1234, 0.05);
assert_eq!(d.point_diff, 0.0);
assert!(!d.significant);
assert_eq!(d.verdict, "tied");
assert_eq!(d.p_value, 1.0);
}
#[test]
fn bootstrap_is_deterministic_per_resample_seed() {
let seeds: Vec<Vec<f64>> = (0..8).map(|s| steady_seed(s, 100)).collect();
let a = bootstrap_dsr_ci(&seeds, 56, TRIALS_SR_STD_DEFAULT, 1000, 42, 0.05);
let b = bootstrap_dsr_ci(&seeds, 56, TRIALS_SR_STD_DEFAULT, 1000, 42, 0.05);
assert_eq!(a.lo, b.lo);
assert_eq!(a.hi, b.hi);
}
}