kestrel_chartkit/indicator/
zscore.rs1use super::{Indicator, IndicatorAlert, IndicatorOutput};
2use crate::model::Bar;
3use crate::stats::{rolling_mean, rolling_stddev};
4use std::collections::VecDeque;
5
6#[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}