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 !value.is_finite() {
83            return None;
84        }
85        if self.window.len() == self.period {
86            let old = self.window.pop_front().expect("non-empty");
87            self.sum -= old;
88            self.sum_sq -= old * old;
89            self.sum_cu -= old * old * old;
90        }
91        self.window.push_back(value);
92        self.sum += value;
93        self.sum_sq += value * value;
94        self.sum_cu += value * value * value;
95        if self.window.len() < self.period {
96            return None;
97        }
98        let n = self.period as f64;
99        let mean = self.sum / n;
100        // m2 = E[x²] − E[x]²
101        let m2 = (self.sum_sq / n - mean * mean).max(0.0);
102        // m3 = E[x³] − 3·mean·E[x²] + 2·mean³ (binomial expansion).
103        let m3 = self.sum_cu / n - 3.0 * mean * (self.sum_sq / n) + 2.0 * mean * mean * mean;
104        if m2 == 0.0 {
105            // A window with no dispersion has no defined shape; return 0.
106            return Some(0.0);
107        }
108        Some(m3 / m2.powf(1.5))
109    }
110
111    fn reset(&mut self) {
112        self.window.clear();
113        self.sum = 0.0;
114        self.sum_sq = 0.0;
115        self.sum_cu = 0.0;
116    }
117
118    fn warmup_period(&self) -> usize {
119        self.period
120    }
121
122    fn is_ready(&self) -> bool {
123        self.window.len() == self.period
124    }
125
126    fn name(&self) -> &'static str {
127        "Skewness"
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::traits::BatchExt;
135    use approx::assert_relative_eq;
136
137    #[test]
138    fn rejects_period_below_three() {
139        assert!(Skewness::new(0).is_err());
140        assert!(Skewness::new(1).is_err());
141        assert!(Skewness::new(2).is_err());
142        assert!(Skewness::new(3).is_ok());
143    }
144
145    #[test]
146    fn accessors_and_metadata() {
147        let s = Skewness::new(14).unwrap();
148        assert_eq!(s.period(), 14);
149        assert_eq!(s.warmup_period(), 14);
150        assert_eq!(s.name(), "Skewness");
151    }
152
153    #[test]
154    fn symmetric_window_is_zero() {
155        // Symmetric around its mean — skewness must be (numerically) zero.
156        let mut s = Skewness::new(5).unwrap();
157        let out = s.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]);
158        assert_relative_eq!(out[4].unwrap(), 0.0, epsilon = 1e-9);
159    }
160
161    #[test]
162    fn constant_series_yields_zero() {
163        let mut s = Skewness::new(5).unwrap();
164        for v in s.batch(&[42.0; 20]).into_iter().flatten() {
165            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
166        }
167    }
168
169    #[test]
170    fn right_tail_is_positive() {
171        // One large positive outlier creates a right-skewed window.
172        let mut s = Skewness::new(5).unwrap();
173        let out = s.batch(&[0.0, 0.0, 0.0, 0.0, 10.0]);
174        assert!(out[4].unwrap() > 0.0);
175    }
176
177    #[test]
178    fn left_tail_is_negative() {
179        // Mirror image — one large negative outlier gives left skew.
180        let mut s = Skewness::new(5).unwrap();
181        let out = s.batch(&[10.0, 10.0, 10.0, 10.0, 0.0]);
182        assert!(out[4].unwrap() < 0.0);
183    }
184
185    #[test]
186    fn reset_clears_state() {
187        let mut s = Skewness::new(5).unwrap();
188        s.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
189        assert!(s.is_ready());
190        s.reset();
191        assert!(!s.is_ready());
192        assert_eq!(s.update(1.0), None);
193    }
194
195    #[test]
196    fn batch_equals_streaming() {
197        let prices: Vec<f64> = (0..60)
198            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 7.0)
199            .collect();
200        let batch = Skewness::new(14).unwrap().batch(&prices);
201        let mut b = Skewness::new(14).unwrap();
202        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
203        assert_eq!(batch, streamed);
204    }
205}