Skip to main content

wickra_core/indicators/
skewness.rs

1//! Rolling Pearson skewness (third standardised central moment).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Pearson skewness of the last `period` values.
9///
10/// ```text
11/// mean = (1/n) · Σ x
12/// m2   = (1/n) · Σ (x − mean)²        // population variance
13/// m3   = (1/n) · Σ (x − mean)³        // third central moment
14/// Skew = m3 / m2^(3/2)
15/// ```
16///
17/// Positive skewness means the right tail (large positive deviations from
18/// the mean) is heavier than the left; negative skewness flags the
19/// opposite. A symmetric distribution has skewness `0`. This is the
20/// population (Pearson) definition with divisor `n`; many statistics
21/// packages report the bias-corrected sample skewness instead. The window
22/// is required to have at least three points so the moments are
23/// well-defined. A window with zero dispersion yields `0`.
24///
25/// Each `update` is O(1): three running sums (`Σ x`, `Σ x²`, `Σ x³`) are
26/// maintained as the window slides; the central moments are then derived
27/// from them via the binomial-expansion identities, so no inner loop runs
28/// per bar.
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Indicator, Skewness};
34///
35/// let mut indicator = Skewness::new(20).unwrap();
36/// let mut last = None;
37/// for i in 0..40 {
38///     last = indicator.update(f64::from(i));
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct Skewness {
44    period: usize,
45    window: VecDeque<f64>,
46    sum: f64,
47    sum_sq: f64,
48    sum_cu: f64,
49}
50
51impl Skewness {
52    /// Construct a new rolling skewness with the given period.
53    ///
54    /// # Errors
55    /// Returns [`Error::InvalidPeriod`] if `period < 3`.
56    pub fn new(period: usize) -> Result<Self> {
57        if period < 3 {
58            return Err(Error::InvalidPeriod {
59                message: "skewness needs period >= 3",
60            });
61        }
62        Ok(Self {
63            period,
64            window: VecDeque::with_capacity(period),
65            sum: 0.0,
66            sum_sq: 0.0,
67            sum_cu: 0.0,
68        })
69    }
70
71    /// Configured period.
72    pub const fn period(&self) -> usize {
73        self.period
74    }
75}
76
77impl Indicator for Skewness {
78    type Input = f64;
79    type Output = f64;
80
81    fn update(&mut self, value: f64) -> Option<f64> {
82        if self.window.len() == self.period {
83            let old = self.window.pop_front().expect("non-empty");
84            self.sum -= old;
85            self.sum_sq -= old * old;
86            self.sum_cu -= old * old * old;
87        }
88        self.window.push_back(value);
89        self.sum += value;
90        self.sum_sq += value * value;
91        self.sum_cu += value * value * value;
92        if self.window.len() < self.period {
93            return None;
94        }
95        let n = self.period as f64;
96        let mean = self.sum / n;
97        // m2 = E[x²] − E[x]²
98        let m2 = (self.sum_sq / n - mean * mean).max(0.0);
99        // m3 = E[x³] − 3·mean·E[x²] + 2·mean³ (binomial expansion).
100        let m3 = self.sum_cu / n - 3.0 * mean * (self.sum_sq / n) + 2.0 * mean * mean * mean;
101        if m2 == 0.0 {
102            // A window with no dispersion has no defined shape; return 0.
103            return Some(0.0);
104        }
105        Some(m3 / m2.powf(1.5))
106    }
107
108    fn reset(&mut self) {
109        self.window.clear();
110        self.sum = 0.0;
111        self.sum_sq = 0.0;
112        self.sum_cu = 0.0;
113    }
114
115    fn warmup_period(&self) -> usize {
116        self.period
117    }
118
119    fn is_ready(&self) -> bool {
120        self.window.len() == self.period
121    }
122
123    fn name(&self) -> &'static str {
124        "Skewness"
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::traits::BatchExt;
132    use approx::assert_relative_eq;
133
134    #[test]
135    fn rejects_period_below_three() {
136        assert!(Skewness::new(0).is_err());
137        assert!(Skewness::new(1).is_err());
138        assert!(Skewness::new(2).is_err());
139        assert!(Skewness::new(3).is_ok());
140    }
141
142    #[test]
143    fn accessors_and_metadata() {
144        let s = Skewness::new(14).unwrap();
145        assert_eq!(s.period(), 14);
146        assert_eq!(s.warmup_period(), 14);
147        assert_eq!(s.name(), "Skewness");
148    }
149
150    #[test]
151    fn symmetric_window_is_zero() {
152        // Symmetric around its mean — skewness must be (numerically) zero.
153        let mut s = Skewness::new(5).unwrap();
154        let out = s.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]);
155        assert_relative_eq!(out[4].unwrap(), 0.0, epsilon = 1e-9);
156    }
157
158    #[test]
159    fn constant_series_yields_zero() {
160        let mut s = Skewness::new(5).unwrap();
161        for v in s.batch(&[42.0; 20]).into_iter().flatten() {
162            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
163        }
164    }
165
166    #[test]
167    fn right_tail_is_positive() {
168        // One large positive outlier creates a right-skewed window.
169        let mut s = Skewness::new(5).unwrap();
170        let out = s.batch(&[0.0, 0.0, 0.0, 0.0, 10.0]);
171        assert!(out[4].unwrap() > 0.0);
172    }
173
174    #[test]
175    fn left_tail_is_negative() {
176        // Mirror image — one large negative outlier gives left skew.
177        let mut s = Skewness::new(5).unwrap();
178        let out = s.batch(&[10.0, 10.0, 10.0, 10.0, 0.0]);
179        assert!(out[4].unwrap() < 0.0);
180    }
181
182    #[test]
183    fn reset_clears_state() {
184        let mut s = Skewness::new(5).unwrap();
185        s.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
186        assert!(s.is_ready());
187        s.reset();
188        assert!(!s.is_ready());
189        assert_eq!(s.update(1.0), None);
190    }
191
192    #[test]
193    fn batch_equals_streaming() {
194        let prices: Vec<f64> = (0..60)
195            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 7.0)
196            .collect();
197        let batch = Skewness::new(14).unwrap().batch(&prices);
198        let mut b = Skewness::new(14).unwrap();
199        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
200        assert_eq!(batch, streamed);
201    }
202}