use sharpebench_stats::StatisticalError;
pub fn percentile_of(value: f64, population: &[f64]) -> f64 {
if population.is_empty() {
return 0.0;
}
let n_le = population.iter().filter(|&&p| value >= p).count();
100.0 * n_le as f64 / population.len() as f64
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BaselineBand {
Below,
Within,
Above,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HumanBaseline {
pub floor_sharpe: f64,
pub median_sharpe: f64,
pub ceiling_sharpe: f64,
}
impl HumanBaseline {
pub fn skilled_trader(periods_per_year: f64) -> Result<Self, StatisticalError> {
if !periods_per_year.is_finite() || periods_per_year <= 0.0 {
return Err(StatisticalError::InvalidParameter {
name: "periods_per_year",
requirement: "must be finite and positive",
});
}
let per_period = |annual: f64| annual / periods_per_year.sqrt();
Ok(Self {
floor_sharpe: per_period(0.5),
median_sharpe: per_period(1.0),
ceiling_sharpe: per_period(2.0),
})
}
pub fn reference_dsr_population(
&self,
track_len: usize,
n_trials: u32,
trials_sr_std: f64,
) -> Result<Vec<f64>, StatisticalError> {
[self.floor_sharpe, self.median_sharpe, self.ceiling_sharpe]
.iter()
.map(|&sr| dsr_from_sharpe(sr, track_len, n_trials, trials_sr_std))
.collect()
}
pub fn classify_dsr(
&self,
dsr: f64,
track_len: usize,
n_trials: u32,
trials_sr_std: f64,
) -> Result<BaselineBand, StatisticalError> {
let floor = dsr_from_sharpe(self.floor_sharpe, track_len, n_trials, trials_sr_std)?;
let ceiling = dsr_from_sharpe(self.ceiling_sharpe, track_len, n_trials, trials_sr_std)?;
Ok(if dsr < floor {
BaselineBand::Below
} else if dsr > ceiling {
BaselineBand::Above
} else {
BaselineBand::Within
})
}
}
fn dsr_from_sharpe(
sr: f64,
track_len: usize,
n_trials: u32,
trials_sr_std: f64,
) -> Result<f64, StatisticalError> {
let sr_star = crate::deflated_sharpe::expected_max_sharpe(trials_sr_std, n_trials)?;
if track_len < 2 {
return Ok(0.0);
}
let denom = (1.0 + 0.5 * sr * sr).max(1e-12).sqrt();
let z = (sr - sr_star) * (track_len as f64 - 1.0).sqrt() / denom;
Ok(crate::stats::norm_cdf(z))
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn daily() -> (HumanBaseline, f64) {
(
HumanBaseline::skilled_trader(252.0).unwrap(),
0.5 / 252.0_f64.sqrt(),
)
}
#[test]
fn ranks_within_population() {
let pop = [0.2, 0.5, 0.8, 1.1, 1.4];
assert!(approx(percentile_of(0.9, &pop), 60.0)); assert!(approx(percentile_of(0.1, &pop), 0.0)); assert!(approx(percentile_of(2.0, &pop), 100.0)); assert!(approx(percentile_of(0.5, &pop), 40.0)); }
#[test]
fn empty_population_is_zero() {
assert_eq!(percentile_of(1.0, &[]), 0.0);
}
#[test]
fn skilled_trader_band_is_ordered_and_per_period() {
let (b, _) = daily();
assert!(b.floor_sharpe < b.median_sharpe && b.median_sharpe < b.ceiling_sharpe);
assert!(approx(b.median_sharpe, 1.0 / 252.0_f64.sqrt()));
}
#[test]
fn skilled_trader_follows_periods_per_year() {
for ppy in [52.0_f64, 252.0, 365.0, 2190.0, 8760.0] {
let b = HumanBaseline::skilled_trader(ppy).unwrap();
let annual = |sr: f64| sr * ppy.sqrt();
assert!(approx(annual(b.floor_sharpe), 0.5), "{ppy}");
assert!(approx(annual(b.median_sharpe), 1.0), "{ppy}");
assert!(approx(annual(b.ceiling_sharpe), 2.0), "{ppy}");
}
let daily = HumanBaseline::skilled_trader(252.0).unwrap();
let hourly = HumanBaseline::skilled_trader(8760.0).unwrap();
assert!(approx(
daily.median_sharpe / hourly.median_sharpe,
(8760.0_f64 / 252.0).sqrt()
));
}
#[test]
fn skilled_trader_periods_per_year_boundary() {
let expected = Err(StatisticalError::InvalidParameter {
name: "periods_per_year",
requirement: "must be finite and positive",
});
for bad in [
0.0,
-0.0,
-252.0,
f64::NAN,
f64::INFINITY,
f64::NEG_INFINITY,
] {
assert_eq!(HumanBaseline::skilled_trader(bad), expected, "{bad}");
}
let smallest = HumanBaseline::skilled_trader(f64::MIN_POSITIVE).unwrap();
assert!(smallest.ceiling_sharpe.is_finite());
assert!(approx(
HumanBaseline::skilled_trader(1.0).unwrap().median_sharpe,
1.0
));
}
#[test]
fn reference_population_is_ordered_and_scores_a_dsr() {
let (b, sigma) = daily();
let pop = b.reference_dsr_population(500, 50, sigma).unwrap();
assert_eq!(pop.len(), 3);
assert!(
pop[0] <= pop[1] && pop[1] <= pop[2],
"floor≤median≤ceiling DSR"
);
let pct = percentile_of(pop[1], &pop);
assert!((0.0..=100.0).contains(&pct));
}
#[test]
fn classify_dsr_brackets_the_band() {
let (b, disp) = daily();
let (len, nt) = (500, 50);
let pop = b.reference_dsr_population(len, nt, disp).unwrap();
assert_eq!(
b.classify_dsr(pop[0] - 0.1, len, nt, disp).unwrap(),
BaselineBand::Below
);
assert_eq!(
b.classify_dsr((pop[0] + pop[2]) / 2.0, len, nt, disp)
.unwrap(),
BaselineBand::Within
);
assert_eq!(
b.classify_dsr(pop[2] + 1e-6, len, nt, disp).unwrap(),
BaselineBand::Above
);
}
#[test]
fn an_invalid_dispersion_cannot_place_the_band() {
let (b, _) = daily();
let expected = StatisticalError::InvalidParameter {
name: "trials_sr_std",
requirement: "must be finite and non-negative",
};
for bad in [-1.0, f64::NAN, f64::INFINITY] {
assert_eq!(b.reference_dsr_population(500, 50, bad), Err(expected));
assert_eq!(b.classify_dsr(0.9, 500, 50, bad), Err(expected));
assert_eq!(b.reference_dsr_population(1, 50, bad), Err(expected));
}
}
#[test]
fn valid_baseline_inputs_return_the_same_numbers() {
let (b, sigma) = daily();
let pop = b.reference_dsr_population(500, 50, sigma).unwrap();
let sr_star = crate::deflated_sharpe::expected_max_sharpe(sigma, 50).unwrap();
let expect = |sr: f64| {
let denom = (1.0 + 0.5 * sr * sr).max(1e-12).sqrt();
crate::stats::norm_cdf((sr - sr_star) * (500.0_f64 - 1.0).sqrt() / denom)
};
assert_eq!(pop[0], expect(b.floor_sharpe));
assert_eq!(pop[1], expect(b.median_sharpe));
assert_eq!(pop[2], expect(b.ceiling_sharpe));
assert_eq!(
b.reference_dsr_population(1, 50, sigma).unwrap(),
vec![0.0; 3]
);
}
}