use super::{PercentileF32, PercentileF64};
macro_rules! impl_cvar {
($name:ident, $builder:ident, $ty:ty, $percentile_name:ident) => {
#[doc = concat!("use nexus_stats_core::statistics::", stringify!($name), ";")]
#[doc = concat!("let mut cvar = ", stringify!($name), "::builder().alpha(0.05).build().unwrap();")]
#[doc = concat!(" cvar.update((i % 1000 + 1) as ", stringify!($ty), ").unwrap();")]
#[derive(Debug, Clone)]
pub struct $name {
percentile: $percentile_name,
tail_sum: $ty,
tail_count: u64,
count: u64,
alpha: $ty,
}
#[doc = stringify!($name)]
#[derive(Debug, Clone)]
pub struct $builder {
alpha: Option<$ty>,
}
impl $name {
#[inline]
#[must_use]
pub fn builder() -> $builder {
$builder {
alpha: Option::None,
}
}
#[inline]
pub fn update(&mut self, sample: $ty) -> Result<(), crate::DataError> {
check_finite!(sample);
self.percentile.update(sample)?;
self.count += 1;
if self.percentile.is_primed() {
if let Option::Some(var) = self.percentile.percentile() {
if sample <= var {
self.tail_sum += sample;
self.tail_count += 1;
}
}
}
Ok(())
}
#[inline]
#[must_use]
pub fn cvar(&self) -> Option<$ty> {
if !self.is_primed() || self.tail_count == 0 {
return Option::None;
}
Option::Some(self.tail_sum / self.tail_count as $ty)
}
#[inline]
#[must_use]
pub fn var(&self) -> Option<$ty> {
self.percentile.percentile()
}
#[inline]
#[must_use]
pub fn alpha(&self) -> $ty {
self.alpha
}
#[inline]
#[must_use]
pub fn tail_count(&self) -> u64 {
self.tail_count
}
#[inline]
#[must_use]
pub fn count(&self) -> u64 {
self.count
}
#[inline]
#[must_use]
pub fn is_primed(&self) -> bool {
self.percentile.is_primed() && self.tail_count >= 1
}
#[inline]
pub fn reset(&mut self) {
self.percentile.reset();
self.tail_sum = 0.0 as $ty;
self.tail_count = 0;
self.count = 0;
}
}
impl $builder {
#[inline]
#[must_use]
pub fn alpha(mut self, alpha: $ty) -> Self {
self.alpha = Option::Some(alpha);
self
}
pub fn build(self) -> Result<$name, crate::ConfigError> {
let alpha = self
.alpha
.ok_or(crate::ConfigError::Missing("alpha"))?;
if !(alpha > 0.0 as $ty && alpha < 1.0 as $ty) {
return Err(crate::ConfigError::Invalid(
"alpha must be in (0, 1) exclusive",
));
}
let percentile = $percentile_name::new(alpha)?;
Ok($name {
percentile,
tail_sum: 0.0 as $ty,
tail_count: 0,
count: 0,
alpha,
})
}
}
};
}
impl_cvar!(CvarF64, CvarF64Builder, f64, PercentileF64);
impl_cvar!(CvarF32, CvarF32Builder, f32, PercentileF32);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_distribution() {
let mut cv = CvarF64::builder().alpha(0.05).build().unwrap();
for i in 0..10_000u64 {
let v = (i % 1000 + 1) as f64;
cv.update(v).unwrap();
}
assert!(cv.tail_count() > 0, "should have tail observations");
let cvar = cv.cvar().unwrap();
assert!(cvar < 500.0, "CVaR should be below median, got {cvar}");
assert!(cvar > 0.0, "CVaR should be positive, got {cvar}");
}
#[test]
fn var_matches_percentile() {
let mut cv = CvarF64::builder().alpha(0.10).build().unwrap();
let mut p = PercentileF64::new(0.10).unwrap();
for i in 1..=500 {
let v = i as f64;
cv.update(v).unwrap();
p.update(v).unwrap();
}
let var = cv.var().unwrap();
let pct = p.percentile().unwrap();
assert!(
(var - pct).abs() < 1.0,
"VaR {var} should match percentile {pct}"
);
}
#[test]
fn empty_returns_none() {
let cv = CvarF64::builder().alpha(0.05).build().unwrap();
assert!(cv.cvar().is_none());
assert!(cv.var().is_none());
assert!(!cv.is_primed());
}
#[test]
fn priming_phase() {
let mut cv = CvarF64::builder().alpha(0.05).build().unwrap();
for i in 1..=4 {
cv.update(i as f64).unwrap();
assert!(!cv.percentile.is_primed());
assert!(cv.cvar().is_none());
}
cv.update(5.0).unwrap();
}
#[test]
fn all_equal() {
let mut cv = CvarF64::builder().alpha(0.05).build().unwrap();
for _ in 0..200 {
cv.update(42.0).unwrap();
}
if let Some(cvar) = cv.cvar() {
assert!(
(cvar - 42.0).abs() < 1e-6,
"constant stream: CVaR should equal the constant, got {cvar}"
);
}
if let Some(var) = cv.var() {
assert!(
(var - 42.0).abs() < 1e-6,
"constant stream: VaR should equal the constant, got {var}"
);
}
}
#[test]
fn tail_heavier_than_var() {
let mut cv = CvarF64::builder().alpha(0.10).build().unwrap();
for i in 0..10_000u64 {
let v = (i % 1000 + 1) as f64;
cv.update(v).unwrap();
}
let cvar = cv.cvar().unwrap();
let var = cv.var().unwrap();
assert!(
cvar < var * 1.5,
"CVaR ({cvar}) should be roughly <= VaR ({var})"
);
assert!(
cv.tail_count() > 100,
"should have many tail observations at alpha=0.10"
);
}
#[test]
fn rejects_nan_inf() {
let mut cv = CvarF64::builder().alpha(0.05).build().unwrap();
assert!(cv.update(f64::NAN).is_err());
assert!(cv.update(f64::INFINITY).is_err());
assert!(cv.update(f64::NEG_INFINITY).is_err());
assert_eq!(cv.count(), 0);
}
#[test]
fn reset_clears() {
let mut cv = CvarF64::builder().alpha(0.05).build().unwrap();
for i in 1..=100 {
cv.update(i as f64).unwrap();
}
assert!(cv.count() > 0);
cv.reset();
assert_eq!(cv.count(), 0);
assert_eq!(cv.tail_count(), 0);
assert!(cv.cvar().is_none());
assert!(cv.var().is_none());
assert!((cv.alpha() - 0.05).abs() < 1e-10);
}
}