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        // `m2^1.5` as `m2 * sqrt(m2)`: `sqrt` is correctly rounded on every
107        // platform, where `powf` is the platform libm's and differs in the last
108        // bit between them -- and costs most of an update.
109        Some(m3 / (m2 * m2.sqrt()))
110    }
111
112    fn reset(&mut self) {
113        self.window.clear();
114        self.moments.reset();
115    }
116
117    #[inline]
118    fn warmup_period(&self) -> usize {
119        self.period
120    }
121
122    #[inline]
123    fn is_ready(&self) -> bool {
124        self.window.len() == self.period
125    }
126
127    #[inline]
128    fn name(&self) -> &'static str {
129        "Skewness"
130    }
131
132    /// SIMD kernel: shifted first to third power sums of the window as
133    /// window-sum scans, re-anchored every `16 · period` values like the exact
134    /// accumulator, finished lane-parallel with `m2 · sqrt(m2)` for the
135    /// power. Agrees with the exact batch to within a few units in the last
136    /// place relative to the moments involved; warmup `NaN`s and length are
137    /// identical. Skewness only remembers its last `period` inputs, so
138    /// afterwards the state is rebuilt exactly by replaying them.
139    fn batch_fast_into(&mut self, inputs: &[f64], out: &mut [f64]) {
140        assert_eq!(
141            inputs.len(),
142            out.len(),
143            "batch output length must equal input length"
144        );
145        let p = self.period;
146        if !self.window.is_empty() || inputs.len() < p || !crate::fast::in_range(inputs) {
147            self.batch_nan_into(inputs, out);
148            return;
149        }
150        crate::fast::with_scratch(crate::fast::power_scratch_len(3, p), |scratch| {
151            wickra_simd::dispatch(crate::fast::SkewnessFast {
152                x: inputs,
153                period: p,
154                scratch,
155                out,
156                _borrow: std::marker::PhantomData,
157            });
158        });
159        crate::fast::replay_tail(self, &inputs[inputs.len() - p..]);
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::traits::BatchExt;
167    use approx::assert_relative_eq;
168
169    #[test]
170    fn rejects_period_below_three() {
171        assert!(Skewness::new(0).is_err());
172        assert!(Skewness::new(1).is_err());
173        assert!(Skewness::new(2).is_err());
174        assert!(Skewness::new(3).is_ok());
175    }
176
177    #[test]
178    fn accessors_and_metadata() {
179        let s = Skewness::new(14).unwrap();
180        assert_eq!(s.period(), 14);
181        assert_eq!(s.warmup_period(), 14);
182        assert_eq!(s.name(), "Skewness");
183    }
184
185    #[test]
186    fn symmetric_window_is_zero() {
187        // Symmetric around its mean — skewness must be (numerically) zero.
188        let mut s = Skewness::new(5).unwrap();
189        let out = s.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]);
190        assert_relative_eq!(out[4].unwrap(), 0.0, epsilon = 1e-9);
191    }
192
193    #[test]
194    fn constant_series_yields_zero() {
195        let mut s = Skewness::new(5).unwrap();
196        for v in s.batch(&[42.0; 20]).into_iter().flatten() {
197            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
198        }
199    }
200
201    #[test]
202    fn right_tail_is_positive() {
203        // One large positive outlier creates a right-skewed window.
204        let mut s = Skewness::new(5).unwrap();
205        let out = s.batch(&[0.0, 0.0, 0.0, 0.0, 10.0]);
206        assert!(out[4].unwrap() > 0.0);
207    }
208
209    #[test]
210    fn left_tail_is_negative() {
211        // Mirror image — one large negative outlier gives left skew.
212        let mut s = Skewness::new(5).unwrap();
213        let out = s.batch(&[10.0, 10.0, 10.0, 10.0, 0.0]);
214        assert!(out[4].unwrap() < 0.0);
215    }
216
217    #[test]
218    fn reset_clears_state() {
219        let mut s = Skewness::new(5).unwrap();
220        s.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
221        assert!(s.is_ready());
222        s.reset();
223        assert!(!s.is_ready());
224        assert_eq!(s.update(1.0), None);
225    }
226
227    #[test]
228    fn batch_equals_streaming() {
229        let prices: Vec<f64> = (0..60)
230            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 7.0)
231            .collect();
232        let batch = Skewness::new(14).unwrap().batch(&prices);
233        let mut b = Skewness::new(14).unwrap();
234        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
235        assert_eq!(batch, streamed);
236    }
237}