use serde::Serialize;
use crate::deflated_sharpe::{deflated_sharpe_ratio, sharpe_ratio};
use crate::significance::bootstrap_pvalue;
#[derive(Clone, Debug, Serialize)]
pub struct BudgetCurveOpts {
pub periods_per_year: f64,
pub base_n_trials: u32,
pub trials_sr_std: f64,
pub bootstrap_seed: u64,
pub n_boot: usize,
pub block_prob: f64,
}
impl Default for BudgetCurveOpts {
fn default() -> Self {
Self {
periods_per_year: 252.0,
base_n_trials: 1,
trials_sr_std: 0.5,
bootstrap_seed: 0x5BA7_2026,
n_boot: 2000,
block_prob: 0.1,
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct BudgetPoint {
pub budget: f64,
pub n_returns: usize,
pub oos_dsr: f64,
pub oos_sharpe: f64,
pub oos_sharpe_annualized: f64,
pub oos_p_value: f64,
pub marginal_dsr_per_budget: Option<f64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct BudgetCurveReport {
pub points: Vec<BudgetPoint>,
pub n_budget_points: usize,
pub peak_budget: f64,
pub peak_dsr: f64,
pub peak_dsr_deflated_for_selection: f64,
pub overfit_onset: Option<f64>,
pub is_monotone_improving: bool,
}
pub fn budget_curve(
points: &[(f64, &[f64])],
opts: &BudgetCurveOpts,
) -> Result<BudgetCurveReport, String> {
if points.len() < 2 {
return Err(format!(
"at least two budget points are required, got {}",
points.len()
));
}
let n_budget_points = points.len();
for i in 0..n_budget_points {
let (budget, returns) = points[i];
if returns.len() < 2 {
return Err(format!(
"point {i} (budget {budget}) has {} held-out returns; at least 2 are required",
returns.len()
));
}
if i > 0 {
let prev = points[i - 1].0;
if budget <= prev {
return Err(format!(
"budgets must be strictly increasing: point {i} budget {budget} is not > point {} budget {prev}",
i - 1
));
}
}
}
let ann = opts.periods_per_year.max(0.0).sqrt();
let mut curve: Vec<BudgetPoint> = Vec::with_capacity(n_budget_points);
for i in 0..n_budget_points {
let (budget, returns) = points[i];
let oos_dsr = deflated_sharpe_ratio(returns, opts.base_n_trials, opts.trials_sr_std);
let oos_sharpe = sharpe_ratio(returns);
let oos_p_value =
bootstrap_pvalue(returns, opts.bootstrap_seed, opts.n_boot, opts.block_prob);
let marginal_dsr_per_budget = if i == 0 {
None
} else {
let db = budget - points[i - 1].0;
Some((oos_dsr - curve[i - 1].oos_dsr) / db)
};
curve.push(BudgetPoint {
budget,
n_returns: returns.len(),
oos_dsr,
oos_sharpe,
oos_sharpe_annualized: oos_sharpe * ann,
oos_p_value,
marginal_dsr_per_budget,
});
}
let mut peak_idx = 0usize;
for i in 1..n_budget_points {
if curve[i].oos_dsr > curve[peak_idx].oos_dsr {
peak_idx = i;
}
}
let peak_budget = curve[peak_idx].budget;
let peak_dsr = curve[peak_idx].oos_dsr;
let peak_footprint = opts.base_n_trials.saturating_add(n_budget_points as u32);
let peak_dsr_deflated_for_selection =
deflated_sharpe_ratio(points[peak_idx].1, peak_footprint, opts.trials_sr_std);
let overfit_onset = curve
.iter()
.skip(1)
.find(|p| matches!(p.marginal_dsr_per_budget, Some(m) if m <= 0.0))
.map(|p| p.budget);
let is_monotone_improving = curve
.iter()
.skip(1)
.all(|p| matches!(p.marginal_dsr_per_budget, Some(m) if m > 0.0));
Ok(BudgetCurveReport {
points: curve,
n_budget_points,
peak_budget,
peak_dsr,
peak_dsr_deflated_for_selection,
overfit_onset,
is_monotone_improving,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn window(mean_ret: f64, amp: f64, n: usize) -> Vec<f64> {
(0..n)
.map(|i| mean_ret + amp * (i as f64 * 0.7).sin())
.collect()
}
fn curve_of(pts: &[(f64, Vec<f64>)]) -> Vec<(f64, &[f64])> {
pts.iter().map(|(b, w)| (*b, w.as_slice())).collect()
}
#[test]
fn overfitting_curve_flags_the_turn_down_before_the_max_budget() {
let pts = vec![
(1.0, window(0.0007, 0.02, 40)),
(2.0, window(0.0014, 0.02, 40)),
(3.0, window(0.0020, 0.02, 40)), (4.0, window(0.0013, 0.02, 40)),
(5.0, window(0.0006, 0.02, 40)), ];
let c = curve_of(&pts);
let r = budget_curve(&c, &BudgetCurveOpts::default()).unwrap();
assert_eq!(r.peak_budget, 3.0, "peak is the interior budget: {r:?}");
assert!(
r.peak_budget < 5.0,
"peak must precede the max budget (the EdgeBench differentiator)"
);
assert_eq!(
r.overfit_onset,
Some(4.0),
"onset is the first budget where held-out edge stopped rising"
);
assert!(!r.is_monotone_improving, "a turn-down is not monotone");
}
#[test]
fn improving_then_plateauing_curve_has_no_early_onset() {
let plateau = window(0.0020, 0.02, 40);
let pts = vec![
(1.0, window(0.0007, 0.02, 40)),
(2.0, window(0.0014, 0.02, 40)),
(3.0, plateau.clone()),
(4.0, plateau.clone()),
(5.0, plateau),
];
let c = curve_of(&pts);
let r = budget_curve(&c, &BudgetCurveOpts::default()).unwrap();
assert_eq!(r.peak_budget, 3.0, "peak sits at the plateau start: {r:?}");
match r.overfit_onset {
None => {}
Some(b) => assert!(
b >= 4.0,
"any onset must be on the flat tail (budget {b}), not before the plateau"
),
}
assert!(
!r.is_monotone_improving,
"a flat tail is not strictly rising"
);
}
#[test]
fn deflation_for_selection_strictly_raises_the_bar() {
let pts = vec![
(1.0, window(0.0010, 0.02, 40)),
(2.0, window(0.0014, 0.02, 40)),
(3.0, window(0.0012, 0.02, 40)),
];
let c = curve_of(&pts);
let r = budget_curve(&c, &BudgetCurveOpts::default()).unwrap();
assert!(
r.peak_dsr_deflated_for_selection < r.peak_dsr,
"selection deflation must strictly lower the peak: {} !< {}",
r.peak_dsr_deflated_for_selection,
r.peak_dsr
);
}
#[test]
fn marginal_is_none_first_then_tracks_the_dsr_sign() {
let pts = vec![
(1.0, window(0.0007, 0.02, 40)),
(2.0, window(0.0020, 0.02, 40)), (3.0, window(0.0005, 0.02, 40)), ];
let c = curve_of(&pts);
let r = budget_curve(&c, &BudgetCurveOpts::default()).unwrap();
assert!(
r.points[0].marginal_dsr_per_budget.is_none(),
"first is None"
);
let m1 = r.points[1].marginal_dsr_per_budget.unwrap();
let d1 = r.points[1].oos_dsr - r.points[0].oos_dsr;
assert!(m1 > 0.0 && d1 > 0.0, "rising step ⇒ positive marginal");
assert_eq!(
m1.signum(),
d1.signum(),
"marginal sign tracks the DSR delta"
);
let m2 = r.points[2].marginal_dsr_per_budget.unwrap();
let d2 = r.points[2].oos_dsr - r.points[1].oos_dsr;
assert!(m2 < 0.0 && d2 < 0.0, "falling step ⇒ negative marginal");
assert_eq!(
m2.signum(),
d2.signum(),
"marginal sign tracks the DSR delta"
);
}
#[test]
fn monotone_improving_true_only_for_a_strictly_rising_curve() {
let rising_pts = [
(1.0, window(0.0006, 0.02, 40)),
(2.0, window(0.0013, 0.02, 40)),
(3.0, window(0.0020, 0.02, 40)),
];
let rising = curve_of(&rising_pts);
let r = budget_curve(&rising, &BudgetCurveOpts::default()).unwrap();
assert!(r.is_monotone_improving, "strictly rising DSR ⇒ true: {r:?}");
assert!(r.overfit_onset.is_none(), "a rising curve never turns down");
let dipped_pts = [
(1.0, window(0.0006, 0.02, 40)),
(2.0, window(0.0020, 0.02, 40)),
(3.0, window(0.0013, 0.02, 40)),
];
let dipped = curve_of(&dipped_pts);
let r2 = budget_curve(&dipped, &BudgetCurveOpts::default()).unwrap();
assert!(!r2.is_monotone_improving, "a dip breaks monotonicity");
}
#[test]
fn degenerate_inputs_error_cleanly() {
let opts = BudgetCurveOpts::default();
assert!(budget_curve(&[], &opts).is_err());
let one = window(0.005, 0.005, 40);
assert!(budget_curve(&[(1.0, one.as_slice())], &opts).is_err());
let a = window(0.005, 0.005, 40);
let b = window(0.005, 0.005, 40);
assert!(budget_curve(&[(2.0, a.as_slice()), (2.0, b.as_slice())], &opts).is_err());
assert!(budget_curve(&[(3.0, a.as_slice()), (1.0, b.as_slice())], &opts).is_err());
let full = window(0.005, 0.005, 40);
let empty: Vec<f64> = Vec::new();
assert!(budget_curve(&[(1.0, full.as_slice()), (2.0, empty.as_slice())], &opts).is_err());
let single = vec![0.01];
assert!(budget_curve(&[(1.0, full.as_slice()), (2.0, single.as_slice())], &opts).is_err());
}
#[test]
fn annualized_sharpe_scales_the_raw_sharpe() {
let pts_owned = [
(1.0, window(0.005, 0.008, 80)),
(2.0, window(0.006, 0.007, 80)),
];
let pts = curve_of(&pts_owned);
let opts = BudgetCurveOpts {
periods_per_year: 252.0,
..BudgetCurveOpts::default()
};
let r = budget_curve(&pts, &opts).unwrap();
let p0 = &r.points[0];
assert!(
(p0.oos_sharpe_annualized - p0.oos_sharpe * 252.0_f64.sqrt()).abs() < 1e-12,
"annualized = raw * sqrt(periods_per_year)"
);
}
}