Skip to main content

kestrel_chartkit/indicator/
zscore.rs

1use super::{Indicator, IndicatorAlert, IndicatorOutput};
2use crate::model::Bar;
3use crate::stats::{rolling_mean, rolling_stddev};
4use std::collections::VecDeque;
5
6/// Rolling Z-Score Engine.
7/// Z = (Price - Mean) / StdDev
8#[derive(Debug, Clone)]
9pub struct ZScoreEngine {
10    period: usize,
11    window: VecDeque<f64>,
12}
13
14impl ZScoreEngine {
15    pub fn new(period: usize) -> Self {
16        Self {
17            period: period.max(2),
18            window: VecDeque::with_capacity(period),
19        }
20    }
21}
22
23impl Indicator for ZScoreEngine {
24    fn name(&self) -> &str {
25        "zscore"
26    }
27
28    fn warmup_period(&self) -> usize {
29        self.period
30    }
31
32    fn reset(&mut self) {
33        self.window.clear();
34    }
35
36    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
37        self.window.push_back(bar.close);
38        if self.window.len() > self.period {
39            self.window.pop_front();
40        }
41
42        if self.window.len() < self.period {
43            return None;
44        }
45
46        let slice: Vec<f64> = self.window.iter().copied().collect();
47        let mean = rolling_mean(&slice);
48        let stddev = rolling_stddev(&slice);
49
50        let z = if stddev > 1e-8 {
51            (bar.close - mean) / stddev
52        } else {
53            0.0
54        };
55
56        Some(IndicatorOutput::new(z))
57    }
58
59    fn alerts(&self) -> Vec<IndicatorAlert> {
60        Vec::new()
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn test_zscore_calculation() {
70        let mut zsec = ZScoreEngine::new(5);
71        for i in 0..5 {
72            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + i as f64 * 10.0, 1000.0);
73            zsec.on_bar(&b);
74        }
75        let out = zsec
76            .on_bar(&Bar::new(5, 100.0, 105.0, 95.0, 140.0, 1000.0))
77            .unwrap();
78        assert!(out.value > 0.0);
79    }
80}