use crate::error::GeomError;
use crate::statistics::distributions::{ChiSquared, Distribution};
fn quantile(sorted: &[f64], alpha: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let position = alpha * (n - 1) as f64;
let lower = position.floor() as usize;
let upper = (lower + 1).min(n - 1);
let weight = position - lower as f64;
sorted[lower] * (1.0 - weight) + sorted[upper] * weight
}
fn check_returns(returns: &[f64], alpha: f64) -> Result<(), GeomError> {
if returns.len() < 2 || returns.iter().any(|r| !r.is_finite()) {
return Err(GeomError::InvalidArgument("at least two finite returns are required"));
}
if !(0.0..1.0).contains(&alpha) || alpha == 0.0 {
return Err(GeomError::InvalidArgument("the tail probability must lie in (0, 1)"));
}
Ok(())
}
pub fn var_historical(returns: &[f64], alpha: f64) -> Result<f64, GeomError> {
check_returns(returns, alpha)?;
let mut sorted = returns.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite returns"));
Ok(-quantile(&sorted, alpha))
}
pub fn var_parametric(returns: &[f64], alpha: f64) -> Result<f64, GeomError> {
check_returns(returns, alpha)?;
let n = returns.len() as f64;
let mean = returns.iter().sum::<f64>() / n;
let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
let deviation = variance.sqrt();
if !(deviation > 0.0) {
return Err(GeomError::Degenerate("the returns have no variation"));
}
Ok(-(mean + normal_quantile(alpha) * deviation))
}
fn normal_quantile(p: f64) -> f64 {
let cdf = |x: f64| crate::statistics::distributions::gaussian_cdf(x, 0.0, 1.0);
let (mut low, mut high) = (-40.0f64, 40.0f64);
for _ in 0..200 {
let mid = 0.5 * (low + high);
if cdf(mid) < p {
low = mid;
} else {
high = mid;
}
if high - low < 1e-15 * (1.0 + low.abs()) {
break;
}
}
0.5 * (low + high)
}
pub fn cvar_historical(returns: &[f64], alpha: f64) -> Result<f64, GeomError> {
check_returns(returns, alpha)?;
let mut sorted = returns.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite returns"));
let count = ((alpha * sorted.len() as f64).floor() as usize).max(1);
let tail: f64 = sorted[..count].iter().sum::<f64>() / count as f64;
Ok(-tail)
}
pub fn var_cornish_fisher(returns: &[f64], alpha: f64) -> Result<f64, GeomError> {
check_returns(returns, alpha)?;
if returns.len() < 4 {
return Err(GeomError::InvalidArgument("the expansion needs at least four returns"));
}
let n = returns.len() as f64;
let mean = returns.iter().sum::<f64>() / n;
let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
let deviation = variance.sqrt();
if !(deviation > 0.0) {
return Err(GeomError::Degenerate("the returns have no variation"));
}
let standardised = |power: i32| {
returns.iter().map(|r| ((r - mean) / deviation).powi(power)).sum::<f64>() / n
};
let skew = standardised(3);
let excess = standardised(4) - 3.0;
let z = normal_quantile(alpha);
let corrected = z
+ (z * z - 1.0) * skew / 6.0
+ (z * z * z - 3.0 * z) * excess / 24.0
- (2.0 * z * z * z - 5.0 * z) * skew * skew / 36.0;
let slope = 1.0 + z * skew / 3.0 + (z * z - 1.0) * excess / 8.0
- (6.0 * z * z - 5.0) * skew * skew / 36.0;
if !corrected.is_finite() || slope <= 0.0 {
return Err(GeomError::Degenerate(
"the sample's moments put it outside the Cornish-Fisher expansion's valid range",
));
}
Ok(-(mean + corrected * deviation))
}
pub fn garch_var_forecast(
model: &crate::stochastic::timeseries::Garch11,
returns: &[f64],
alpha: f64,
) -> Result<f64, GeomError> {
check_returns(returns, alpha)?;
let filtered = model.conditional_variance(returns);
let last_return = returns[returns.len() - 1];
let last_variance = filtered[filtered.len() - 1];
let projected = model.omega + model.alpha * last_return * last_return + model.beta * last_variance;
if !(projected > 0.0) || !projected.is_finite() {
return Err(GeomError::Degenerate("the projected variance is not positive"));
}
Ok(-normal_quantile(alpha) * projected.sqrt())
}
#[derive(Debug, Clone, PartialEq)]
pub struct BacktestStats {
pub total_return: f64,
pub trades: usize,
pub win_rate: f64,
pub max_drawdown: f64,
pub equity: Vec<f64>,
}
pub fn backtest_sma_crossover(
prices: &[f64],
fast: usize,
slow: usize,
) -> Result<BacktestStats, GeomError> {
if fast == 0 || slow == 0 || fast >= slow {
return Err(GeomError::InvalidArgument("the fast window must be shorter than the slow"));
}
if prices.len() < slow + 2 || prices.iter().any(|p| !(*p > 0.0) || !p.is_finite()) {
return Err(GeomError::InvalidArgument("backtest_sma_crossover: bad price series"));
}
let average = |end: usize, window: usize| -> f64 {
prices[end + 1 - window..=end].iter().sum::<f64>() / window as f64
};
let mut equity = vec![1.0];
let mut wealth = 1.0;
let mut holding = false;
let mut entry = 0.0;
let mut trades = 0usize;
let mut wins = 0usize;
for bar in (slow - 1)..prices.len() - 1 {
let signal = average(bar, fast) > average(bar, slow);
if signal && !holding {
holding = true;
entry = prices[bar];
} else if !signal && holding {
holding = false;
trades += 1;
if prices[bar] > entry {
wins += 1;
}
}
if holding {
wealth *= prices[bar + 1] / prices[bar];
}
equity.push(wealth);
}
if holding {
trades += 1;
if prices[prices.len() - 1] > entry {
wins += 1;
}
}
let mut peak = 0.0f64;
let mut worst = 0.0f64;
for value in &equity {
peak = peak.max(*value);
worst = worst.max((peak - value) / peak);
}
Ok(BacktestStats {
total_return: wealth - 1.0,
trades,
win_rate: if trades > 0 { wins as f64 / trades as f64 } else { 0.0 },
max_drawdown: worst,
equity,
})
}
pub fn kupiec_test(
violations: usize,
observations: usize,
alpha: f64,
) -> Result<crate::statistics::inference::TestResult, GeomError> {
if observations == 0 || violations > observations {
return Err(GeomError::InvalidArgument("kupiec_test: bad counts"));
}
if !(0.0..1.0).contains(&alpha) || alpha == 0.0 {
return Err(GeomError::InvalidArgument("the tail probability must lie in (0, 1)"));
}
let n = observations as f64;
let x = violations as f64;
let observed = x / n;
let term = |p: f64, count: f64| if count == 0.0 { 0.0 } else { count * p.ln() };
let null = term(alpha, x) + term(1.0 - alpha, n - x);
let fitted = term(observed, x) + term(1.0 - observed, n - x);
let statistic = (-2.0 * (null - fitted)).max(0.0);
let p_value = 1.0 - ChiSquared::new(1.0).cdf(statistic);
Ok(crate::statistics::inference::TestResult { statistic, p_value, df: 1.0 })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::monte_carlo::Rng;
fn gaussian_returns(n: usize, mean: f64, deviation: f64, seed: u64) -> Vec<f64> {
let mut rng = Rng::new(seed);
(0..n).map(|_| mean + deviation * rng.next_gaussian()).collect()
}
#[test]
fn a_loss_is_reported_as_a_positive_number() {
let returns = [-0.10f64, -0.05, 0.0, 0.05, 0.10];
let var = var_historical(&returns, 0.25).unwrap();
assert!(var > 0.0, "a loss came back as {var}");
assert!((var - 0.05).abs() < 1e-15, "got {var}");
let winners = [0.01f64, 0.02, 0.03, 0.04];
assert!(var_historical(&winners, 0.25).unwrap() < 0.0);
}
#[test]
fn expected_shortfall_is_never_below_the_quantile_it_averages_past() {
let mut rng = Rng::new(0x0F1D_1001);
for _ in 0..30 {
let n = 200 + (rng.next_f64() * 800.0) as usize;
let returns = gaussian_returns(n, 0.0005, 0.012, rng.next_u64());
for alpha in [0.01f64, 0.025, 0.05, 0.1, 0.25] {
let var = var_historical(&returns, alpha).unwrap();
let shortfall = cvar_historical(&returns, alpha).unwrap();
assert!(
shortfall >= var - 1e-12,
"at alpha={alpha} the shortfall {shortfall} fell under the VaR {var}"
);
assert!(shortfall > var, "the tail had no spread at alpha={alpha}");
}
}
}
#[test]
fn value_at_risk_rises_as_the_confidence_does() {
let returns = gaussian_returns(2000, 0.0, 0.01, 0x0F1D_1002);
let mut previous = f64::NEG_INFINITY;
for alpha in [0.25f64, 0.1, 0.05, 0.025, 0.01] {
let historical = var_historical(&returns, alpha).unwrap();
let parametric = var_parametric(&returns, alpha).unwrap();
assert!(historical > previous, "the historical VaR fell at alpha={alpha}");
previous = historical;
assert!(
(historical - parametric).abs() < 0.12 * parametric,
"at alpha={alpha}: {historical} against {parametric}"
);
}
}
#[test]
fn the_parametric_estimate_is_the_gaussian_quantile_it_claims_to_be() {
let returns = gaussian_returns(200_000, 0.001, 0.02, 0x0F1D_1003);
for (alpha, z) in [(0.05f64, -1.644_853_626_951_47f64), (0.01, -2.326_347_874_040_84)] {
let expected = -(0.001 + z * 0.02);
let parametric = var_parametric(&returns, alpha).unwrap();
assert!(
(parametric - expected).abs() < 0.02 * expected,
"at alpha={alpha}: {parametric} against {expected}"
);
}
assert!(var_parametric(&[0.01, 0.01, 0.01], 0.05).is_err());
assert!(var_historical(&[0.01], 0.05).is_err());
assert!(var_historical(&[0.01, 0.02], 0.0).is_err());
assert!(var_historical(&[0.01, 0.02], 1.0).is_err());
}
#[test]
fn the_correction_pulls_a_thin_tailed_sample_back_toward_its_own_quantile() {
let half: Vec<f64> = (1..=500).map(|k| k as f64 * 0.001).collect();
let mut symmetric: Vec<f64> = half.iter().map(|x| -x).collect();
symmetric.extend(half.iter());
let historical = var_historical(&symmetric, 0.01).unwrap();
let parametric = var_parametric(&symmetric, 0.01).unwrap();
let corrected = var_cornish_fisher(&symmetric, 0.01).unwrap();
assert!(parametric > historical, "the Gaussian fit should overstate a uniform tail");
assert!(
corrected < parametric && corrected > historical,
"the correction gave {corrected}, outside [{historical}, {parametric}]"
);
}
#[test]
fn the_kurtosis_term_changes_sign_inside_the_tail() {
let half: Vec<f64> = (1..=500).map(|k| k as f64 * 0.001).collect();
let mut symmetric: Vec<f64> = half.iter().map(|x| -x).collect();
symmetric.extend(half.iter());
let far = var_cornish_fisher(&symmetric, 0.01).unwrap()
- var_parametric(&symmetric, 0.01).unwrap();
let near = var_cornish_fisher(&symmetric, 0.05).unwrap()
- var_parametric(&symmetric, 0.05).unwrap();
assert!(far < 0.0, "at 1% the correction moved by {far}");
assert!(near > 0.0, "at 5% the correction moved by {near}");
let crossing = var_cornish_fisher(&symmetric, 0.0416).unwrap()
- var_parametric(&symmetric, 0.0416).unwrap();
assert!(crossing.abs() < 0.05 * far.abs(), "at the crossing it moved by {crossing}");
}
#[test]
fn a_mild_left_skew_is_where_the_correction_earns_its_keep() {
let mut returns = gaussian_returns(4000, 0.0005, 0.008, 0x0F1D_1004);
for k in 0..40 {
returns[k * 97] = -0.03;
}
let historical = var_historical(&returns, 0.01).unwrap();
let parametric = var_parametric(&returns, 0.01).unwrap();
let corrected = var_cornish_fisher(&returns, 0.01).unwrap();
assert!(parametric < historical, "the Gaussian fit should understate this tail");
assert!(
corrected > parametric && corrected < historical,
"the correction gave {corrected}, outside [{parametric}, {historical}]"
);
assert!(
(corrected - historical).abs() < (parametric - historical).abs(),
"the correction did not improve on the Gaussian fit"
);
}
#[test]
fn large_moments_make_the_expansion_overshoot_rather_than_fail() {
let mut returns = gaussian_returns(4000, 0.0005, 0.008, 0x0F1D_1004);
for k in 0..40 {
returns[k * 97] = -0.10;
}
let historical = var_historical(&returns, 0.01).unwrap();
let corrected = var_cornish_fisher(&returns, 0.01).unwrap();
assert!(
corrected > 2.0 * historical,
"the expansion gave {corrected} against a sample quantile of {historical}"
);
}
#[test]
fn a_quantile_that_would_run_backwards_is_refused() {
let mut returns = vec![0.001f64; 2000];
for (index, value) in returns.iter_mut().enumerate() {
*value = if index % 500 == 0 { -0.5 + 1.0 * f64::from(index % 1000 == 0) } else { 0.001 };
}
let refused = var_cornish_fisher(&returns, 0.4);
assert!(refused.is_err(), "an invalid expansion returned {refused:?}");
if let Ok(value) = var_cornish_fisher(&returns, 0.01) {
assert!(value.is_finite());
}
assert!(var_cornish_fisher(&[0.01, -0.01, 0.02], 0.05).is_err());
}
#[test]
fn value_at_risk_can_punish_diversification_where_expected_shortfall_cannot() {
let n = 10_000;
let mut a = vec![0.01f64; n];
let mut b = vec![0.01f64; n];
for i in 0..n {
if i % 25 == 0 {
a[i] = -1.0;
}
if (i + 7) % 25 == 0 {
b[i] = -1.0;
}
}
let mixed: Vec<f64> = a.iter().zip(b.iter()).map(|(x, y)| 0.5 * (x + y)).collect();
let var_a = var_historical(&a, 0.05).unwrap();
let var_b = var_historical(&b, 0.05).unwrap();
let var_mixed = var_historical(&mixed, 0.05).unwrap();
assert!(var_a < 0.0 && var_b < 0.0, "each bond's 95% VaR should be a gain");
assert!(var_mixed > 0.4, "the combined VaR was only {var_mixed}");
assert!(
var_mixed > var_a + var_b,
"VaR was subadditive here after all: {var_mixed} against {}",
var_a + var_b
);
let cvar_a = cvar_historical(&a, 0.05).unwrap();
let cvar_b = cvar_historical(&b, 0.05).unwrap();
let cvar_mixed = cvar_historical(&mixed, 0.05).unwrap();
assert!(
cvar_mixed <= cvar_a + cvar_b + 1e-12,
"expected shortfall was superadditive: {cvar_mixed} against {}",
cvar_a + cvar_b
);
assert!(cvar_a > 0.7, "the shortfall missed the defaults: {cvar_a}");
}
#[test]
fn a_garch_forecast_answers_to_what_just_happened() {
let model = crate::stochastic::timeseries::Garch11 { omega: 1e-5, alpha: 0.1, beta: 0.85 };
let calm = gaussian_returns(500, 0.0, 0.005, 0x0F1D_1005);
let mut stormy = calm.clone();
for value in stormy.iter_mut().rev().take(20) {
*value *= 8.0;
}
let after_calm = garch_var_forecast(&model, &calm, 0.01).unwrap();
let after_storm = garch_var_forecast(&model, &stormy, 0.01).unwrap();
assert!(after_storm > 1.5 * after_calm, "{after_storm} against {after_calm}");
assert!(after_calm > 0.0);
let flat = crate::stochastic::timeseries::Garch11 { omega: 4e-5, alpha: 0.0, beta: 0.0 };
let a = garch_var_forecast(&flat, &calm, 0.01).unwrap();
let b = garch_var_forecast(&flat, &stormy, 0.01).unwrap();
assert!((a - b).abs() < 1e-15, "a memoryless model still moved: {a} against {b}");
assert!(garch_var_forecast(&model, &[0.01], 0.01).is_err());
}
#[test]
fn kupiec_reports_no_evidence_when_the_breaches_land_where_they_should() {
let exact = kupiec_test(50, 1000, 0.05).unwrap();
assert!(exact.statistic.abs() < 1e-12, "the statistic was {}", exact.statistic);
assert!((exact.p_value - 1.0).abs() < 1e-12);
assert_eq!(exact.df, 1.0);
let understated = kupiec_test(120, 1000, 0.05).unwrap();
assert!(understated.statistic > 50.0, "got {}", understated.statistic);
assert!(understated.p_value < 1e-10);
let overstated = kupiec_test(5, 1000, 0.05).unwrap();
assert!(overstated.statistic > 20.0, "got {}", overstated.statistic);
assert!(overstated.p_value < 1e-5);
for count in [45usize, 50, 55] {
let result = kupiec_test(count, 1000, 0.05).unwrap();
assert!(result.p_value > 0.1, "{count} breaches gave p={}", result.p_value);
}
assert!(kupiec_test(0, 100, 0.05).unwrap().statistic.is_finite());
assert!(kupiec_test(100, 100, 0.05).unwrap().statistic.is_finite());
assert!(kupiec_test(101, 100, 0.05).is_err());
assert!(kupiec_test(0, 0, 0.05).is_err());
assert!(kupiec_test(5, 100, 1.0).is_err());
}
#[test]
fn a_historical_var_is_calibrated_against_its_own_sample_by_construction() {
let returns = gaussian_returns(4000, 0.0003, 0.011, 0x0F1D_1006);
for alpha in [0.01f64, 0.05, 0.1] {
let var = var_historical(&returns, alpha).unwrap();
let breaches = returns.iter().filter(|r| **r < -var).count();
let expected = alpha * returns.len() as f64;
assert!(
(breaches as f64 - expected).abs() < 0.2 * expected + 2.0,
"at alpha={alpha}: {breaches} breaches against {expected}"
);
assert!(kupiec_test(breaches, returns.len(), alpha).unwrap().p_value > 0.05);
}
}
#[test]
fn the_crossover_rule_matches_buy_and_hold_on_a_series_that_only_rises() {
let prices: Vec<f64> = (0..200).map(|k| 100.0 * 1.002f64.powi(k)).collect();
let stats = backtest_sma_crossover(&prices, 5, 20).unwrap();
assert_eq!(stats.trades, 1, "it should enter once and stay");
assert!((stats.win_rate - 1.0).abs() < 1e-15);
assert!(stats.max_drawdown < 1e-12, "a rising equity curve has no drawdown");
let invested = prices[199] / prices[19] - 1.0;
assert!(
(stats.total_return - invested).abs() < 1e-12,
"the rule made {} against the market's {invested}",
stats.total_return
);
assert_eq!(stats.equity.len(), prices.len() - 20 + 1);
let falling: Vec<f64> = (0..200).map(|k| 100.0 * 0.998f64.powi(k)).collect();
let bear = backtest_sma_crossover(&falling, 5, 20).unwrap();
assert_eq!(bear.trades, 0);
assert!(bear.total_return.abs() < 1e-15, "it lost {} while flat", bear.total_return);
assert!(bear.max_drawdown < 1e-15);
assert!(backtest_sma_crossover(&prices, 20, 5).is_err());
assert!(backtest_sma_crossover(&prices, 0, 5).is_err());
assert!(backtest_sma_crossover(&prices[..10], 5, 20).is_err());
assert!(backtest_sma_crossover(&[100.0, -1.0, 100.0, 100.0], 1, 2).is_err());
}
#[test]
fn the_backtest_does_not_look_at_the_bar_it_trades_on() {
let mut prices = vec![100.0f64; 30];
for (index, price) in prices.iter_mut().enumerate() {
*price = if index < 20 { 100.0 } else { 100.0 + (index - 19) as f64 };
}
let stats = backtest_sma_crossover(&prices, 3, 10).unwrap();
let invested_from = stats.equity.len();
assert!(invested_from > 1);
let best_possible = prices[prices.len() - 1] / prices[9] - 1.0;
assert!(
stats.total_return <= best_possible + 1e-12,
"the rule made {} where the most available was {best_possible}",
stats.total_return
);
}
}