wickra_core/indicators/
kurtosis.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
42pub struct Kurtosis {
43 period: usize,
44 window: VecDeque<f64>,
45 sum: f64,
46 sum_sq: f64,
47 sum_cu: f64,
48 sum_qu: f64,
49}
50
51impl Kurtosis {
52 pub fn new(period: usize) -> Result<Self> {
57 if period < 4 {
58 return Err(Error::InvalidPeriod {
59 message: "kurtosis needs period >= 4",
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 sum_qu: 0.0,
69 })
70 }
71
72 pub const fn period(&self) -> usize {
74 self.period
75 }
76}
77
78impl Indicator for Kurtosis {
79 type Input = f64;
80 type Output = f64;
81
82 fn update(&mut self, value: f64) -> Option<f64> {
83 if !value.is_finite() {
84 return None;
85 }
86 if self.window.len() == self.period {
87 let old = self.window.pop_front().expect("non-empty");
88 let sq = old * old;
89 self.sum -= old;
90 self.sum_sq -= sq;
91 self.sum_cu -= old * sq;
92 self.sum_qu -= sq * sq;
93 }
94 self.window.push_back(value);
95 let sq = value * value;
96 self.sum += value;
97 self.sum_sq += sq;
98 self.sum_cu += value * sq;
99 self.sum_qu += sq * sq;
100 if self.window.len() < self.period {
101 return None;
102 }
103 let n = self.period as f64;
104 let mean = self.sum / n;
105 let m2 = (self.sum_sq / n - mean * mean).max(0.0);
106 if m2 == 0.0 {
107 return Some(0.0);
109 }
110 let mean_sq = mean * mean;
112 let m4 = self.sum_qu / n - 4.0 * mean * (self.sum_cu / n)
113 + 6.0 * mean_sq * (self.sum_sq / n)
114 - 3.0 * mean_sq * mean_sq;
115 Some(m4 / (m2 * m2) - 3.0)
116 }
117
118 fn reset(&mut self) {
119 self.window.clear();
120 self.sum = 0.0;
121 self.sum_sq = 0.0;
122 self.sum_cu = 0.0;
123 self.sum_qu = 0.0;
124 }
125
126 fn warmup_period(&self) -> usize {
127 self.period
128 }
129
130 fn is_ready(&self) -> bool {
131 self.window.len() == self.period
132 }
133
134 fn name(&self) -> &'static str {
135 "Kurtosis"
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::traits::BatchExt;
143 use approx::assert_relative_eq;
144
145 #[test]
146 fn rejects_period_below_four() {
147 assert!(Kurtosis::new(0).is_err());
148 assert!(Kurtosis::new(3).is_err());
149 assert!(Kurtosis::new(4).is_ok());
150 }
151
152 #[test]
153 fn accessors_and_metadata() {
154 let k = Kurtosis::new(14).unwrap();
155 assert_eq!(k.period(), 14);
156 assert_eq!(k.warmup_period(), 14);
157 assert_eq!(k.name(), "Kurtosis");
158 }
159
160 #[test]
161 fn two_point_distribution_is_negative_two() {
162 let mut k = Kurtosis::new(4).unwrap();
165 let out = k.batch(&[-1.0, 1.0, -1.0, 1.0]);
166 assert_relative_eq!(out[3].unwrap(), -2.0, epsilon = 1e-9);
167 }
168
169 #[test]
170 fn constant_series_yields_zero() {
171 let mut k = Kurtosis::new(5).unwrap();
172 for v in k.batch(&[42.0; 20]).into_iter().flatten() {
173 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
174 }
175 }
176
177 #[test]
178 fn outlier_window_is_leptokurtic() {
179 let mut k = Kurtosis::new(5).unwrap();
182 let out = k.batch(&[0.0, 0.0, 0.0, 0.0, 100.0]);
183 assert!(out[4].unwrap() > 0.0);
184 }
185
186 #[test]
187 fn reset_clears_state() {
188 let mut k = Kurtosis::new(5).unwrap();
189 k.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
190 assert!(k.is_ready());
191 k.reset();
192 assert!(!k.is_ready());
193 assert_eq!(k.update(1.0), None);
194 }
195
196 #[test]
197 fn batch_equals_streaming() {
198 let prices: Vec<f64> = (0..60)
199 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
200 .collect();
201 let batch = Kurtosis::new(14).unwrap().batch(&prices);
202 let mut b = Kurtosis::new(14).unwrap();
203 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
204 assert_eq!(batch, streamed);
205 }
206}