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 of the close.
7///
8/// `Z = (close - mean) / sd` over the last `period` closes, this one included, with the
9/// **population** standard deviation (divisor `period`); `0` when the deviation is below `1e-8`.
10/// `period` is at least 2.
11///
12/// First output: with the `period`-th bar. [`Indicator::reset`] clears the window.
13#[derive(Debug, Clone)]
14pub struct ZScoreEngine {
15    period: usize,
16    window: VecDeque<f64>,
17}
18
19impl ZScoreEngine {
20    pub fn new(period: usize) -> Self {
21        Self {
22            period: period.max(2),
23            window: VecDeque::with_capacity(period),
24        }
25    }
26}
27
28impl Indicator for ZScoreEngine {
29    fn name(&self) -> &str {
30        "zscore"
31    }
32
33    fn warmup_period(&self) -> usize {
34        self.period
35    }
36
37    fn reset(&mut self) {
38        self.window.clear();
39    }
40
41    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
42        self.window.push_back(bar.close);
43        if self.window.len() > self.period {
44            self.window.pop_front();
45        }
46
47        if self.window.len() < self.period {
48            return None;
49        }
50
51        let slice: Vec<f64> = self.window.iter().copied().collect();
52        let mean = rolling_mean(&slice);
53        let stddev = rolling_stddev(&slice);
54
55        let z = if stddev > 1e-8 {
56            (bar.close - mean) / stddev
57        } else {
58            0.0
59        };
60
61        Some(IndicatorOutput::new(z))
62    }
63
64    fn alerts(&self) -> Vec<IndicatorAlert> {
65        Vec::new()
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn test_zscore_calculation() {
75        let mut zsec = ZScoreEngine::new(5);
76        for i in 0..5 {
77            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + i as f64 * 10.0, 1000.0);
78            zsec.on_bar(&b);
79        }
80        let out = zsec
81            .on_bar(&Bar::new(5, 100.0, 105.0, 95.0, 140.0, 1000.0))
82            .unwrap();
83        assert!(out.value > 0.0);
84    }
85}