Skip to main content

nautilus_indicators/average/
zscore.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{collections::VecDeque, fmt::Display};
17
18use nautilus_core::correctness::FAILED;
19use nautilus_model::{
20    data::{Bar, QuoteTick, TradeTick},
21    enums::PriceType,
22};
23
24use crate::{
25    indicator::Indicator,
26    support::{MAX_PERIOD, ShiftedMoments},
27};
28
29/// Z-Score: how many standard deviations the latest price sits from its rolling
30/// mean.
31///
32/// ```text
33/// ZScore = (price - SMA(price, n)) / population_stddev(price, n)
34/// ```
35///
36/// A reading of `+2` means price is two standard deviations above its recent
37/// average, statistically stretched to the upside; `-2` is the mirror. It is the
38/// standard normalization behind mean-reversion strategies: a large magnitude
39/// flags an extension, a return toward `0` flags reversion. A window with zero
40/// dispersion yields `0` rather than dividing by zero.
41#[repr(C)]
42#[derive(Debug)]
43#[cfg_attr(
44    feature = "python",
45    pyo3::pyclass(module = "nautilus_trader.indicators")
46)]
47#[cfg_attr(
48    feature = "python",
49    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
50)]
51pub struct ZScore {
52    pub period: usize,
53    pub price_type: PriceType,
54    pub value: f64,
55    pub mean: f64,
56    pub std: f64,
57    pub count: usize,
58    pub initialized: bool,
59    has_inputs: bool,
60    window: VecDeque<f64>,
61    moments: ShiftedMoments,
62}
63
64impl Display for ZScore {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "{}({})", self.name(), self.period)
67    }
68}
69
70impl Indicator for ZScore {
71    fn name(&self) -> String {
72        stringify!(ZScore).to_string()
73    }
74
75    fn has_inputs(&self) -> bool {
76        self.has_inputs
77    }
78
79    fn initialized(&self) -> bool {
80        self.initialized
81    }
82
83    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
84        self.update_raw(quote.extract_price(self.price_type)?.into());
85        Ok(())
86    }
87
88    fn handle_trade(&mut self, trade: &TradeTick) {
89        self.update_raw((&trade.price).into());
90    }
91
92    fn handle_bar(&mut self, bar: &Bar) {
93        self.update_raw((&bar.close).into());
94    }
95
96    fn reset(&mut self) {
97        self.window.clear();
98        self.moments.reset();
99        self.value = 0.0;
100        self.mean = 0.0;
101        self.std = 0.0;
102        self.count = 0;
103        self.has_inputs = false;
104        self.initialized = false;
105    }
106}
107
108impl ZScore {
109    /// Creates a new [`ZScore`] instance.
110    ///
111    /// # Panics
112    ///
113    /// Panics if a requested allocation exceeds `MAX_PERIOD` elements.
114    /// Panics if `period` is zero.
115    #[must_use]
116    pub fn new(period: usize, price_type: Option<PriceType>) -> Self {
117        Self::new_checked(period, price_type).expect(FAILED)
118    }
119
120    /// Creates a new [`ZScore`] instance with a validated period.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if `period` is zero or exceeds `MAX_PERIOD`.
125    pub fn new_checked(period: usize, price_type: Option<PriceType>) -> anyhow::Result<Self> {
126        anyhow::ensure!(period <= MAX_PERIOD, "period cannot exceed {MAX_PERIOD}");
127        anyhow::ensure!(period > 0, "ZScore: period must be > 0 (received {period})");
128        Ok(Self {
129            period,
130            price_type: price_type.unwrap_or(PriceType::Last),
131            value: 0.0,
132            mean: 0.0,
133            std: 0.0,
134            count: 0,
135            has_inputs: false,
136            initialized: false,
137            window: VecDeque::with_capacity(period),
138            moments: ShiftedMoments::new(),
139        })
140    }
141
142    /// Updates the indicator with the given raw price value.
143    pub fn update_raw(&mut self, value: f64) {
144        if !value.is_finite() {
145            return;
146        }
147        self.count += 1;
148        self.has_inputs = true;
149
150        if self.window.len() == self.period
151            && let Some(old) = self.window.pop_front()
152        {
153            self.moments.evict(old);
154        }
155        self.window.push_back(value);
156        self.moments.push(value);
157        if self.moments.needs_reseed(self.period) {
158            self.moments.reseed(&self.window);
159        }
160
161        if self.window.len() < self.period {
162            return;
163        }
164        self.mean = self.moments.mean(self.period);
165        self.std = self.moments.std_dev(self.period);
166        // A window with no dispersion: the price is exactly its own mean.
167        self.value = if self.std == 0.0 {
168            0.0
169        } else {
170            (value - self.mean) / self.std
171        };
172        self.initialized = true;
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use rstest::rstest;
179
180    use super::*;
181    use crate::indicator::Indicator;
182
183    #[rstest]
184    fn test_name_and_display() {
185        let indicator = ZScore::new(20, None);
186        assert_eq!(indicator.name(), "ZScore");
187        assert_eq!(format!("{indicator}"), "ZScore(20)");
188        assert_eq!(indicator.period, 20);
189        assert!(!indicator.initialized());
190        assert!(!indicator.has_inputs());
191    }
192
193    #[rstest]
194    #[should_panic(expected = "period must be > 0")]
195    fn test_zero_period_panics() {
196        let _ = ZScore::new(0, None);
197    }
198
199    #[rstest]
200    fn test_first_value_on_period_th_input() {
201        let mut indicator = ZScore::new(5, None);
202        for i in 1..5 {
203            indicator.update_raw(f64::from(i));
204            assert!(!indicator.initialized(), "initialized early at input {i}");
205        }
206        indicator.update_raw(5.0);
207        assert!(indicator.initialized());
208    }
209
210    #[rstest]
211    fn test_reference_value() {
212        // Window [1, 3]: mean 2, population variance (1 + 9)/2 - 4 = 1, stddev 1;
213        // the latest price 3 is (3 - 2) / 1 = 1 stddev above.
214        let mut indicator = ZScore::new(2, None);
215        indicator.update_raw(1.0);
216        assert!(!indicator.initialized());
217        indicator.update_raw(3.0);
218        assert!(indicator.initialized());
219        assert_eq!(indicator.value, 1.0);
220    }
221
222    #[rstest]
223    fn test_constant_series_yields_zero() {
224        let mut indicator = ZScore::new(10, None);
225        for _ in 0..30 {
226            indicator.update_raw(42.0);
227        }
228        assert_eq!(indicator.value, 0.0);
229    }
230
231    #[rstest]
232    fn test_tiny_spread_far_from_previous_offset_keeps_its_deviation() {
233        // The window [100, 100 + 2^-24] has population deviation 2^-25, but it sits
234        // about 10 from the reference point the first reseed left, where
235        // `sum_sq / n - mean^2` cancels to zero unless the sums reseed.
236        let tiny = 2.0_f64.powi(-24);
237        let mut indicator = ZScore::new(2, None);
238        for value in [110.0, 100.0, 100.0 + tiny] {
239            indicator.update_raw(value);
240        }
241        assert_eq!(indicator.mean, 100.0 + tiny / 2.0);
242        assert_eq!(indicator.std, tiny / 2.0);
243        assert_eq!(indicator.value, 1.0);
244    }
245
246    #[rstest]
247    fn test_matches_naive_definition() {
248        // Independent two-pass reference computed straight from the definition.
249        fn naive(window: &[f64]) -> f64 {
250            let n = window.len() as f64;
251            let mean = window.iter().sum::<f64>() / n;
252            let var = window.iter().map(|y| (y - mean) * (y - mean)).sum::<f64>() / n;
253            (window[window.len() - 1] - mean) / var.sqrt()
254        }
255
256        let prices: Vec<f64> = (0..60)
257            .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
258            .collect();
259        let period = 20;
260        let mut indicator = ZScore::new(period, None);
261        let mut compared = 0_usize;
262
263        for (i, &p) in prices.iter().enumerate() {
264            indicator.update_raw(p);
265
266            if i + 1 < period {
267                continue;
268            }
269            let want = naive(&prices[i + 1 - period..=i]);
270            assert!(
271                (indicator.value - want).abs() <= 1e-12 * want.abs().max(1.0),
272                "bar {i}: got {} want {want}",
273                indicator.value
274            );
275            compared += 1;
276        }
277        assert_eq!(compared, prices.len() - period + 1);
278    }
279
280    #[rstest]
281    fn test_reset() {
282        let mut indicator = ZScore::new(5, None);
283        for i in 0..20 {
284            indicator.update_raw(f64::from(i));
285        }
286        indicator.reset();
287        assert!(!indicator.initialized());
288        assert!(!indicator.has_inputs());
289        assert_eq!(indicator.value, 0.0);
290        assert_eq!(indicator.count, 0);
291    }
292}