1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
//! Rolling Z-Score indicator.
//!
//! Computes `(value - rolling_mean) / rolling_stddev` over a configurable
//! sliding window of scalar observations.
use std::collections::VecDeque;
use rust_decimal::Decimal;
use crate::error::IndicatorError;
use crate::stddev::decimal_sqrt;
/// Rolling z-score over a sliding window of scalar observations.
///
/// Feeds individual `Decimal` values via [`update`](Self::update) and returns
/// the current z-score via [`value`](Self::value).
///
/// Returns `None` when the window is not yet filled or when the standard
/// deviation is zero (all values identical).
///
/// # Example
///
/// ```
/// use quant_indicators::RollingZScore;
/// use rust_decimal_macros::dec;
///
/// let mut zs = RollingZScore::new(3).unwrap();
/// zs.update(dec!(1));
/// zs.update(dec!(2));
/// assert!(zs.value().is_none()); // window not filled
/// zs.update(dec!(3));
/// assert!(zs.value().is_some()); // z-score available
/// ```
#[derive(Debug, Clone)]
pub struct RollingZScore {
window: usize,
values: VecDeque<Decimal>,
}
impl RollingZScore {
/// Create a new rolling z-score with the given window size.
///
/// # Errors
///
/// Returns `InvalidParameter` if `window` is 0 or 1 (z-score requires
/// at least 2 observations for variance).
#[must_use = "returns Result that may contain an error"]
pub fn new(window: usize) -> Result<Self, IndicatorError> {
if window <= 1 {
return Err(IndicatorError::InvalidParameter {
message: format!("RollingZScore window must be > 1, got {}", window),
});
}
Ok(Self {
window,
values: VecDeque::with_capacity(window),
})
}
/// Feed a new observation into the rolling window.
///
/// If the window is full, the oldest observation is evicted.
pub fn update(&mut self, value: Decimal) {
if self.values.len() == self.window {
self.values.pop_front();
}
self.values.push_back(value);
}
/// Return the current z-score, or `None` if the window is not yet
/// filled or the standard deviation is zero.
#[must_use]
pub fn value(&self) -> Option<Decimal> {
if self.values.len() < self.window {
return None;
}
let n = Decimal::from(self.window as u64);
let sum: Decimal = self.values.iter().copied().sum();
let mean = sum / n;
let variance_sum: Decimal = self
.values
.iter()
.map(|v| {
let diff = *v - mean;
diff * diff
})
.sum();
let variance = variance_sum / n;
let stddev = decimal_sqrt(variance);
if stddev.is_zero() {
return None;
}
let latest = self.values.back()?;
Some((*latest - mean) / stddev)
}
}
#[cfg(test)]
#[path = "rolling_zscore_tests.rs"]
mod tests;