Files
wickra/crates/wickra-core/src/indicators/variance.rs
T
kingchencandGitHub 05fcdd9a5e feat(family-12): add 13 Statistik/Regression indicators (#51)
* feat(family-12): add 13 Statistik/Regression indicators

Brings the Price Statistics family to 20 indicators (7 → 20) and the
total catalogue to 84 (71 → 84). Every indicator ships in the Rust
core plus Python, Node, and WASM bindings with full streaming ↔ batch
parity, fuzz coverage, and benches.

Scalar (f64 → f64):
- Variance, CoefficientOfVariation: rolling population variance and
  its dimensionless ratio with the mean. O(1) updates.
- Skewness, Kurtosis: rolling Pearson skewness and excess kurtosis,
  derived from running sums of x, x², x³, x⁴ via the binomial
  identities — also O(1) per bar.
- StandardError, DetrendedStdDev: standard error of estimate (n − 2)
  and population StdDev (n) of OLS residuals, sharing the LinReg
  O(1) sliding sums.
- RSquared: coefficient of determination of the rolling OLS fit; the
  trend-quality filter, clamped to [0, 1].
- MedianAbsoluteDeviation: robust dispersion estimator; O(period log
  period) per emission via two in-place sorts of a reusable scratch
  buffer.
- Autocorrelation(period, lag): rolling lag-k Pearson autocorrelation.
- HurstExponent(period, chunks): R/S-analysis trend-persistence
  estimator clamped to [0, 1].

Pair indicators (Input = (f64, f64)):
- PearsonCorrelation: rolling cross-series Pearson, O(1).
- Beta: rolling OLS slope of asset vs. benchmark (CAPM).
- SpearmanCorrelation: rolling rank correlation with mid-rank tie
  handling; O(period log period).

Touchpoints:
- crates/wickra-core: 13 new indicator modules + mod.rs / lib.rs
  re-exports.
- bindings/python: pyclasses + add_class registration + __init__.py
  import & __all__ updates. The pair indicators expose
  update(x, y) and batch(x, y) over two equally-sized numpy arrays.
- bindings/node: scalar indicators via node_scalar_indicator! macro;
  pair indicators via new node_pair_indicator! macro; explicit
  structs for Autocorrelation and HurstExponent (two-arg ctors).
  index.js extended with the new exports.
- bindings/wasm: scalar wrappers via wasm_scalar_indicator!; pair
  wrappers via new wasm_pair_indicator! macro.
- fuzz: every scalar drove through the generic helper; pair
  indicators stress-tested by pairing adjacent samples of the fuzz
  input.
- Python tests (test_new_indicators.py): added to SCALAR
  parametrisation, plus algebraic reference values
  (variance of [2,4,6] = 8/3, MAD ignoring outlier = 0, monotone
  non-linear Spearman = 1, two-to-one Beta = 2, etc.) and a
  streaming-vs-batch test for the pair indicators.
- Node tests (indicators.test.js): extended the scalar factories
  map and added a pair-indicator section with the same algebraic
  reference values.
- crates/wickra/benches: bench_scalar entries for all 10 single-
  input new indicators.
- README: counter 71 → 84; Price Statistics family-table row
  expanded with the 13 new indicators.
- CHANGELOG: Unreleased section documents the family addition.

Wiki drafts (ghost-ignored, manual sync to wickra.wiki at release
time): indicator-ideas/families/wiki/family-12-statistik-regression/
contains 13 deep-dive pages plus _Sidebar / Indicators-Overview /
Warmup-Periods / Home fragments for the curator merge.

cargo check --workspace --all-features: clean.

* fix(family-12): remove unreachable defensive guards in hurst_exponent

The three guards (m < 2 continue, end > buf.len() break, denom == 0.0
return) are by-construction unreachable given the constructor invariant
period >= 2 * chunks: m = period / k for k in 1..=chunks always
satisfies m >= 2 and end = (c+1) * m <= k * m <= period = buf.len(),
and m_1 = period and m_2 = period / 2 are always distinct so the slope
denominator is strictly positive. Removing them brings codecov/patch
back to 100%.
2026-05-25 23:42:05 +02:00

193 lines
5.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Rolling population variance.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling population variance over the last `period` values.
///
/// ```text
/// mean = (1/n) · Σ price
/// Variance = (1/n) · Σ price² mean²
/// ```
///
/// Variance is the squared standard deviation. It is the second central
/// moment of the rolling distribution and the natural input to risk
/// calculations that expect squared returns (e.g. portfolio variance,
/// covariance matrices). Use [`crate::StdDev`] when you need the
/// scale-preserving square root instead.
///
/// Floating-point cancellation can drive the running expression slightly
/// negative on perfectly constant inputs; the result is clamped to zero
/// before being returned so it stays a valid variance.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Variance};
///
/// let mut indicator = Variance::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Variance {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl Variance {
/// Construct a new rolling variance with the given period.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Variance {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(value);
self.sum += value;
self.sum_sq += value * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
Some((self.sum_sq / n - mean * mean).max(0.0))
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Variance"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Variance::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let v = Variance::new(14).unwrap();
assert_eq!(v.period(), 14);
assert_eq!(v.warmup_period(), 14);
assert_eq!(v.name(), "Variance");
}
#[test]
fn reference_value() {
// Variance(3) of [2, 4, 6]: mean = 4, variance = (4 + 0 + 4) / 3 = 8/3.
let mut v = Variance::new(3).unwrap();
let out = v.batch(&[2.0, 4.0, 6.0]);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_relative_eq!(out[2].unwrap(), 8.0 / 3.0, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut v = Variance::new(5).unwrap();
for o in v.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(o, 0.0, epsilon = 1e-12);
}
}
#[test]
fn first_value_on_period_th_input() {
let mut v = Variance::new(5).unwrap();
let out = v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
for (i, x) in out.iter().enumerate().take(4) {
assert!(x.is_none(), "index {i} must be None during warmup");
}
assert!(out[4].is_some());
}
#[test]
fn reset_clears_state() {
let mut v = Variance::new(5).unwrap();
v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
assert_eq!(v.update(1.0), None);
}
#[test]
fn equals_stddev_squared() {
// The rolling Variance must equal the rolling population StdDev squared.
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 7.0)
.collect();
let mut var = Variance::new(14).unwrap();
let mut sd = crate::StdDev::new(14).unwrap();
for &p in &prices {
let (v, s) = (var.update(p), sd.update(p));
assert_eq!(v.is_some(), s.is_some());
if let (Some(v), Some(s)) = (v, s) {
assert_relative_eq!(v, s * s, epsilon = 1e-9);
}
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).cos() * 10.0)
.collect();
let batch = Variance::new(14).unwrap().batch(&prices);
let mut b = Variance::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}