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