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
16//! Rolling z-score over a fixed window.
17
18use std::fmt::Display;
19
20use arraydeque::{ArrayDeque, Wrapping};
21use nautilus_core::correctness::{FAILED, check_predicate_true};
22use nautilus_model::{
23    data::{Bar, QuoteTick, TradeTick},
24    enums::PriceType,
25};
26
27use crate::indicator::Indicator;
28
29const MAX_PERIOD: usize = 1_024;
30
31#[repr(C)]
32#[derive(Debug)]
33#[cfg_attr(
34    feature = "python",
35    pyo3::pyclass(module = "nautilus_trader.indicators")
36)]
37#[cfg_attr(
38    feature = "python",
39    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
40)]
41pub struct ZScore {
42    pub period: usize,
43    pub price_type: PriceType,
44    pub value: f64,
45    pub mean: f64,
46    pub std: f64,
47    pub count: usize,
48    inputs: ArrayDeque<f64, MAX_PERIOD, Wrapping>,
49    pub initialized: bool,
50}
51
52impl Display for ZScore {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}({})", self.name(), self.period)
55    }
56}
57
58impl Indicator for ZScore {
59    fn name(&self) -> String {
60        stringify!(ZScore).into()
61    }
62
63    fn has_inputs(&self) -> bool {
64        self.count > 0
65    }
66
67    fn initialized(&self) -> bool {
68        self.initialized
69    }
70
71    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
72        self.process_raw(quote.extract_price(self.price_type)?.into());
73        Ok(())
74    }
75
76    fn handle_trade(&mut self, trade: &TradeTick) {
77        self.process_raw(trade.price.into());
78    }
79
80    fn handle_bar(&mut self, bar: &Bar) {
81        self.process_raw(bar.close.into());
82    }
83
84    fn reset(&mut self) {
85        self.value = 0.0;
86        self.mean = 0.0;
87        self.std = 0.0;
88        self.count = 0;
89        self.inputs.clear();
90        self.initialized = false;
91    }
92}
93
94impl ZScore {
95    /// Creates a new [`ZScore`] instance.
96    ///
97    /// Computes `(x - mean) / std` using sample standard deviation. The window
98    /// expands until `period` observations, then rolls at that length. With one
99    /// observation or a finite constant window, `mean` matches the input exactly,
100    /// while `std` and `value` are 0. Other zero `std` values produce `value` 0;
101    /// non-finite `std` values produce `value` `NaN`. `price_type` affects only
102    /// quote handling.
103    ///
104    /// # Panics
105    ///
106    /// Panics if `period` is less than 2 or greater than `MAX_PERIOD`.
107    #[must_use]
108    pub fn new(period: usize, price_type: Option<PriceType>) -> Self {
109        Self::new_checked(period, price_type).expect(FAILED)
110    }
111
112    /// Creates a new [`ZScore`] instance with the given period.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if `period` is less than 2 or greater than `MAX_PERIOD`.
117    pub fn new_checked(period: usize, price_type: Option<PriceType>) -> anyhow::Result<Self> {
118        check_predicate_true(period >= 2, "`period` must be at least 2")?;
119        check_predicate_true(period <= MAX_PERIOD, "`period` exceeds MAX_PERIOD")?;
120
121        Ok(Self {
122            period,
123            price_type: price_type.unwrap_or(PriceType::Last),
124            value: 0.0,
125            mean: 0.0,
126            std: 0.0,
127            count: 0,
128            inputs: ArrayDeque::new(),
129            initialized: false,
130        })
131    }
132
133    /// Updates the indicator with a raw observation.
134    pub fn update_raw(&mut self, value: f64) {
135        self.process_raw(value);
136    }
137
138    fn process_raw(&mut self, value: f64) {
139        if self.inputs.len() == self.period {
140            let _ = self.inputs.pop_front();
141        } else {
142            self.count += 1;
143        }
144
145        let _ = self.inputs.push_back(value);
146
147        let n = self.count as f64;
148        self.mean = self.inputs.iter().sum::<f64>() / n;
149        self.initialized = self.count >= self.period;
150
151        if self.count < 2 {
152            self.std = 0.0;
153            self.value = 0.0;
154            return;
155        }
156
157        let mean = self.mean;
158        let (m2, is_constant) = self
159            .inputs
160            .iter()
161            .fold((0.0, true), |(m2, is_constant), &x| {
162                let d = x - mean;
163                (
164                    m2 + d * d,
165                    is_constant && x.is_finite() && x.to_bits() == value.to_bits(),
166                )
167            });
168
169        if is_constant {
170            self.mean = value;
171            self.std = 0.0;
172            self.value = 0.0;
173            return;
174        }
175
176        self.std = (m2 / (n - 1.0)).sqrt();
177        self.value = if self.std == 0.0 {
178            0.0
179        } else if self.std.is_finite() {
180            (value - self.mean) / self.std
181        } else {
182            f64::NAN
183        };
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use arraydeque::{ArrayDeque, Wrapping};
190    use nautilus_model::{
191        data::{Bar, QuoteTick, TradeTick},
192        enums::PriceType,
193    };
194    use proptest::prelude::*;
195    use rstest::rstest;
196
197    use super::{MAX_PERIOD, ZScore};
198    use crate::{
199        indicator::Indicator,
200        stubs::*,
201        testing::{approx_equal_with, assert_approx_equal},
202    };
203
204    /// Batch z-score of `window` using sample std (`n - 1`).
205    fn batch_zscore(window: &[f64]) -> (f64, f64, f64) {
206        let n = window.len() as f64;
207        let mean = window.iter().sum::<f64>() / n;
208        let m2: f64 = window
209            .iter()
210            .map(|x| {
211                let d = x - mean;
212                d * d
213            })
214            .sum();
215        let std = (m2 / (n - 1.0)).sqrt();
216        let x = *window.last().unwrap();
217        let is_constant = window
218            .iter()
219            .all(|&value| value.is_finite() && value.to_bits() == x.to_bits());
220        let z = if is_constant || std == 0.0 {
221            0.0
222        } else {
223            (x - mean) / std
224        };
225        (mean, std, z)
226    }
227
228    #[rstest]
229    fn zscore_initialized_state(indicator_zscore_10: ZScore) {
230        assert_eq!(format!("{indicator_zscore_10}"), "ZScore(10)");
231        assert_eq!(indicator_zscore_10.period, 10);
232        assert_eq!(indicator_zscore_10.price_type, PriceType::Mid);
233        assert_eq!(indicator_zscore_10.value, 0.0);
234        assert_eq!(indicator_zscore_10.mean, 0.0);
235        assert_eq!(indicator_zscore_10.std, 0.0);
236        assert_eq!(indicator_zscore_10.count, 0);
237        assert!(!indicator_zscore_10.initialized());
238        assert!(!indicator_zscore_10.has_inputs());
239    }
240
241    #[rstest]
242    fn zscore_default_price_type_is_last() {
243        let z = ZScore::new(5, None);
244        assert_eq!(z.price_type, PriceType::Last);
245    }
246
247    #[rstest]
248    fn zscore_initializes_at_period() {
249        let mut z = ZScore::new(5, None);
250        for i in 1..5 {
251            z.update_raw(f64::from(i));
252            assert!(!z.initialized());
253        }
254        z.update_raw(5.0);
255        assert!(z.initialized());
256        assert_eq!(z.count, 5);
257        assert!(z.has_inputs());
258    }
259
260    #[rstest]
261    fn zscore_constant_series_is_zero() {
262        let mut z = ZScore::new(4, None);
263        for _ in 0..8 {
264            z.update_raw(3.0);
265        }
266        assert_eq!(z.std, 0.0);
267        assert_eq!(z.value, 0.0);
268        assert_eq!(z.mean, 3.0);
269    }
270
271    #[rstest]
272    #[case(1.000_03, 10)]
273    #[case(0.1, 20)]
274    fn zscore_constant_series_with_rounding_error_is_zero(
275        #[case] value: f64,
276        #[case] period: usize,
277    ) {
278        let mut z = ZScore::new(period, None);
279        for _ in 0..period {
280            z.update_raw(value);
281        }
282
283        assert_eq!(z.mean, value);
284        assert_eq!(z.std, 0.0);
285        assert_eq!(z.value, 0.0);
286    }
287
288    #[rstest]
289    fn zscore_preserves_non_finite_value() {
290        let mut z = ZScore::new(2, None);
291        z.update_raw(1.0);
292        z.update_raw(f64::NAN);
293
294        assert!(z.std.is_nan());
295        assert!(z.value.is_nan());
296    }
297
298    #[rstest]
299    #[case::mean_overflow(f64::MAX, f64::MAX / 2.0)]
300    #[case::variance_overflow(-f64::MAX, f64::MAX)]
301    fn zscore_propagates_non_finite_arithmetic(#[case] first: f64, #[case] second: f64) {
302        let mut z = ZScore::new(2, None);
303        z.update_raw(first);
304        z.update_raw(second);
305
306        assert_eq!(z.count, 2);
307        assert!(z.initialized);
308        assert!(z.std.is_infinite());
309        assert!(z.value.is_nan());
310    }
311
312    #[rstest]
313    fn zscore_expanding_window_before_period() {
314        let mut z = ZScore::new(5, None);
315
316        z.update_raw(2.0);
317        assert!(!z.initialized());
318        assert_eq!(z.count, 1);
319        assert_eq!(z.mean, 2.0);
320        assert_eq!(z.std, 0.0);
321        assert_eq!(z.value, 0.0);
322
323        z.update_raw(4.0);
324        assert!(!z.initialized());
325        assert_eq!(z.count, 2);
326        assert_eq!(z.mean, 3.0);
327        assert_approx_equal(z.std, 2.0_f64.sqrt());
328        assert_approx_equal(z.value, 1.0 / 2.0_f64.sqrt());
329    }
330
331    #[rstest]
332    fn zscore_transitions_from_expanding_to_rolling() {
333        let mut z = ZScore::new(3, None);
334        z.update_raw(2.0);
335        z.update_raw(4.0);
336        z.update_raw(6.0);
337
338        assert!(z.initialized());
339        assert_eq!(z.count, 3);
340        assert_eq!(z.mean, 4.0);
341        assert_eq!(z.std, 2.0);
342        assert_eq!(z.value, 1.0);
343
344        z.update_raw(8.0);
345        assert_eq!(z.count, 3);
346        assert_eq!(z.mean, 6.0);
347        assert_eq!(z.std, 2.0);
348        assert_eq!(z.value, 1.0);
349    }
350
351    #[rstest]
352    fn zscore_matches_batch_window() {
353        let mut z = ZScore::new(5, None);
354        let inputs = [3.0, 5.0, 7.0, 8.0, 1.0, 9.0, 12.0, 4.0, 6.0, 7.0];
355        let mut window: ArrayDeque<f64, 5, Wrapping> = ArrayDeque::new();
356
357        for &x in &inputs {
358            if window.len() == 5 {
359                let _ = window.pop_front();
360            }
361            let _ = window.push_back(x);
362            z.update_raw(x);
363
364            if window.len() >= 2 {
365                let w: Vec<f64> = window.iter().copied().collect();
366                let (mean, std, batch_z) = batch_zscore(&w);
367                assert_approx_equal(z.mean, mean);
368                assert_approx_equal(z.std, std);
369                assert_approx_equal(z.value, batch_z);
370            }
371        }
372    }
373
374    #[rstest]
375    fn zscore_handle_bar_uses_close(bar_ethusdt_binance_minute_bid: Bar) {
376        let mut z = ZScore::new(2, None);
377        z.handle_bar(&bar_ethusdt_binance_minute_bid);
378        z.handle_bar(&bar_ethusdt_binance_minute_bid);
379        assert!(z.has_inputs());
380        let close: f64 = bar_ethusdt_binance_minute_bid.close.into();
381        assert_eq!(z.mean, close);
382        assert_eq!(z.value, 0.0);
383    }
384
385    #[rstest]
386    fn zscore_handle_quote_uses_price_type(indicator_zscore_10: ZScore, stub_quote: QuoteTick) {
387        let mut z = indicator_zscore_10;
388        z.handle_quote(&stub_quote).unwrap();
389        assert_eq!(z.count, 1);
390        assert_eq!(z.mean, 1501.0);
391        assert_eq!(z.value, 0.0);
392    }
393
394    #[rstest]
395    fn zscore_handle_trade_uses_price(indicator_zscore_10: ZScore, stub_trade: TradeTick) {
396        let mut z = indicator_zscore_10;
397        z.handle_trade(&stub_trade);
398        assert_eq!(z.count, 1);
399        assert_eq!(z.mean, 1500.0);
400        assert_eq!(z.value, 0.0);
401    }
402
403    #[rstest]
404    fn zscore_reset_returns_to_fresh_state(indicator_zscore_10: ZScore) {
405        let mut z = indicator_zscore_10;
406        for i in 0..20 {
407            z.update_raw(f64::from(i));
408        }
409        z.reset();
410        assert!(!z.initialized());
411        assert!(!z.has_inputs());
412        assert_eq!(z.value, 0.0);
413        assert_eq!(z.mean, 0.0);
414        assert_eq!(z.std, 0.0);
415        assert_eq!(z.count, 0);
416    }
417
418    #[rstest]
419    #[should_panic(expected = "Condition failed")]
420    fn zscore_new_with_period_one_panics() {
421        let _ = ZScore::new(1, None);
422    }
423
424    #[rstest]
425    #[should_panic(expected = "Condition failed")]
426    fn zscore_new_with_zero_period_panics() {
427        let _ = ZScore::new(0, None);
428    }
429
430    #[rstest]
431    #[should_panic(expected = "Condition failed")]
432    fn zscore_new_with_period_above_max_panics() {
433        let _ = ZScore::new(MAX_PERIOD + 1, None);
434    }
435
436    #[rstest]
437    fn zscore_new_checked_rejects_invalid_period() {
438        assert!(ZScore::new_checked(0, None).is_err());
439        assert!(ZScore::new_checked(1, None).is_err());
440        assert!(ZScore::new_checked(MAX_PERIOD + 1, None).is_err());
441        assert!(ZScore::new_checked(2, None).is_ok());
442    }
443
444    #[rstest]
445    fn zscore_near_equal_large_magnitude_matches_batch() {
446        let inputs = [-814.051_168_710_620_9, -813.996_166_896_107_9];
447        let mut z = ZScore::new(2, None);
448        for &x in &inputs {
449            z.update_raw(x);
450        }
451        let (mean, std, batch_z) = batch_zscore(&inputs);
452        assert_approx_equal(z.mean, mean);
453        assert_approx_equal(z.std, std);
454        assert_approx_equal(z.value, batch_z);
455    }
456
457    #[rstest]
458    fn zscore_slide_from_large_values_to_zeros_matches_batch() {
459        let inputs = [
460            858.223_114_833_198,
461            -299.638_657_482_500_7,
462            -377.208_520_869_421_76,
463            -394.324_913_206_254_8,
464            406.662_086_491_207_45,
465            -912.384_594_640_612_4,
466            0.0,
467            0.0,
468            0.0,
469        ];
470        let mut z = ZScore::new(2, None);
471        let mut window: Vec<f64> = Vec::new();
472
473        for &x in &inputs {
474            window.push(x);
475
476            if window.len() > 2 {
477                window.remove(0);
478            }
479
480            z.update_raw(x);
481
482            if window.len() >= 2 {
483                let (mean, std, batch_z) = batch_zscore(&window);
484                assert_approx_equal(z.mean, mean);
485                assert_approx_equal(z.std, std);
486                assert_approx_equal(z.value, batch_z);
487            }
488        }
489    }
490
491    #[rstest]
492    fn zscore_slide_from_zeros_to_near_equal_large_matches_batch() {
493        let inputs = [
494            0.0,
495            0.0,
496            -665.301_640_322_359_3,
497            -786.149_294_354_941_7,
498            592.982_187_831_149,
499            592.422_790_241_439_3,
500        ];
501        let mut z = ZScore::new(2, None);
502        let mut window: Vec<f64> = Vec::new();
503
504        for &x in &inputs {
505            window.push(x);
506
507            if window.len() > 2 {
508                window.remove(0);
509            }
510
511            z.update_raw(x);
512
513            if window.len() >= 2 {
514                let (mean, std, batch_z) = batch_zscore(&window);
515                assert_approx_equal(z.mean, mean);
516                assert_approx_equal(z.std, std);
517                assert_approx_equal(z.value, batch_z);
518            }
519        }
520    }
521
522    proptest! {
523        #[rstest]
524        fn zscore_streaming_matches_batch_window(
525            values in prop::collection::vec(-1_000.0f64..1_000.0, 2..40),
526            period in 2usize..16,
527        ) {
528            let mut z = ZScore::new(period, None);
529            let mut window: Vec<f64> = Vec::new();
530
531            for &x in &values {
532                window.push(x);
533                if window.len() > period {
534                    window.remove(0);
535                }
536                z.update_raw(x);
537
538                if window.len() >= 2 {
539                    let (mean, std, batch_z) = batch_zscore(&window);
540                    prop_assert!(approx_equal_with(z.mean, mean, 1e-9, 1e-12));
541                    prop_assert!(approx_equal_with(z.std, std, 1e-9, 1e-12));
542                    prop_assert!(approx_equal_with(z.value, batch_z, 1e-9, 1e-12));
543                }
544            }
545        }
546    }
547}