Skip to main content

wickra_core/indicators/
jarque_bera.rs

1//! Jarque-Bera — a normality-test statistic on a rolling window.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Jarque-Bera — the Jarque-Bera test statistic measuring how far a window's
9/// distribution departs from normal, via its **skewness** and **excess
10/// kurtosis**.
11///
12/// ```text
13/// S  = skewness         = m3 / m2^(3/2)
14/// K  = excess kurtosis  = m4 / m2²  − 3
15/// JB = (period / 6) · ( S² + K²/4 )
16/// ```
17///
18/// where `m2`, `m3`, `m4` are the second, third and fourth central moments of the
19/// window. A perfectly normal sample has zero skew and zero excess kurtosis, so
20/// `JB = 0`; the statistic grows as the distribution becomes asymmetric (non-zero
21/// skew) or fat- or thin-tailed (non-zero excess kurtosis). Under the null of
22/// normality `JB` is asymptotically χ² with two degrees of freedom, so values
23/// above roughly `6` reject normality at the 95% level — a useful streaming flag
24/// for fat-tail / crash-risk regimes in a return series.
25///
26/// The statistic is `≥ 0`. A degenerate window with zero variance (`m2 == 0`)
27/// returns `0`. The first value lands after `period` inputs; each `update`
28/// recomputes the four moments over the window in O(`period`).
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Indicator, JarqueBera};
34///
35/// let mut indicator = JarqueBera::new(50).unwrap();
36/// let mut last = None;
37/// for i in 0..80 {
38///     last = indicator.update((f64::from(i) * 0.3).sin());
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct JarqueBera {
44    period: usize,
45    window: VecDeque<f64>,
46    last: Option<f64>,
47}
48
49impl JarqueBera {
50    /// Construct a rolling Jarque-Bera over `period` values.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`Error::PeriodZero`] if `period == 0` and
55    /// [`Error::InvalidPeriod`] if `period < 4` (the statistic is degenerate on
56    /// fewer than four points).
57    pub fn new(period: usize) -> Result<Self> {
58        if period == 0 {
59            return Err(Error::PeriodZero);
60        }
61        if period > crate::error::MAX_PERIOD {
62            return Err(Error::InvalidPeriod {
63                message: crate::error::PERIOD_ABOVE_MAX,
64            });
65        }
66        if period < 4 {
67            return Err(Error::InvalidPeriod {
68                message: "Jarque-Bera needs period >= 4",
69            });
70        }
71        Ok(Self {
72            period,
73            window: VecDeque::with_capacity(period),
74            last: None,
75        })
76    }
77
78    /// Configured window length.
79    pub const fn period(&self) -> usize {
80        self.period
81    }
82
83    /// Current value if available.
84    pub const fn value(&self) -> Option<f64> {
85        self.last
86    }
87
88    fn compute(&self) -> f64 {
89        let n = self.period as f64;
90        let mean = self.window.iter().sum::<f64>() / n;
91        let mut m2 = 0.0;
92        let mut m3 = 0.0;
93        let mut m4 = 0.0;
94        for &v in &self.window {
95            let d = v - mean;
96            let d2 = d * d;
97            m2 += d2;
98            m3 += d2 * d;
99            m4 += d2 * d2;
100        }
101        m2 /= n;
102        m3 /= n;
103        m4 /= n;
104        if m2 == 0.0 {
105            return 0.0;
106        }
107        // `m2^1.5` as `m2 * sqrt(m2)`, correctly rounded on every platform
108        // where `powf` is the platform libm's.
109        let skew = m3 / (m2 * m2.sqrt());
110        let excess_kurt = m4 / (m2 * m2) - 3.0;
111        (n / 6.0) * (skew * skew + excess_kurt * excess_kurt / 4.0)
112    }
113}
114
115impl Indicator for JarqueBera {
116    type Input = f64;
117    type Output = f64;
118
119    #[inline]
120    fn update(&mut self, input: f64) -> Option<f64> {
121        if !input.is_finite() {
122            return None;
123        }
124        if self.window.len() == self.period {
125            self.window.pop_front();
126        }
127        self.window.push_back(input);
128        if self.window.len() < self.period {
129            return None;
130        }
131        let out = self.compute();
132        self.last = Some(out);
133        Some(out)
134    }
135
136    fn reset(&mut self) {
137        self.window.clear();
138        self.last = None;
139    }
140
141    #[inline]
142    fn warmup_period(&self) -> usize {
143        self.period
144    }
145
146    #[inline]
147    fn is_ready(&self) -> bool {
148        self.last.is_some()
149    }
150
151    #[inline]
152    fn name(&self) -> &'static str {
153        "JarqueBera"
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::traits::BatchExt;
161    use approx::assert_relative_eq;
162
163    #[test]
164    fn rejects_invalid_period() {
165        assert!(matches!(JarqueBera::new(0), Err(Error::PeriodZero)));
166        assert!(matches!(
167            JarqueBera::new(3),
168            Err(Error::InvalidPeriod { .. })
169        ));
170        assert!(JarqueBera::new(4).is_ok());
171    }
172
173    #[test]
174    fn accessors_and_metadata() {
175        let jb = JarqueBera::new(50).unwrap();
176        assert_eq!(jb.period(), 50);
177        assert_eq!(jb.warmup_period(), 50);
178        assert_eq!(jb.name(), "JarqueBera");
179        assert!(!jb.is_ready());
180        assert_eq!(jb.value(), None);
181    }
182
183    #[test]
184    fn first_emission_at_warmup_period() {
185        let mut jb = JarqueBera::new(4).unwrap();
186        let out = jb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
187        for v in out.iter().take(3) {
188            assert!(v.is_none());
189        }
190        assert!(out[3].is_some());
191    }
192
193    #[test]
194    fn constant_window_is_zero() {
195        let mut jb = JarqueBera::new(8).unwrap();
196        let last = jb.batch(&[5.0; 12]).into_iter().flatten().last().unwrap();
197        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
198    }
199
200    #[test]
201    fn output_is_non_negative() {
202        let mut jb = JarqueBera::new(30).unwrap();
203        for v in jb
204            .batch(
205                &(0..200)
206                    .map(|i| (f64::from(i) * 0.3).sin() * 5.0)
207                    .collect::<Vec<_>>(),
208            )
209            .into_iter()
210            .flatten()
211        {
212            assert!(v >= 0.0, "JB must be non-negative, got {v}");
213        }
214    }
215
216    #[test]
217    fn skewed_window_exceeds_symmetric() {
218        // A symmetric window vs. one with a heavy outlier (high skew + kurtosis).
219        let symmetric: Vec<f64> = vec![-3.0, -1.0, 0.0, 1.0, 3.0, -2.0, 2.0, 0.0];
220        let skewed: Vec<f64> = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 20.0];
221        let jb_sym = JarqueBera::new(8)
222            .unwrap()
223            .batch(&symmetric)
224            .into_iter()
225            .flatten()
226            .last()
227            .unwrap();
228        let jb_skew = JarqueBera::new(8)
229            .unwrap()
230            .batch(&skewed)
231            .into_iter()
232            .flatten()
233            .last()
234            .unwrap();
235        assert!(
236            jb_skew > jb_sym,
237            "skewed ({jb_skew}) should exceed symmetric ({jb_sym})"
238        );
239    }
240
241    #[test]
242    fn ignores_non_finite() {
243        let mut jb = JarqueBera::new(4).unwrap();
244        let _ready = jb
245            .batch(&[1.0, 2.0, 3.0, 5.0])
246            .into_iter()
247            .flatten()
248            .last()
249            .unwrap();
250        assert_eq!(jb.update(f64::NAN), None);
251    }
252
253    #[test]
254    fn reset_clears_state() {
255        let mut jb = JarqueBera::new(4).unwrap();
256        jb.batch(&[1.0, 2.0, 3.0, 5.0]);
257        assert!(jb.is_ready());
258        jb.reset();
259        assert!(!jb.is_ready());
260        assert_eq!(jb.value(), None);
261        assert_eq!(jb.update(1.0), None);
262    }
263
264    #[test]
265    fn batch_equals_streaming() {
266        let xs: Vec<f64> = (0..120)
267            .map(|i| (f64::from(i) * 0.25).sin() * 9.0)
268            .collect();
269        let batch = JarqueBera::new(30).unwrap().batch(&xs);
270        let mut b = JarqueBera::new(30).unwrap();
271        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
272        assert_eq!(batch, streamed);
273    }
274}