Skip to main content

wickra_core/indicators/
coefficient_of_variation.rs

1//! Rolling Coefficient of Variation (`StdDev / Mean`).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Coefficient of Variation — the rolling population standard deviation
9/// divided by the rolling mean.
10///
11/// ```text
12/// mean = (1/n) · Σ price
13/// sd   = √( (1/n) · Σ price² − mean² )
14/// CV   = sd / mean
15/// ```
16///
17/// CV is a dimensionless dispersion measure: it scales `StdDev` by the price
18/// level so two assets at very different price magnitudes can be compared
19/// directly. A higher CV means more relative variability for the same
20/// average price.
21///
22/// When the rolling mean is exactly zero the ratio is undefined; the
23/// indicator returns `0.0` in that degenerate case rather than producing a
24/// `NaN`/infinity.
25///
26/// # Example
27///
28/// ```
29/// use wickra_core::{CoefficientOfVariation, Indicator};
30///
31/// let mut indicator = CoefficientOfVariation::new(20).unwrap();
32/// let mut last = None;
33/// for i in 0..40 {
34///     last = indicator.update(100.0 + f64::from(i));
35/// }
36/// assert!(last.is_some());
37/// ```
38#[derive(Debug, Clone)]
39pub struct CoefficientOfVariation {
40    period: usize,
41    window: VecDeque<f64>,
42    sum: f64,
43    sum_sq: f64,
44}
45
46impl CoefficientOfVariation {
47    /// Construct a new rolling CV with the given period.
48    ///
49    /// # Errors
50    /// Returns [`Error::PeriodZero`] if `period == 0`.
51    pub fn new(period: usize) -> Result<Self> {
52        if period == 0 {
53            return Err(Error::PeriodZero);
54        }
55        Ok(Self {
56            period,
57            window: VecDeque::with_capacity(period),
58            sum: 0.0,
59            sum_sq: 0.0,
60        })
61    }
62
63    /// Configured period.
64    pub const fn period(&self) -> usize {
65        self.period
66    }
67}
68
69impl Indicator for CoefficientOfVariation {
70    type Input = f64;
71    type Output = f64;
72
73    fn update(&mut self, value: f64) -> Option<f64> {
74        if !value.is_finite() {
75            return None;
76        }
77        if self.window.len() == self.period {
78            let old = self.window.pop_front().expect("non-empty");
79            self.sum -= old;
80            self.sum_sq -= old * old;
81        }
82        self.window.push_back(value);
83        self.sum += value;
84        self.sum_sq += value * value;
85        if self.window.len() < self.period {
86            return None;
87        }
88        let n = self.period as f64;
89        let mean = self.sum / n;
90        let variance = (self.sum_sq / n - mean * mean).max(0.0);
91        let sd = variance.sqrt();
92        if mean == 0.0 {
93            // Undefined ratio: return 0 instead of NaN/inf so downstream
94            // consumers can keep arithmetic going on flat or zeroed series.
95            return Some(0.0);
96        }
97        Some(sd / mean)
98    }
99
100    fn reset(&mut self) {
101        self.window.clear();
102        self.sum = 0.0;
103        self.sum_sq = 0.0;
104    }
105
106    fn warmup_period(&self) -> usize {
107        self.period
108    }
109
110    fn is_ready(&self) -> bool {
111        self.window.len() == self.period
112    }
113
114    fn name(&self) -> &'static str {
115        "CoefficientOfVariation"
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::traits::BatchExt;
123    use approx::assert_relative_eq;
124
125    #[test]
126    fn rejects_zero_period() {
127        assert!(matches!(
128            CoefficientOfVariation::new(0),
129            Err(Error::PeriodZero)
130        ));
131    }
132
133    #[test]
134    fn accessors_and_metadata() {
135        let cv = CoefficientOfVariation::new(14).unwrap();
136        assert_eq!(cv.period(), 14);
137        assert_eq!(cv.warmup_period(), 14);
138        assert_eq!(cv.name(), "CoefficientOfVariation");
139    }
140
141    #[test]
142    fn reference_value() {
143        // CV(3) of [2, 4, 6]: mean = 4, variance = 8/3, sd = √(8/3); CV = sd / 4.
144        let mut cv = CoefficientOfVariation::new(3).unwrap();
145        let out = cv.batch(&[2.0, 4.0, 6.0]);
146        assert_eq!(out[0], None);
147        let expected = (8.0_f64 / 3.0).sqrt() / 4.0;
148        assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
149    }
150
151    #[test]
152    fn constant_series_yields_zero() {
153        let mut cv = CoefficientOfVariation::new(5).unwrap();
154        for o in cv.batch(&[42.0; 20]).into_iter().flatten() {
155            assert_relative_eq!(o, 0.0, epsilon = 1e-12);
156        }
157    }
158
159    #[test]
160    fn zero_mean_returns_zero() {
161        // [-1, 0, 1] has mean 0; the CV is defined to be 0 rather than NaN.
162        let mut cv = CoefficientOfVariation::new(3).unwrap();
163        let out = cv.batch(&[-1.0, 0.0, 1.0]);
164        assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12);
165    }
166
167    #[test]
168    fn reset_clears_state() {
169        let mut cv = CoefficientOfVariation::new(5).unwrap();
170        cv.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
171        assert!(cv.is_ready());
172        cv.reset();
173        assert!(!cv.is_ready());
174        assert_eq!(cv.update(1.0), None);
175    }
176
177    #[test]
178    fn batch_equals_streaming() {
179        let prices: Vec<f64> = (0..60)
180            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
181            .collect();
182        let batch = CoefficientOfVariation::new(14).unwrap().batch(&prices);
183        let mut b = CoefficientOfVariation::new(14).unwrap();
184        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
185        assert_eq!(batch, streamed);
186    }
187}