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 self.window.len() == self.period {
84 let old = self.window.pop_front().expect("non-empty");
85 let sq = old * old;
86 self.sum -= old;
87 self.sum_sq -= sq;
88 self.sum_cu -= old * sq;
89 self.sum_qu -= sq * sq;
90 }
91 self.window.push_back(value);
92 let sq = value * value;
93 self.sum += value;
94 self.sum_sq += sq;
95 self.sum_cu += value * sq;
96 self.sum_qu += sq * sq;
97 if self.window.len() < self.period {
98 return None;
99 }
100 let n = self.period as f64;
101 let mean = self.sum / n;
102 let m2 = (self.sum_sq / n - mean * mean).max(0.0);
103 if m2 == 0.0 {
104 return Some(0.0);
106 }
107 let mean_sq = mean * mean;
109 let m4 = self.sum_qu / n - 4.0 * mean * (self.sum_cu / n)
110 + 6.0 * mean_sq * (self.sum_sq / n)
111 - 3.0 * mean_sq * mean_sq;
112 Some(m4 / (m2 * m2) - 3.0)
113 }
114
115 fn reset(&mut self) {
116 self.window.clear();
117 self.sum = 0.0;
118 self.sum_sq = 0.0;
119 self.sum_cu = 0.0;
120 self.sum_qu = 0.0;
121 }
122
123 fn warmup_period(&self) -> usize {
124 self.period
125 }
126
127 fn is_ready(&self) -> bool {
128 self.window.len() == self.period
129 }
130
131 fn name(&self) -> &'static str {
132 "Kurtosis"
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::traits::BatchExt;
140 use approx::assert_relative_eq;
141
142 #[test]
143 fn rejects_period_below_four() {
144 assert!(Kurtosis::new(0).is_err());
145 assert!(Kurtosis::new(3).is_err());
146 assert!(Kurtosis::new(4).is_ok());
147 }
148
149 #[test]
150 fn accessors_and_metadata() {
151 let k = Kurtosis::new(14).unwrap();
152 assert_eq!(k.period(), 14);
153 assert_eq!(k.warmup_period(), 14);
154 assert_eq!(k.name(), "Kurtosis");
155 }
156
157 #[test]
158 fn two_point_distribution_is_negative_two() {
159 let mut k = Kurtosis::new(4).unwrap();
162 let out = k.batch(&[-1.0, 1.0, -1.0, 1.0]);
163 assert_relative_eq!(out[3].unwrap(), -2.0, epsilon = 1e-9);
164 }
165
166 #[test]
167 fn constant_series_yields_zero() {
168 let mut k = Kurtosis::new(5).unwrap();
169 for v in k.batch(&[42.0; 20]).into_iter().flatten() {
170 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
171 }
172 }
173
174 #[test]
175 fn outlier_window_is_leptokurtic() {
176 let mut k = Kurtosis::new(5).unwrap();
179 let out = k.batch(&[0.0, 0.0, 0.0, 0.0, 100.0]);
180 assert!(out[4].unwrap() > 0.0);
181 }
182
183 #[test]
184 fn reset_clears_state() {
185 let mut k = Kurtosis::new(5).unwrap();
186 k.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
187 assert!(k.is_ready());
188 k.reset();
189 assert!(!k.is_ready());
190 assert_eq!(k.update(1.0), None);
191 }
192
193 #[test]
194 fn batch_equals_streaming() {
195 let prices: Vec<f64> = (0..60)
196 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
197 .collect();
198 let batch = Kurtosis::new(14).unwrap().batch(&prices);
199 let mut b = Kurtosis::new(14).unwrap();
200 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
201 assert_eq!(batch, streamed);
202 }
203}