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