1use 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 #[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 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 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 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}