wickra_core/indicators/
kurtosis.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedHigherMoments;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
43pub struct Kurtosis {
44 period: usize,
45 window: VecDeque<f64>,
46 moments: ShiftedHigherMoments,
47}
48
49impl Kurtosis {
50 pub fn new(period: usize) -> Result<Self> {
55 if period < 4 {
56 return Err(Error::InvalidPeriod {
57 message: "kurtosis needs period >= 4",
58 });
59 }
60 if period > crate::error::MAX_PERIOD {
61 return Err(Error::InvalidPeriod {
62 message: crate::error::PERIOD_ABOVE_MAX,
63 });
64 }
65 Ok(Self {
66 period,
67 window: VecDeque::with_capacity(period),
68 moments: ShiftedHigherMoments::new(),
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 #[inline]
83 fn update(&mut self, value: f64) -> Option<f64> {
84 if !value.is_finite() {
85 return None;
86 }
87 if self.window.len() == self.period {
88 let old = self.window.pop_front().expect("non-empty");
89 self.moments.evict(old);
90 }
91 self.window.push_back(value);
92 self.moments.push(value);
93 if self.moments.needs_reseed(self.period) {
94 self.moments.reseed(self.window.iter().copied());
95 }
96 if self.window.len() < self.period {
97 return None;
98 }
99 let m2 = self.moments.m2(self.period);
100 if m2 == 0.0 {
101 return Some(0.0);
103 }
104 let m4 = self.moments.m4(self.period);
105 Some(m4 / (m2 * m2) - 3.0)
106 }
107
108 fn reset(&mut self) {
109 self.window.clear();
110 self.moments.reset();
111 }
112
113 #[inline]
114 fn warmup_period(&self) -> usize {
115 self.period
116 }
117
118 #[inline]
119 fn is_ready(&self) -> bool {
120 self.window.len() == self.period
121 }
122
123 #[inline]
124 fn name(&self) -> &'static str {
125 "Kurtosis"
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use crate::traits::BatchExt;
133 use approx::assert_relative_eq;
134
135 #[test]
136 fn rejects_period_below_four() {
137 assert!(Kurtosis::new(0).is_err());
138 assert!(Kurtosis::new(3).is_err());
139 assert!(Kurtosis::new(4).is_ok());
140 }
141
142 #[test]
143 fn accessors_and_metadata() {
144 let k = Kurtosis::new(14).unwrap();
145 assert_eq!(k.period(), 14);
146 assert_eq!(k.warmup_period(), 14);
147 assert_eq!(k.name(), "Kurtosis");
148 }
149
150 #[test]
151 fn two_point_distribution_is_negative_two() {
152 let mut k = Kurtosis::new(4).unwrap();
155 let out = k.batch(&[-1.0, 1.0, -1.0, 1.0]);
156 assert_relative_eq!(out[3].unwrap(), -2.0, epsilon = 1e-9);
157 }
158
159 #[test]
160 fn constant_series_yields_zero() {
161 let mut k = Kurtosis::new(5).unwrap();
162 for v in k.batch(&[42.0; 20]).into_iter().flatten() {
163 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
164 }
165 }
166
167 #[test]
168 fn outlier_window_is_leptokurtic() {
169 let mut k = Kurtosis::new(5).unwrap();
172 let out = k.batch(&[0.0, 0.0, 0.0, 0.0, 100.0]);
173 assert!(out[4].unwrap() > 0.0);
174 }
175
176 #[test]
177 fn reset_clears_state() {
178 let mut k = Kurtosis::new(5).unwrap();
179 k.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
180 assert!(k.is_ready());
181 k.reset();
182 assert!(!k.is_ready());
183 assert_eq!(k.update(1.0), None);
184 }
185
186 #[test]
187 fn batch_equals_streaming() {
188 let prices: Vec<f64> = (0..60)
189 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
190 .collect();
191 let batch = Kurtosis::new(14).unwrap().batch(&prices);
192 let mut b = Kurtosis::new(14).unwrap();
193 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
194 assert_eq!(batch, streamed);
195 }
196}