Skip to main content

fin_stream/norm/
mod.rs

1//! Rolling-window coordinate normalization for financial time series.
2//!
3//! ## Purpose
4//!
5//! Machine-learning pipelines require features in a bounded numeric range.
6//! For streaming market data, a global min/max is unavailable; this module
7//! maintains a **rolling window** of the last `W` observations and maps each
8//! new sample into `[0.0, 1.0]` using the running min and max.
9//!
10//! ## Formula
11//!
12//! Given a window of observations `x_1 ... x_W` with minimum `m` and maximum
13//! `M`, the normalized value of a new sample `x` is:
14//!
15//! ```text
16//!     x_norm = (x - m) / (M - m)    if M != m
17//!     x_norm = 0.0                  if M == m  (degenerate; single-valued window)
18//! ```
19//!
20//! The result is clamped to `[0.0, 1.0]` to handle the case where `x` falls
21//! outside the current window range.
22//!
23//! ## Precision
24//!
25//! Inputs and window storage use [`rust_decimal::Decimal`] to preserve the
26//! same exact arithmetic guarantees as the rest of the pipeline ("never use
27//! f64 for prices"). The normalized output is `f64` because downstream ML
28//! models expect floating-point features and the [0, 1] range does not require
29//! financial precision.
30//!
31//! ## Guarantees
32//!
33//! - Non-panicking: construction returns `Result`; all operations return
34//!   `Result` or `Option`.
35//! - The window is a fixed-size ring buffer; once full, the oldest value is
36//!   evicted on each new observation.
37//! - `MinMaxNormalizer` is `Send` but not `Sync`; wrap in `Mutex` for shared
38//!   multi-thread access.
39
40use crate::error::StreamError;
41use rust_decimal::Decimal;
42use rust_decimal::prelude::ToPrimitive;
43use std::collections::VecDeque;
44
45/// Rolling min-max normalizer over a sliding window of [`Decimal`] observations.
46///
47/// # Example
48///
49/// ```rust
50/// use fin_stream::norm::MinMaxNormalizer;
51/// use rust_decimal_macros::dec;
52///
53/// let mut norm = MinMaxNormalizer::new(4).unwrap();
54/// norm.update(dec!(10));
55/// norm.update(dec!(20));
56/// norm.update(dec!(30));
57/// norm.update(dec!(40));
58///
59/// // 40 is the current max; 10 is the current min
60/// let v = norm.normalize(dec!(40)).unwrap();
61/// assert!((v - 1.0).abs() < 1e-10);
62/// ```
63pub struct MinMaxNormalizer {
64    window_size: usize,
65    window: VecDeque<Decimal>,
66    cached_min: Decimal,
67    cached_max: Decimal,
68    dirty: bool,
69}
70
71impl MinMaxNormalizer {
72    /// Create a new normalizer with the given rolling window size.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`StreamError::ConfigError`] if `window_size == 0`.
77    pub fn new(window_size: usize) -> Result<Self, StreamError> {
78        if window_size == 0 {
79            return Err(StreamError::ConfigError {
80                reason: "MinMaxNormalizer window_size must be > 0".into(),
81            });
82        }
83        Ok(Self {
84            window_size,
85            window: VecDeque::with_capacity(window_size),
86            cached_min: Decimal::MAX,
87            cached_max: Decimal::MIN,
88            dirty: false,
89        })
90    }
91
92    /// Add a new observation to the rolling window.
93    ///
94    /// If the window is full, the oldest value is evicted. After the call,
95    /// the internal min/max cache is marked dirty and will be recomputed lazily
96    /// on the next call to [`normalize`](Self::normalize) or
97    /// [`min_max`](Self::min_max).
98    ///
99    /// # Complexity: O(1) amortized
100    pub fn update(&mut self, value: Decimal) {
101        if self.window.len() == self.window_size {
102            self.window.pop_front();
103            self.dirty = true;
104        }
105        self.window.push_back(value);
106        // Eager update is cheaper than a full recompute when we don't evict.
107        if !self.dirty {
108            if value < self.cached_min {
109                self.cached_min = value;
110            }
111            if value > self.cached_max {
112                self.cached_max = value;
113            }
114        }
115    }
116
117    /// Recompute min and max from the full window.
118    ///
119    /// Called lazily when `dirty` is set (eviction occurred). O(W).
120    fn recompute(&mut self) {
121        self.cached_min = Decimal::MAX;
122        self.cached_max = Decimal::MIN;
123        for &v in &self.window {
124            if v < self.cached_min {
125                self.cached_min = v;
126            }
127            if v > self.cached_max {
128                self.cached_max = v;
129            }
130        }
131        self.dirty = false;
132    }
133
134    /// Return the current `(min, max)` of the window.
135    ///
136    /// Returns `None` if the window is empty.
137    ///
138    /// # Complexity: O(1) when the cache is clean; O(W) after an eviction.
139    pub fn min_max(&mut self) -> Option<(Decimal, Decimal)> {
140        if self.window.is_empty() {
141            return None;
142        }
143        if self.dirty {
144            self.recompute();
145        }
146        Some((self.cached_min, self.cached_max))
147    }
148
149    /// Normalize `value` into `[0.0, 1.0]` using the current window.
150    ///
151    /// The value is clamped so that even if `value` falls outside the window
152    /// range the result is always in `[0.0, 1.0]`.
153    ///
154    /// # Errors
155    ///
156    /// Returns [`StreamError::NormalizationError`] if the window is empty (no
157    /// observations have been fed yet), or if the normalized Decimal cannot be
158    /// converted to `f64`.
159    ///
160    /// # Complexity: O(1) when cache is clean; O(W) after an eviction.
161    #[must_use = "normalized value is returned; ignoring it loses the result"]
162    pub fn normalize(&mut self, value: Decimal) -> Result<f64, StreamError> {
163        let (min, max) = self
164            .min_max()
165            .ok_or_else(|| StreamError::NormalizationError {
166                reason: "window is empty; call update() before normalize()".into(),
167            })?;
168        if max == min {
169            // Degenerate: all values in the window are identical.
170            return Ok(0.0);
171        }
172        let normalized = (value - min) / (max - min);
173        // Clamp to [0, 1] in Decimal space before converting to f64.
174        let clamped = normalized.clamp(Decimal::ZERO, Decimal::ONE);
175        clamped.to_f64().ok_or_else(|| StreamError::NormalizationError {
176            reason: "Decimal-to-f64 conversion failed for normalized value".into(),
177        })
178    }
179
180    /// Inverse of [`normalize`](Self::normalize): map a `[0, 1]` value back to
181    /// the original scale.
182    ///
183    /// `denormalized = normalized * (max - min) + min`
184    ///
185    /// Works outside `[0, 1]` for extrapolation, but returns
186    /// [`StreamError::NormalizationError`] if the window is empty.
187    pub fn denormalize(&mut self, normalized: f64) -> Result<Decimal, StreamError> {
188        use rust_decimal::prelude::FromPrimitive;
189        let (min, max) = self
190            .min_max()
191            .ok_or_else(|| StreamError::NormalizationError {
192                reason: "window is empty; call update() before denormalize()".into(),
193            })?;
194        let scale = max - min;
195        let n_dec = Decimal::from_f64(normalized).ok_or_else(|| StreamError::NormalizationError {
196            reason: "normalized value is not a finite f64".into(),
197        })?;
198        Ok(n_dec * scale + min)
199    }
200
201    /// Scale of the current window: `max - min`.
202    ///
203    /// Returns `None` if the window is empty. Returns `Decimal::ZERO` when all
204    /// observations are identical (zero range → degenerate distribution).
205    pub fn range(&mut self) -> Option<Decimal> {
206        let (min, max) = self.min_max()?;
207        Some(max - min)
208    }
209
210    /// Clamps `value` to the `[min, max]` range of the current window.
211    ///
212    /// Returns `value` unchanged if the window is empty (no clamping possible).
213    pub fn clamp_to_window(&mut self, value: Decimal) -> Decimal {
214        match self.min_max() {
215            None => value,
216            Some((min, max)) => value.max(min).min(max),
217        }
218    }
219
220    /// Midpoint of the current window: `(min + max) / 2`.
221    ///
222    /// Returns `None` if the window is empty.
223    pub fn midpoint(&mut self) -> Option<Decimal> {
224        let (min, max) = self.min_max()?;
225        Some((min + max) / Decimal::TWO)
226    }
227
228    /// Reset the normalizer, clearing all observations and the cache.
229    pub fn reset(&mut self) {
230        self.window.clear();
231        self.cached_min = Decimal::MAX;
232        self.cached_max = Decimal::MIN;
233        self.dirty = false;
234    }
235
236    /// Number of observations currently in the window.
237    pub fn len(&self) -> usize {
238        self.window.len()
239    }
240
241    /// Returns `true` if no observations have been added since construction or
242    /// the last reset.
243    pub fn is_empty(&self) -> bool {
244        self.window.is_empty()
245    }
246
247    /// The configured window size.
248    pub fn window_size(&self) -> usize {
249        self.window_size
250    }
251
252    /// Returns `true` when the window holds exactly `window_size` observations.
253    ///
254    /// At full capacity the normalizer has seen enough data for stable min/max
255    /// estimates; before this point early observations dominate the range.
256    pub fn is_full(&self) -> bool {
257        self.window.len() == self.window_size
258    }
259
260    /// Current window minimum, or `None` if the window is empty.
261    ///
262    /// Equivalent to `self.min_max().map(|(min, _)| min)` but avoids also
263    /// computing the max when only the min is needed.
264    pub fn min(&mut self) -> Option<Decimal> {
265        self.min_max().map(|(min, _)| min)
266    }
267
268    /// Current window maximum, or `None` if the window is empty.
269    ///
270    /// Equivalent to `self.min_max().map(|(_, max)| max)` but avoids also
271    /// computing the min when only the max is needed.
272    pub fn max(&mut self) -> Option<Decimal> {
273        self.min_max().map(|(_, max)| max)
274    }
275
276    /// Returns the arithmetic mean of the current window observations.
277    ///
278    /// Returns `None` if the window is empty.
279    pub fn mean(&self) -> Option<Decimal> {
280        if self.window.is_empty() {
281            return None;
282        }
283        let sum: Decimal = self.window.iter().copied().sum();
284        Some(sum / Decimal::from(self.window.len() as u64))
285    }
286
287    /// Feed a slice of values into the window and return normalized forms of each.
288    ///
289    /// Each value in `values` is first passed through [`update`](Self::update) to
290    /// advance the rolling window, then normalized against the current window state.
291    /// The output has the same length as `values`.
292    ///
293    /// # Errors
294    /// Propagates the first [`StreamError`] returned by [`normalize`](Self::normalize).
295    pub fn normalize_batch(
296        &mut self,
297        values: &[rust_decimal::Decimal],
298    ) -> Result<Vec<f64>, crate::error::StreamError> {
299        values
300            .iter()
301            .map(|&v| {
302                self.update(v);
303                self.normalize(v)
304            })
305            .collect()
306    }
307
308    /// Normalize `value` and clamp the result to `[0.0, 1.0]`.
309    ///
310    /// Identical to [`normalize`](Self::normalize) but silently clamps values
311    /// that fall outside the window's observed range. Useful when applying a
312    /// learned normalizer to out-of-sample data without erroring on outliers.
313    ///
314    /// # Errors
315    ///
316    /// Returns [`StreamError::NormalizationError`] if the window is empty.
317    pub fn normalize_clamp(
318        &mut self,
319        value: rust_decimal::Decimal,
320    ) -> Result<f64, crate::error::StreamError> {
321        self.normalize(value).map(|v| v.clamp(0.0, 1.0))
322    }
323
324    /// Compute the z-score of `value` relative to the current window.
325    ///
326    /// `z = (value - mean) / stddev`
327    ///
328    /// Returns `None` if the window has fewer than 2 observations, or if the
329    /// standard deviation is zero (all values identical).
330    ///
331    /// Useful for detecting outliers and standardising features for ML models
332    /// when a bounded `[0, 1]` range is not required.
333    pub fn z_score(&self, value: Decimal) -> Option<f64> {
334        if self.window.len() < 2 {
335            return None;
336        }
337        let n = Decimal::from(self.window.len() as u64);
338        let mean: Decimal = self.window.iter().copied().sum::<Decimal>() / n;
339        let variance: Decimal = self
340            .window
341            .iter()
342            .map(|&v| { let d = v - mean; d * d })
343            .sum::<Decimal>()
344            / n;
345        if variance.is_zero() {
346            return None;
347        }
348        use rust_decimal::prelude::ToPrimitive;
349        let std_dev_f64 = variance.to_f64()?.sqrt();
350        let value_f64 = value.to_f64()?;
351        let mean_f64 = mean.to_f64()?;
352        Some((value_f64 - mean_f64) / std_dev_f64)
353    }
354
355    /// Returns the percentile rank of `value` within the current window.
356    ///
357    /// The percentile rank is the fraction of window values that are `<= value`,
358    /// expressed in `[0.0, 1.0]`. Returns `None` if the window is empty.
359    ///
360    /// Useful for identifying whether the current value is historically high or low
361    /// relative to its recent context without requiring a min/max range.
362    pub fn percentile_rank(&self, value: rust_decimal::Decimal) -> Option<f64> {
363        if self.window.is_empty() {
364            return None;
365        }
366        let count_le = self
367            .window
368            .iter()
369            .filter(|&&v| v <= value)
370            .count();
371        Some(count_le as f64 / self.window.len() as f64)
372    }
373
374    /// Count of observations in the current window that are strictly above `threshold`.
375    pub fn count_above(&self, threshold: rust_decimal::Decimal) -> usize {
376        self.window.iter().filter(|&&v| v > threshold).count()
377    }
378
379    /// Fraction of window values strictly above the midpoint `(min + max) / 2`.
380    ///
381    /// Returns `None` if the window is empty. Returns `0.0` if all values are equal.
382    pub fn fraction_above_mid(&mut self) -> Option<f64> {
383        let (min, max) = self.min_max()?;
384        let mid = (min + max) / rust_decimal::Decimal::TWO;
385        let above = self.window.iter().filter(|&&v| v > mid).count();
386        Some(above as f64 / self.window.len() as f64)
387    }
388
389    /// `(max - min) / max` as `f64` — the range as a fraction of the maximum.
390    ///
391    /// Measures how wide the window's spread is relative to its peak. Returns
392    /// `None` if the window is empty or the maximum is zero.
393    pub fn normalized_range(&mut self) -> Option<f64> {
394        use rust_decimal::prelude::ToPrimitive;
395        let (min, max) = self.min_max()?;
396        if max.is_zero() {
397            return None;
398        }
399        ((max - min) / max).to_f64()
400    }
401
402    /// Exponential weighted moving average of the current window values.
403    ///
404    /// Applies `alpha` as the smoothing factor (most-recent weight), scanning oldest→newest.
405    /// `alpha` is clamped to `(0, 1]`. Returns `None` if the window is empty.
406    pub fn ewma(&self, alpha: f64) -> Option<f64> {
407        if self.window.is_empty() {
408            return None;
409        }
410        let alpha = alpha.clamp(f64::MIN_POSITIVE, 1.0);
411        let one_minus = 1.0 - alpha;
412        let mut ewma = self.window[0].to_f64().unwrap_or(0.0);
413        for &v in self.window.iter().skip(1) {
414            ewma = alpha * v.to_f64().unwrap_or(ewma) + one_minus * ewma;
415        }
416        Some(ewma)
417    }
418
419    /// Interquartile range: Q3 (75th percentile) − Q1 (25th percentile) of the window.
420    ///
421    /// Returns `None` if the window has fewer than 4 observations.
422    /// The IQR is a robust spread measure less sensitive to outliers than range or std dev.
423    pub fn interquartile_range(&self) -> Option<Decimal> {
424        let n = self.window.len();
425        if n < 4 {
426            return None;
427        }
428        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
429        sorted.sort();
430        let q1_idx = n / 4;
431        let q3_idx = 3 * n / 4;
432        Some(sorted[q3_idx] - sorted[q1_idx])
433    }
434
435    /// Skewness of the window values: `Σ((x - mean)³/n) / std_dev³`.
436    ///
437    /// Positive skew means the tail is longer on the right; negative on the left.
438    /// Returns `None` if the window has fewer than 3 observations or std dev is zero.
439    pub fn skewness(&self) -> Option<f64> {
440        use rust_decimal::prelude::ToPrimitive;
441        let n = self.window.len();
442        if n < 3 {
443            return None;
444        }
445        let n_f = n as f64;
446        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
447        if vals.len() < n {
448            return None;
449        }
450        let mean = vals.iter().sum::<f64>() / n_f;
451        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n_f;
452        let std_dev = variance.sqrt();
453        if std_dev == 0.0 {
454            return None;
455        }
456        let skew = vals.iter().map(|v| ((v - mean) / std_dev).powi(3)).sum::<f64>() / n_f;
457        Some(skew)
458    }
459
460    /// The most recently added value, or `None` if the window is empty.
461    pub fn latest(&self) -> Option<Decimal> {
462        self.window.back().copied()
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use rust_decimal_macros::dec;
470
471    fn norm(w: usize) -> MinMaxNormalizer {
472        MinMaxNormalizer::new(w).unwrap()
473    }
474
475    // ── Construction ─────────────────────────────────────────────────────────
476
477    #[test]
478    fn test_new_normalizer_is_empty() {
479        let n = norm(4);
480        assert!(n.is_empty());
481        assert_eq!(n.len(), 0);
482    }
483
484    #[test]
485    fn test_minmax_is_full_false_before_capacity() {
486        let mut n = norm(3);
487        assert!(!n.is_full());
488        n.update(dec!(1));
489        n.update(dec!(2));
490        assert!(!n.is_full());
491        n.update(dec!(3));
492        assert!(n.is_full());
493    }
494
495    #[test]
496    fn test_minmax_is_full_stays_true_after_eviction() {
497        let mut n = norm(3);
498        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
499            n.update(v);
500        }
501        assert!(n.is_full()); // window stays at capacity after eviction
502    }
503
504    #[test]
505    fn test_new_zero_window_returns_error() {
506        let result = MinMaxNormalizer::new(0);
507        assert!(matches!(result, Err(StreamError::ConfigError { .. })));
508    }
509
510    // ── Normalization range [0, 1] ────────────────────────────────────────────
511
512    #[test]
513    fn test_normalize_min_is_zero() {
514        let mut n = norm(4);
515        n.update(dec!(10));
516        n.update(dec!(20));
517        n.update(dec!(30));
518        n.update(dec!(40));
519        let v = n.normalize(dec!(10)).unwrap();
520        assert!(
521            (v - 0.0).abs() < 1e-10,
522            "min should normalize to 0.0, got {v}"
523        );
524    }
525
526    #[test]
527    fn test_normalize_max_is_one() {
528        let mut n = norm(4);
529        n.update(dec!(10));
530        n.update(dec!(20));
531        n.update(dec!(30));
532        n.update(dec!(40));
533        let v = n.normalize(dec!(40)).unwrap();
534        assert!(
535            (v - 1.0).abs() < 1e-10,
536            "max should normalize to 1.0, got {v}"
537        );
538    }
539
540    #[test]
541    fn test_normalize_midpoint_is_half() {
542        let mut n = norm(4);
543        n.update(dec!(0));
544        n.update(dec!(100));
545        let v = n.normalize(dec!(50)).unwrap();
546        assert!((v - 0.5).abs() < 1e-10);
547    }
548
549    #[test]
550    fn test_normalize_result_clamped_below_zero() {
551        let mut n = norm(4);
552        n.update(dec!(50));
553        n.update(dec!(100));
554        // 10 is below the window min of 50
555        let v = n.normalize(dec!(10)).unwrap();
556        assert!(v >= 0.0);
557        assert_eq!(v, 0.0);
558    }
559
560    #[test]
561    fn test_normalize_result_clamped_above_one() {
562        let mut n = norm(4);
563        n.update(dec!(50));
564        n.update(dec!(100));
565        // 200 is above the window max of 100
566        let v = n.normalize(dec!(200)).unwrap();
567        assert!(v <= 1.0);
568        assert_eq!(v, 1.0);
569    }
570
571    #[test]
572    fn test_normalize_all_same_values_returns_zero() {
573        let mut n = norm(4);
574        n.update(dec!(5));
575        n.update(dec!(5));
576        n.update(dec!(5));
577        let v = n.normalize(dec!(5)).unwrap();
578        assert_eq!(v, 0.0);
579    }
580
581    // ── Empty window error ────────────────────────────────────────────────────
582
583    #[test]
584    fn test_normalize_empty_window_returns_error() {
585        let mut n = norm(4);
586        let err = n.normalize(dec!(1)).unwrap_err();
587        assert!(matches!(err, StreamError::NormalizationError { .. }));
588    }
589
590    #[test]
591    fn test_min_max_empty_returns_none() {
592        let mut n = norm(4);
593        assert!(n.min_max().is_none());
594    }
595
596    // ── Rolling window eviction ───────────────────────────────────────────────
597
598    /// After the window fills and the minimum is evicted, the new min must
599    /// reflect the remaining values.
600    #[test]
601    fn test_rolling_window_evicts_oldest() {
602        let mut n = norm(3);
603        n.update(dec!(1)); // will be evicted
604        n.update(dec!(5));
605        n.update(dec!(10));
606        n.update(dec!(20)); // evicts 1
607        let (min, max) = n.min_max().unwrap();
608        assert_eq!(min, dec!(5));
609        assert_eq!(max, dec!(20));
610    }
611
612    #[test]
613    fn test_rolling_window_len_does_not_exceed_capacity() {
614        let mut n = norm(3);
615        for i in 0..10 {
616            n.update(Decimal::from(i));
617        }
618        assert_eq!(n.len(), 3);
619    }
620
621    // ── Reset behavior ────────────────────────────────────────────────────────
622
623    #[test]
624    fn test_reset_clears_window() {
625        let mut n = norm(4);
626        n.update(dec!(10));
627        n.update(dec!(20));
628        n.reset();
629        assert!(n.is_empty());
630        assert!(n.min_max().is_none());
631    }
632
633    #[test]
634    fn test_normalize_works_after_reset() {
635        let mut n = norm(4);
636        n.update(dec!(10));
637        n.reset();
638        n.update(dec!(0));
639        n.update(dec!(100));
640        let v = n.normalize(dec!(100)).unwrap();
641        assert!((v - 1.0).abs() < 1e-10);
642    }
643
644    // ── Streaming update ──────────────────────────────────────────────────────
645
646    #[test]
647    fn test_streaming_updates_monotone_sequence() {
648        let mut n = norm(5);
649        let prices = [dec!(100), dec!(101), dec!(102), dec!(103), dec!(104), dec!(105)];
650        for &p in &prices {
651            n.update(p);
652        }
653        // Window now holds [101, 102, 103, 104, 105]; min=101, max=105
654        let v_min = n.normalize(dec!(101)).unwrap();
655        let v_max = n.normalize(dec!(105)).unwrap();
656        assert!((v_min - 0.0).abs() < 1e-10);
657        assert!((v_max - 1.0).abs() < 1e-10);
658    }
659
660    #[test]
661    fn test_normalization_monotonicity_in_window() {
662        let mut n = norm(10);
663        for i in 0..10 {
664            n.update(Decimal::from(i * 10));
665        }
666        // Values 0, 10, 20, ..., 90 in window; min=0, max=90
667        let v0 = n.normalize(dec!(0)).unwrap();
668        let v50 = n.normalize(dec!(50)).unwrap();
669        let v90 = n.normalize(dec!(90)).unwrap();
670        assert!(v0 < v50, "normalized values should be monotone");
671        assert!(v50 < v90, "normalized values should be monotone");
672    }
673
674    #[test]
675    fn test_high_precision_input_preserved() {
676        // Verify that a value like 50000.12345678 is handled without f64 loss.
677        let mut n = norm(2);
678        n.update(dec!(50000.00000000));
679        n.update(dec!(50000.12345678));
680        let (min, max) = n.min_max().unwrap();
681        assert_eq!(min, dec!(50000.00000000));
682        assert_eq!(max, dec!(50000.12345678));
683    }
684
685    // ── denormalize ───────────────────────────────────────────────────────────
686
687    #[test]
688    fn test_denormalize_empty_window_returns_error() {
689        let mut n = norm(4);
690        assert!(matches!(n.denormalize(0.5), Err(StreamError::NormalizationError { .. })));
691    }
692
693    #[test]
694    fn test_denormalize_roundtrip_min() {
695        let mut n = norm(4);
696        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
697            n.update(v);
698        }
699        let normalized = n.normalize(dec!(10)).unwrap(); // should be ~0.0
700        let back = n.denormalize(normalized).unwrap();
701        assert!((back - dec!(10)).abs() < dec!(0.0001));
702    }
703
704    #[test]
705    fn test_denormalize_roundtrip_max() {
706        let mut n = norm(4);
707        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
708            n.update(v);
709        }
710        let normalized = n.normalize(dec!(40)).unwrap(); // should be ~1.0
711        let back = n.denormalize(normalized).unwrap();
712        assert!((back - dec!(40)).abs() < dec!(0.0001));
713    }
714
715    // ── range ─────────────────────────────────────────────────────────────────
716
717    #[test]
718    fn test_range_none_when_empty() {
719        let mut n = norm(4);
720        assert!(n.range().is_none());
721    }
722
723    #[test]
724    fn test_range_zero_when_all_same() {
725        let mut n = norm(3);
726        n.update(dec!(5));
727        n.update(dec!(5));
728        n.update(dec!(5));
729        assert_eq!(n.range(), Some(dec!(0)));
730    }
731
732    #[test]
733    fn test_range_correct() {
734        let mut n = norm(4);
735        for v in [dec!(10), dec!(40), dec!(20), dec!(30)] {
736            n.update(v);
737        }
738        assert_eq!(n.range(), Some(dec!(30))); // 40 - 10
739    }
740
741    // ── MinMaxNormalizer::midpoint ────────────────────────────────────────────
742
743    #[test]
744    fn test_midpoint_none_when_empty() {
745        let mut n = norm(4);
746        assert!(n.midpoint().is_none());
747    }
748
749    #[test]
750    fn test_midpoint_correct() {
751        let mut n = norm(4);
752        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
753            n.update(v);
754        }
755        // min=10, max=40 → midpoint = 25
756        assert_eq!(n.midpoint(), Some(dec!(25)));
757    }
758
759    #[test]
760    fn test_midpoint_single_value() {
761        let mut n = norm(4);
762        n.update(dec!(42));
763        assert_eq!(n.midpoint(), Some(dec!(42)));
764    }
765
766    // ── MinMaxNormalizer::clamp_to_window ─────────────────────────────────────
767
768    #[test]
769    fn test_clamp_to_window_returns_value_unchanged_when_empty() {
770        let mut n = norm(4);
771        assert_eq!(n.clamp_to_window(dec!(50)), dec!(50));
772    }
773
774    #[test]
775    fn test_clamp_to_window_clamps_above_max() {
776        let mut n = norm(4);
777        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
778        assert_eq!(n.clamp_to_window(dec!(100)), dec!(30));
779    }
780
781    #[test]
782    fn test_clamp_to_window_clamps_below_min() {
783        let mut n = norm(4);
784        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
785        assert_eq!(n.clamp_to_window(dec!(5)), dec!(10));
786    }
787
788    #[test]
789    fn test_clamp_to_window_passthrough_when_in_range() {
790        let mut n = norm(4);
791        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
792        assert_eq!(n.clamp_to_window(dec!(15)), dec!(15));
793    }
794
795    // ── MinMaxNormalizer::count_above ─────────────────────────────────────────
796
797    #[test]
798    fn test_count_above_zero_when_empty() {
799        let n = norm(4);
800        assert_eq!(n.count_above(dec!(5)), 0);
801    }
802
803    #[test]
804    fn test_count_above_counts_strictly_above() {
805        let mut n = norm(8);
806        for v in [dec!(1), dec!(5), dec!(10), dec!(15)] { n.update(v); }
807        assert_eq!(n.count_above(dec!(5)), 2); // 10 and 15
808    }
809
810    #[test]
811    fn test_count_above_all_when_threshold_below_all() {
812        let mut n = norm(4);
813        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
814        assert_eq!(n.count_above(dec!(5)), 3);
815    }
816
817    #[test]
818    fn test_count_above_zero_when_threshold_above_all() {
819        let mut n = norm(4);
820        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
821        assert_eq!(n.count_above(dec!(100)), 0);
822    }
823
824    // ── MinMaxNormalizer::normalized_range ────────────────────────────────────
825
826    #[test]
827    fn test_normalized_range_none_when_empty() {
828        let mut n = norm(4);
829        assert!(n.normalized_range().is_none());
830    }
831
832    #[test]
833    fn test_normalized_range_zero_when_all_same() {
834        let mut n = norm(4);
835        for _ in 0..4 { n.update(dec!(5)); }
836        assert_eq!(n.normalized_range(), Some(0.0));
837    }
838
839    #[test]
840    fn test_normalized_range_correct_value() {
841        let mut n = norm(4);
842        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
843        // (40-10)/40 = 0.75
844        let nr = n.normalized_range().unwrap();
845        assert!((nr - 0.75).abs() < 1e-10);
846    }
847
848    // ── MinMaxNormalizer::normalize_clamp ─────────────────────────────────────
849
850    #[test]
851    fn test_normalize_clamp_in_range_equals_normalize() {
852        let mut n = norm(4);
853        for v in [dec!(0), dec!(25), dec!(75), dec!(100)] {
854            n.update(v);
855        }
856        let clamped = n.normalize_clamp(dec!(50)).unwrap();
857        let normal = n.normalize(dec!(50)).unwrap();
858        assert!((clamped - normal).abs() < 1e-9);
859    }
860
861    #[test]
862    fn test_normalize_clamp_above_max_clamped_to_one() {
863        let mut n = norm(3);
864        for v in [dec!(0), dec!(50), dec!(100)] {
865            n.update(v);
866        }
867        // 200 is above the window max of 100; normalize would return > 1.0
868        let clamped = n.normalize_clamp(dec!(200)).unwrap();
869        assert!((clamped - 1.0).abs() < 1e-9, "expected 1.0 got {clamped}");
870    }
871
872    #[test]
873    fn test_normalize_clamp_below_min_clamped_to_zero() {
874        let mut n = norm(3);
875        for v in [dec!(10), dec!(50), dec!(100)] {
876            n.update(v);
877        }
878        // -50 is below the window min of 10; normalize would return < 0.0
879        let clamped = n.normalize_clamp(dec!(-50)).unwrap();
880        assert!((clamped - 0.0).abs() < 1e-9, "expected 0.0 got {clamped}");
881    }
882
883    #[test]
884    fn test_normalize_clamp_empty_window_returns_error() {
885        let mut n = norm(4);
886        assert!(n.normalize_clamp(dec!(5)).is_err());
887    }
888
889    // ── MinMaxNormalizer::latest ──────────────────────────────────────────────
890
891    #[test]
892    fn test_latest_none_when_empty() {
893        let n = norm(5);
894        assert_eq!(n.latest(), None);
895    }
896
897    #[test]
898    fn test_latest_returns_most_recent_value() {
899        let mut n = norm(5);
900        n.update(dec!(10));
901        n.update(dec!(20));
902        n.update(dec!(30));
903        assert_eq!(n.latest(), Some(dec!(30)));
904    }
905
906    #[test]
907    fn test_latest_updates_on_each_push() {
908        let mut n = norm(3);
909        n.update(dec!(1));
910        assert_eq!(n.latest(), Some(dec!(1)));
911        n.update(dec!(5));
912        assert_eq!(n.latest(), Some(dec!(5)));
913    }
914
915    #[test]
916    fn test_latest_returns_last_after_window_overflow() {
917        let mut n = norm(2); // window_size = 2
918        n.update(dec!(100));
919        n.update(dec!(200));
920        n.update(dec!(300)); // oldest (100) evicted
921        assert_eq!(n.latest(), Some(dec!(300)));
922    }
923}
924
925/// Rolling z-score normalizer over a sliding window of [`Decimal`] observations.
926///
927/// Maps each new sample to its z-score: `(x - mean) / std_dev`. The rolling
928/// window maintains an O(1) mean and variance via incremental sum/sum-of-squares
929/// tracking, with O(W) recompute only when a value is evicted.
930///
931/// Returns 0.0 when the window has fewer than 2 observations (variance is 0).
932///
933/// # Example
934///
935/// ```rust
936/// use fin_stream::norm::ZScoreNormalizer;
937/// use rust_decimal_macros::dec;
938///
939/// let mut norm = ZScoreNormalizer::new(5).unwrap();
940/// for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] {
941///     norm.update(v);
942/// }
943/// // 30 is the mean; normalize returns 0.0
944/// let z = norm.normalize(dec!(30)).unwrap();
945/// assert!((z - 0.0).abs() < 1e-9);
946/// ```
947pub struct ZScoreNormalizer {
948    window_size: usize,
949    window: VecDeque<Decimal>,
950    sum: Decimal,
951    sum_sq: Decimal,
952}
953
954impl ZScoreNormalizer {
955    /// Create a new z-score normalizer with the given rolling window size.
956    ///
957    /// # Errors
958    ///
959    /// Returns [`StreamError::ConfigError`] if `window_size == 0`.
960    pub fn new(window_size: usize) -> Result<Self, StreamError> {
961        if window_size == 0 {
962            return Err(StreamError::ConfigError {
963                reason: "ZScoreNormalizer window_size must be > 0".into(),
964            });
965        }
966        Ok(Self {
967            window_size,
968            window: VecDeque::with_capacity(window_size),
969            sum: Decimal::ZERO,
970            sum_sq: Decimal::ZERO,
971        })
972    }
973
974    /// Add a new observation to the rolling window.
975    ///
976    /// Evicts the oldest value when the window is full, adjusting running sums
977    /// in O(1). No full recompute is needed unless eviction causes sum drift;
978    /// the implementation recomputes exactly when necessary via `recompute`.
979    pub fn update(&mut self, value: Decimal) {
980        if self.window.len() == self.window_size {
981            let evicted = self.window.pop_front().unwrap_or(Decimal::ZERO);
982            self.sum -= evicted;
983            self.sum_sq -= evicted * evicted;
984        }
985        self.window.push_back(value);
986        self.sum += value;
987        self.sum_sq += value * value;
988    }
989
990    /// Normalize `value` to a z-score using the current window's mean and std dev.
991    ///
992    /// Returns 0.0 if:
993    /// - The window has fewer than 2 observations (std dev undefined).
994    /// - The standard deviation is effectively zero (all window values identical).
995    ///
996    /// # Errors
997    ///
998    /// Returns [`StreamError::NormalizationError`] if the window is empty.
999    ///
1000    /// # Complexity: O(1)
1001    #[must_use = "z-score is returned; ignoring it loses the normalized value"]
1002    pub fn normalize(&self, value: Decimal) -> Result<f64, StreamError> {
1003        let n = self.window.len();
1004        if n == 0 {
1005            return Err(StreamError::NormalizationError {
1006                reason: "window is empty; call update() before normalize()".into(),
1007            });
1008        }
1009        if n < 2 {
1010            return Ok(0.0);
1011        }
1012        let n_dec = Decimal::from(n as u64);
1013        let mean = self.sum / n_dec;
1014        // Population variance = E[X²] - (E[X])²
1015        let variance = (self.sum_sq / n_dec) - mean * mean;
1016        // Clamp to zero to guard against floating-point subtraction underflow.
1017        let variance = if variance < Decimal::ZERO {
1018            Decimal::ZERO
1019        } else {
1020            variance
1021        };
1022        let var_f64 = variance.to_f64().ok_or_else(|| StreamError::NormalizationError {
1023            reason: "Decimal-to-f64 conversion failed for variance".into(),
1024        })?;
1025        let std_dev = var_f64.sqrt();
1026        if std_dev < f64::EPSILON {
1027            return Ok(0.0);
1028        }
1029        let diff = value - mean;
1030        let diff_f64 = diff.to_f64().ok_or_else(|| StreamError::NormalizationError {
1031            reason: "Decimal-to-f64 conversion failed for diff".into(),
1032        })?;
1033        Ok(diff_f64 / std_dev)
1034    }
1035
1036    /// Current rolling mean of the window, or `None` if the window is empty.
1037    pub fn mean(&self) -> Option<Decimal> {
1038        if self.window.is_empty() {
1039            return None;
1040        }
1041        let n = Decimal::from(self.window.len() as u64);
1042        Some(self.sum / n)
1043    }
1044
1045    /// Current population standard deviation of the window.
1046    ///
1047    /// Returns `None` if the window is empty. Returns `Some(0.0)` if fewer
1048    /// than 2 observations are present (undefined variance, treated as zero)
1049    /// or if all values are identical.
1050    pub fn std_dev(&self) -> Option<f64> {
1051        let n = self.window.len();
1052        if n == 0 {
1053            return None;
1054        }
1055        if n < 2 {
1056            return Some(0.0);
1057        }
1058        let n_dec = Decimal::from(n as u64);
1059        let mean = self.sum / n_dec;
1060        let variance = (self.sum_sq / n_dec) - mean * mean;
1061        let variance = if variance < Decimal::ZERO { Decimal::ZERO } else { variance };
1062        let var_f64 = variance.to_f64().unwrap_or(0.0);
1063        Some(var_f64.sqrt())
1064    }
1065
1066    /// Reset the normalizer, clearing all observations and sums.
1067    pub fn reset(&mut self) {
1068        self.window.clear();
1069        self.sum = Decimal::ZERO;
1070        self.sum_sq = Decimal::ZERO;
1071    }
1072
1073    /// Number of observations currently in the window.
1074    pub fn len(&self) -> usize {
1075        self.window.len()
1076    }
1077
1078    /// Returns `true` if no observations have been added since construction or reset.
1079    pub fn is_empty(&self) -> bool {
1080        self.window.is_empty()
1081    }
1082
1083    /// The configured window size.
1084    pub fn window_size(&self) -> usize {
1085        self.window_size
1086    }
1087
1088    /// Returns `true` when the window holds exactly `window_size` observations.
1089    ///
1090    /// At full capacity the z-score calculation is stable; before this point
1091    /// the window may not be representative of the underlying distribution.
1092    pub fn is_full(&self) -> bool {
1093        self.window.len() == self.window_size
1094    }
1095
1096    /// Running sum of all values currently in the window.
1097    ///
1098    /// Returns `None` if the window is empty. Useful for deriving a rolling
1099    /// mean without calling [`normalize`](Self::normalize).
1100    pub fn sum(&self) -> Option<Decimal> {
1101        if self.window.is_empty() {
1102            return None;
1103        }
1104        Some(self.sum)
1105    }
1106
1107    /// Current population variance of the window.
1108    ///
1109    /// Computed as `E[X²] − (E[X])²` from running sums in O(1). Returns
1110    /// `None` if fewer than 2 observations are present (variance undefined).
1111    pub fn variance(&self) -> Option<Decimal> {
1112        let n = self.window.len();
1113        if n < 2 {
1114            return None;
1115        }
1116        let n_dec = Decimal::from(n as u64);
1117        let mean = self.sum / n_dec;
1118        let v = (self.sum_sq / n_dec) - mean * mean;
1119        Some(if v < Decimal::ZERO { Decimal::ZERO } else { v })
1120    }
1121
1122    /// Standard deviation of the current window as `f64`.
1123    ///
1124    /// Returns `None` if the window has fewer than 2 observations.
1125    pub fn std_dev_f64(&self) -> Option<f64> {
1126        self.variance_f64().map(|v| v.sqrt())
1127    }
1128
1129    /// Current window variance as `f64` (convenience wrapper around [`variance`](Self::variance)).
1130    ///
1131    /// Returns `None` if the window has fewer than 2 observations.
1132    pub fn variance_f64(&self) -> Option<f64> {
1133        use rust_decimal::prelude::ToPrimitive;
1134        self.variance()?.to_f64()
1135    }
1136
1137    /// Feed a slice of values into the window and return z-scores for each.
1138    ///
1139    /// Each value is first passed through [`update`](Self::update) to advance
1140    /// the rolling window, then normalized. The output has the same length as
1141    /// `values`.
1142    ///
1143    /// # Errors
1144    ///
1145    /// Propagates the first [`StreamError`] returned by [`normalize`](Self::normalize).
1146    pub fn normalize_batch(
1147        &mut self,
1148        values: &[Decimal],
1149    ) -> Result<Vec<f64>, StreamError> {
1150        values
1151            .iter()
1152            .map(|&v| {
1153                self.update(v);
1154                self.normalize(v)
1155            })
1156            .collect()
1157    }
1158
1159    /// Returns `true` if `value` is an outlier: its z-score exceeds `z_threshold` in magnitude.
1160    ///
1161    /// Returns `false` when the window has fewer than 2 observations (z-score undefined).
1162    /// A typical threshold is `2.0` (95th percentile) or `3.0` (99.7th percentile).
1163    pub fn is_outlier(&self, value: Decimal, z_threshold: f64) -> bool {
1164        let n = self.window.len();
1165        if n < 2 {
1166            return false;
1167        }
1168        let n_dec = Decimal::from(n as u64);
1169        let mean = self.sum / n_dec;
1170        let variance = (self.sum_sq / n_dec) - mean * mean;
1171        let variance = if variance < Decimal::ZERO { Decimal::ZERO } else { variance };
1172        let sd = variance.to_f64().unwrap_or(0.0).sqrt();
1173        if sd == 0.0 {
1174            return false;
1175        }
1176        let mean_f64 = mean.to_f64().unwrap_or(0.0);
1177        let val_f64 = value.to_f64().unwrap_or(mean_f64);
1178        ((val_f64 - mean_f64) / sd).abs() > z_threshold
1179    }
1180
1181    /// Percentile rank: fraction of window observations that are ≤ `value`.
1182    ///
1183    /// Returns `None` if the window is empty. Range: `[0.0, 1.0]`.
1184    pub fn percentile_rank(&self, value: Decimal) -> Option<f64> {
1185        if self.window.is_empty() {
1186            return None;
1187        }
1188        let count = self.window.iter().filter(|&&v| v <= value).count();
1189        Some(count as f64 / self.window.len() as f64)
1190    }
1191
1192    /// Minimum value seen in the current window.
1193    ///
1194    /// Returns `None` when the window is empty.
1195    pub fn running_min(&self) -> Option<Decimal> {
1196        self.window.iter().copied().reduce(Decimal::min)
1197    }
1198
1199    /// Maximum value seen in the current window.
1200    ///
1201    /// Returns `None` when the window is empty.
1202    pub fn running_max(&self) -> Option<Decimal> {
1203        self.window.iter().copied().reduce(Decimal::max)
1204    }
1205
1206    /// Range of values in the current window: `running_max − running_min`.
1207    ///
1208    /// Returns `None` when the window is empty.
1209    pub fn window_range(&self) -> Option<Decimal> {
1210        let min = self.running_min()?;
1211        let max = self.running_max()?;
1212        Some(max - min)
1213    }
1214
1215    /// Coefficient of variation: `std_dev / |mean|`.
1216    ///
1217    /// A dimensionless measure of relative dispersion. Returns `None` when the
1218    /// window has fewer than 2 observations or when the mean is zero.
1219    pub fn coefficient_of_variation(&self) -> Option<f64> {
1220        let mean = self.mean()?;
1221        if mean.is_zero() {
1222            return None;
1223        }
1224        let std_dev = self.std_dev()?;
1225        let mean_f = mean.abs().to_f64()?;
1226        Some(std_dev / mean_f)
1227    }
1228
1229    /// Population variance of the current window: `std_dev²`.
1230    ///
1231    /// Returns `None` when the window is empty (same conditions as
1232    /// [`std_dev`](Self::std_dev)).
1233    pub fn sample_variance(&self) -> Option<f64> {
1234        let sd = self.std_dev()?;
1235        Some(sd * sd)
1236    }
1237
1238    /// Current window mean as `f64`.
1239    ///
1240    /// A convenience over calling `mean()` and then converting to `f64`.
1241    /// Returns `None` when the window is empty.
1242    pub fn window_mean_f64(&self) -> Option<f64> {
1243        use rust_decimal::prelude::ToPrimitive;
1244        self.mean()?.to_f64()
1245    }
1246
1247    /// Returns `true` if `value` is within `sigma_tolerance` standard
1248    /// deviations of the window mean (inclusive).
1249    ///
1250    /// Equivalent to `|z_score(value)| <= sigma_tolerance`.  Returns `false`
1251    /// when the window has fewer than 2 observations (z-score undefined).
1252    pub fn is_near_mean(&self, value: Decimal, sigma_tolerance: f64) -> bool {
1253        let n = self.window.len();
1254        if n < 2 {
1255            return false;
1256        }
1257        let n_dec = Decimal::from(n as u64);
1258        use rust_decimal::prelude::ToPrimitive;
1259        let mean = self.sum / n_dec;
1260        let variance: Decimal = self.window.iter()
1261            .map(|&x| {
1262                let diff = x - mean;
1263                diff * diff
1264            })
1265            .sum::<Decimal>() / n_dec;
1266        let std_dev = variance.to_f64().unwrap_or(0.0).sqrt();
1267        if std_dev == 0.0 {
1268            return true;
1269        }
1270        let diff = (value - mean).abs().to_f64().unwrap_or(f64::MAX);
1271        diff / std_dev <= sigma_tolerance
1272    }
1273
1274    /// Sum of all values currently in the window as `Decimal`.
1275    ///
1276    /// Returns `Decimal::ZERO` on an empty window.
1277    pub fn window_sum(&self) -> Decimal {
1278        self.sum
1279    }
1280
1281    /// Sum of all values currently in the window as `f64`.
1282    ///
1283    /// Returns `0.0` on an empty window.
1284    pub fn window_sum_f64(&self) -> f64 {
1285        use rust_decimal::prelude::ToPrimitive;
1286        self.sum.to_f64().unwrap_or(0.0)
1287    }
1288
1289    /// Maximum value currently in the window as `f64`.
1290    ///
1291    /// Returns `None` when the window is empty.
1292    pub fn window_max_f64(&self) -> Option<f64> {
1293        use rust_decimal::prelude::ToPrimitive;
1294        self.window.iter().max().and_then(|v| v.to_f64())
1295    }
1296
1297    /// Minimum value currently in the window as `f64`.
1298    ///
1299    /// Returns `None` when the window is empty.
1300    pub fn window_min_f64(&self) -> Option<f64> {
1301        use rust_decimal::prelude::ToPrimitive;
1302        self.window.iter().min().and_then(|v| v.to_f64())
1303    }
1304
1305    /// Difference between the window maximum and minimum, as `f64`.
1306    ///
1307    /// Returns `None` if the window is empty.
1308    pub fn window_span_f64(&self) -> Option<f64> {
1309        let max = self.window_max_f64()?;
1310        let min = self.window_min_f64()?;
1311        Some(max - min)
1312    }
1313
1314    /// Excess kurtosis of the window: `(Σ((x-mean)⁴/n) / std_dev⁴) - 3`.
1315    ///
1316    /// Returns `None` if the window has fewer than 4 observations or std dev is zero.
1317    /// A normal distribution has excess kurtosis of 0; positive values indicate
1318    /// heavier tails (leptokurtic); negative values indicate lighter tails (platykurtic).
1319    pub fn kurtosis(&self) -> Option<f64> {
1320        use rust_decimal::prelude::ToPrimitive;
1321        let n = self.window.len();
1322        if n < 4 {
1323            return None;
1324        }
1325        let n_f = n as f64;
1326        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
1327        if vals.len() < n {
1328            return None;
1329        }
1330        let mean = vals.iter().sum::<f64>() / n_f;
1331        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n_f;
1332        let std_dev = variance.sqrt();
1333        if std_dev == 0.0 {
1334            return None;
1335        }
1336        let kurt = vals.iter().map(|v| ((v - mean) / std_dev).powi(4)).sum::<f64>() / n_f - 3.0;
1337        Some(kurt)
1338    }
1339
1340    /// Returns `true` if the z-score of `value` exceeds `sigma` in absolute terms.
1341    ///
1342    /// Convenience wrapper around [`normalize`](Self::normalize) for alert logic.
1343    /// Returns `false` if the normalizer window is empty or std-dev is zero.
1344    pub fn is_extreme(&self, value: Decimal, sigma: f64) -> bool {
1345        self.normalize(value)
1346            .ok()
1347            .map(|z| z.abs() > sigma)
1348            .unwrap_or(false)
1349    }
1350
1351    /// The most recently added value, or `None` if the window is empty.
1352    pub fn latest(&self) -> Option<Decimal> {
1353        self.window.back().copied()
1354    }
1355
1356    /// Median of the current window, or `None` if empty.
1357    pub fn median(&self) -> Option<Decimal> {
1358        if self.window.is_empty() { return None; }
1359        let mut vals: Vec<Decimal> = self.window.iter().copied().collect();
1360        vals.sort();
1361        let mid = vals.len() / 2;
1362        if vals.len() % 2 == 0 {
1363            Some((vals[mid - 1] + vals[mid]) / Decimal::TWO)
1364        } else {
1365            Some(vals[mid])
1366        }
1367    }
1368
1369    /// Empirical percentile of `value` within the current window: fraction of values ≤ `value`.
1370    ///
1371    /// Returns a value in `[0.0, 1.0]`. Returns `None` if the window is empty.
1372    pub fn percentile(&self, value: Decimal) -> Option<f64> {
1373        let n = self.window.len();
1374        if n == 0 { return None; }
1375        let count = self.window.iter().filter(|&&v| v <= value).count();
1376        Some(count as f64 / n as f64)
1377    }
1378
1379    /// Stateless EMA z-score helper: updates running `ema_mean` and `ema_var` and returns
1380    /// the z-score `(value - ema_mean) / sqrt(ema_var)`.
1381    ///
1382    /// `alpha ∈ (0, 1]` controls smoothing speed (higher = faster adaptation).
1383    /// Initialize `ema_mean = 0.0` and `ema_var = 0.0` before first call.
1384    /// Returns `None` if `value` cannot be converted to f64 or variance is still zero.
1385    pub fn ema_z_score(value: Decimal, alpha: f64, ema_mean: &mut f64, ema_var: &mut f64) -> Option<f64> {
1386        use rust_decimal::prelude::ToPrimitive;
1387        let v = value.to_f64()?;
1388        let delta = v - *ema_mean;
1389        *ema_mean += alpha * delta;
1390        *ema_var = (1.0 - alpha) * (*ema_var + alpha * delta * delta);
1391        let std = ema_var.sqrt();
1392        if std == 0.0 { return None; }
1393        Some((v - *ema_mean) / std)
1394    }
1395
1396    /// Z-score of the most recently added value.
1397    ///
1398    /// Returns `None` if the window is empty or std-dev is zero.
1399    pub fn z_score_of_latest(&self) -> Option<f64> {
1400        let latest = self.latest()?;
1401        self.normalize(latest).ok()
1402    }
1403
1404    /// Exponential moving average of z-scores for all values in the current window.
1405    ///
1406    /// `alpha` is the smoothing factor (0 < alpha ≤ 1). Higher alpha gives more weight
1407    /// to recent z-scores. Returns `None` if the window has fewer than 2 observations.
1408    pub fn ema_of_z_scores(&self, alpha: f64) -> Option<f64> {
1409        let n = self.window.len();
1410        if n < 2 {
1411            return None;
1412        }
1413        let mut ema: Option<f64> = None;
1414        for &value in &self.window {
1415            if let Ok(z) = self.normalize(value) {
1416                ema = Some(match ema {
1417                    None => z,
1418                    Some(prev) => alpha * z + (1.0 - alpha) * prev,
1419                });
1420            }
1421        }
1422        ema
1423    }
1424
1425    /// Chainable alias for `update`: feeds `value` into the window and returns `&mut Self`.
1426    pub fn add_observation(&mut self, value: Decimal) -> &mut Self {
1427        self.update(value);
1428        self
1429    }
1430
1431    /// Signed deviation of `value` from the window mean, as `f64`.
1432    ///
1433    /// Returns `None` if the window is empty.
1434    pub fn deviation_from_mean(&self, value: Decimal) -> Option<f64> {
1435        use rust_decimal::prelude::ToPrimitive;
1436        let mean = self.mean()?.to_f64()?;
1437        value.to_f64().map(|v| v - mean)
1438    }
1439
1440    /// Returns a `Vec` of window values that are within `sigma` standard deviations of the mean.
1441    ///
1442    /// Useful for robust statistics after removing extreme outliers.
1443    /// Returns all values if std-dev is zero (no outliers possible), empty vec if window is empty.
1444    pub fn trim_outliers(&self, sigma: f64) -> Vec<Decimal> {
1445        use rust_decimal::prelude::ToPrimitive;
1446        if self.window.is_empty() { return vec![]; }
1447        let mean = match self.mean() { Some(m) => m, None => return vec![] };
1448        let std = match self.std_dev().and_then(|s| s.to_f64()) {
1449            Some(s) if s > 0.0 => s,
1450            _ => return self.window.iter().copied().collect(),
1451        };
1452        let mean_f64 = match mean.to_f64() { Some(m) => m, None => return vec![] };
1453        self.window.iter().copied()
1454            .filter(|v| {
1455                v.to_f64()
1456                    .map(|vf| ((vf - mean_f64) / std).abs() <= sigma)
1457                    .unwrap_or(false)
1458            })
1459            .collect()
1460    }
1461
1462    /// Batch normalize: returns z-scores for each value as if they were added one-by-one.
1463    ///
1464    /// Each z-score uses only the window state after incorporating that value.
1465    /// The internal state is modified; call `reset()` if you need to restore it.
1466    /// Returns `None` entries where normalization fails (window warming up or zero std-dev).
1467    pub fn rolling_zscore_batch(&mut self, values: &[Decimal]) -> Vec<Option<f64>> {
1468        values.iter().map(|&v| {
1469            self.update(v);
1470            self.normalize(v).ok()
1471        }).collect()
1472    }
1473
1474    /// Change in mean between the first half and second half of the current window.
1475    ///
1476    /// Splits the window in two, computes the mean of each half, and returns
1477    /// `second_half_mean - first_half_mean` as `f64`. Returns `None` if the
1478    /// window has fewer than 2 observations.
1479    pub fn rolling_mean_change(&self) -> Option<f64> {
1480        let n = self.window.len();
1481        if n < 2 {
1482            return None;
1483        }
1484        let mid = n / 2;
1485        let first: Decimal = self.window.iter().take(mid).copied().sum::<Decimal>()
1486            / Decimal::from(mid as u64);
1487        let second: Decimal = self.window.iter().skip(mid).copied().sum::<Decimal>()
1488            / Decimal::from((n - mid) as u64);
1489        (second - first).to_f64()
1490    }
1491
1492    /// Count of window values whose z-score is strictly positive (above the mean).
1493    ///
1494    /// Returns `0` if the window is empty or all values are equal (z-scores are all 0).
1495    pub fn count_positive_z_scores(&self) -> usize {
1496        self.window
1497            .iter()
1498            .filter(|&&v| self.normalize(v).map_or(false, |z| z > 0.0))
1499            .count()
1500    }
1501
1502    /// Returns `true` if the absolute change between first-half and second-half window means
1503    /// is below `threshold`. A stable mean indicates the distribution is not trending.
1504    ///
1505    /// Returns `false` if the window has fewer than 2 observations.
1506    pub fn is_mean_stable(&self, threshold: f64) -> bool {
1507        match self.rolling_mean_change() {
1508            Some(change) => change.abs() < threshold,
1509            None => false,
1510        }
1511    }
1512
1513    /// Count of window values whose absolute z-score exceeds `z_threshold`.
1514    ///
1515    /// Returns `0` if the window has fewer than 2 observations or std-dev is zero.
1516    pub fn above_threshold_count(&self, z_threshold: f64) -> usize {
1517        self.window
1518            .iter()
1519            .filter(|&&v| {
1520                self.normalize(v)
1521                    .map_or(false, |z| z.abs() > z_threshold)
1522            })
1523            .count()
1524    }
1525
1526    /// Median Absolute Deviation (MAD) of the current window.
1527    ///
1528    /// `MAD = median(|x_i - median(window)|)`. Returns `None` if the window
1529    /// is empty.
1530    pub fn mad(&self) -> Option<Decimal> {
1531        let med = self.median()?;
1532        let mut deviations: Vec<Decimal> = self.window.iter().map(|&x| (x - med).abs()).collect();
1533        deviations.sort();
1534        let n = deviations.len();
1535        if n == 0 { return None; }
1536        let mid = n / 2;
1537        if n % 2 == 0 {
1538            Some((deviations[mid - 1] + deviations[mid]) / Decimal::TWO)
1539        } else {
1540            Some(deviations[mid])
1541        }
1542    }
1543
1544    /// Robust z-score: `(value - median) / MAD`.
1545    ///
1546    /// More resistant to outliers than the standard z-score. Returns `None`
1547    /// when the window is empty or MAD is zero.
1548    pub fn robust_z_score(&self, value: Decimal) -> Option<f64> {
1549        use rust_decimal::prelude::ToPrimitive;
1550        let med = self.median()?;
1551        let mad = self.mad()?;
1552        if mad.is_zero() { return None; }
1553        ((value - med) / mad).to_f64()
1554    }
1555}
1556
1557#[cfg(test)]
1558mod zscore_tests {
1559    use super::*;
1560    use rust_decimal_macros::dec;
1561
1562    fn znorm(w: usize) -> ZScoreNormalizer {
1563        ZScoreNormalizer::new(w).unwrap()
1564    }
1565
1566    #[test]
1567    fn test_zscore_new_zero_window_returns_error() {
1568        assert!(matches!(
1569            ZScoreNormalizer::new(0),
1570            Err(StreamError::ConfigError { .. })
1571        ));
1572    }
1573
1574    #[test]
1575    fn test_zscore_is_full_false_before_capacity() {
1576        let mut n = znorm(3);
1577        assert!(!n.is_full());
1578        n.update(dec!(1));
1579        n.update(dec!(2));
1580        assert!(!n.is_full());
1581        n.update(dec!(3));
1582        assert!(n.is_full());
1583    }
1584
1585    #[test]
1586    fn test_zscore_is_full_stays_true_after_eviction() {
1587        let mut n = znorm(3);
1588        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
1589            n.update(v);
1590        }
1591        assert!(n.is_full());
1592    }
1593
1594    #[test]
1595    fn test_zscore_empty_window_returns_error() {
1596        let n = znorm(4);
1597        assert!(matches!(
1598            n.normalize(dec!(1)),
1599            Err(StreamError::NormalizationError { .. })
1600        ));
1601    }
1602
1603    #[test]
1604    fn test_zscore_single_value_returns_zero() {
1605        let mut n = znorm(4);
1606        n.update(dec!(50));
1607        assert_eq!(n.normalize(dec!(50)).unwrap(), 0.0);
1608    }
1609
1610    #[test]
1611    fn test_zscore_mean_is_zero() {
1612        let mut n = znorm(5);
1613        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] {
1614            n.update(v);
1615        }
1616        // mean = 30; z-score of 30 should be 0
1617        let z = n.normalize(dec!(30)).unwrap();
1618        assert!((z - 0.0).abs() < 1e-9, "z-score of mean should be 0, got {z}");
1619    }
1620
1621    #[test]
1622    fn test_zscore_symmetric_around_mean() {
1623        let mut n = znorm(4);
1624        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
1625            n.update(v);
1626        }
1627        // mean = 25; values equidistant above and below mean have equal |z|
1628        let z_low = n.normalize(dec!(15)).unwrap();
1629        let z_high = n.normalize(dec!(35)).unwrap();
1630        assert!((z_low.abs() - z_high.abs()).abs() < 1e-9);
1631        assert!(z_low < 0.0, "below-mean z-score should be negative");
1632        assert!(z_high > 0.0, "above-mean z-score should be positive");
1633    }
1634
1635    #[test]
1636    fn test_zscore_all_same_returns_zero() {
1637        let mut n = znorm(4);
1638        for _ in 0..4 {
1639            n.update(dec!(100));
1640        }
1641        assert_eq!(n.normalize(dec!(100)).unwrap(), 0.0);
1642    }
1643
1644    #[test]
1645    fn test_zscore_rolling_window_eviction() {
1646        let mut n = znorm(3);
1647        n.update(dec!(1));
1648        n.update(dec!(2));
1649        n.update(dec!(3));
1650        // Evict 1, add 100 — window is [2, 3, 100]
1651        n.update(dec!(100));
1652        // mean ≈ 35; value 100 should have positive z-score
1653        let z = n.normalize(dec!(100)).unwrap();
1654        assert!(z > 0.0);
1655    }
1656
1657    #[test]
1658    fn test_zscore_reset_clears_state() {
1659        let mut n = znorm(4);
1660        for v in [dec!(10), dec!(20), dec!(30)] {
1661            n.update(v);
1662        }
1663        n.reset();
1664        assert!(n.is_empty());
1665        assert!(n.mean().is_none());
1666        assert!(matches!(
1667            n.normalize(dec!(1)),
1668            Err(StreamError::NormalizationError { .. })
1669        ));
1670    }
1671
1672    #[test]
1673    fn test_zscore_len_and_window_size() {
1674        let mut n = znorm(5);
1675        assert_eq!(n.len(), 0);
1676        assert!(n.is_empty());
1677        n.update(dec!(1));
1678        n.update(dec!(2));
1679        assert_eq!(n.len(), 2);
1680        assert_eq!(n.window_size(), 5);
1681    }
1682
1683    // ── std_dev ───────────────────────────────────────────────────────────────
1684
1685    #[test]
1686    fn test_std_dev_none_when_empty() {
1687        let n = znorm(5);
1688        assert!(n.std_dev().is_none());
1689    }
1690
1691    #[test]
1692    fn test_std_dev_zero_with_one_observation() {
1693        let mut n = znorm(5);
1694        n.update(dec!(42));
1695        assert_eq!(n.std_dev(), Some(0.0));
1696    }
1697
1698    #[test]
1699    fn test_std_dev_zero_when_all_same() {
1700        let mut n = znorm(4);
1701        for _ in 0..4 {
1702            n.update(dec!(10));
1703        }
1704        let sd = n.std_dev().unwrap();
1705        assert!(sd < f64::EPSILON);
1706    }
1707
1708    #[test]
1709    fn test_std_dev_positive_for_varying_values() {
1710        let mut n = znorm(4);
1711        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
1712            n.update(v);
1713        }
1714        let sd = n.std_dev().unwrap();
1715        // Population std dev of [10,20,30,40]: mean=25, var=125, sd≈11.18
1716        assert!((sd - 11.18).abs() < 0.01);
1717    }
1718
1719    // ── ZScoreNormalizer::variance ────────────────────────────────────────────
1720
1721    #[test]
1722    fn test_variance_none_when_fewer_than_two_observations() {
1723        let mut n = znorm(5);
1724        assert!(n.variance().is_none());
1725        n.update(dec!(10));
1726        assert!(n.variance().is_none());
1727    }
1728
1729    #[test]
1730    fn test_variance_zero_for_identical_values() {
1731        let mut n = znorm(4);
1732        for _ in 0..4 {
1733            n.update(dec!(7));
1734        }
1735        assert_eq!(n.variance().unwrap(), dec!(0));
1736    }
1737
1738    #[test]
1739    fn test_variance_correct_for_known_values() {
1740        let mut n = znorm(4);
1741        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
1742            n.update(v);
1743        }
1744        // Population variance of [10,20,30,40]: mean=25, var=125
1745        let var = n.variance().unwrap();
1746        let var_f64 = f64::try_from(var).unwrap();
1747        assert!((var_f64 - 125.0).abs() < 0.01, "expected 125 got {var_f64}");
1748    }
1749
1750    // ── ZScoreNormalizer::normalize_batch ─────────────────────────────────────
1751
1752    #[test]
1753    fn test_normalize_batch_same_length_as_input() {
1754        let mut n = znorm(5);
1755        let vals = [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)];
1756        let out = n.normalize_batch(&vals).unwrap();
1757        assert_eq!(out.len(), vals.len());
1758    }
1759
1760    #[test]
1761    fn test_normalize_batch_last_value_matches_single_normalize() {
1762        let mut n1 = znorm(5);
1763        let vals = [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)];
1764        let batch = n1.normalize_batch(&vals).unwrap();
1765
1766        let mut n2 = znorm(5);
1767        for &v in &vals {
1768            n2.update(v);
1769        }
1770        let single = n2.normalize(dec!(50)).unwrap();
1771        assert!((batch[4] - single).abs() < 1e-9);
1772    }
1773
1774    #[test]
1775    fn test_sum_empty_returns_none() {
1776        let n = znorm(4);
1777        assert!(n.sum().is_none());
1778    }
1779
1780    #[test]
1781    fn test_sum_matches_manual() {
1782        let mut n = znorm(4);
1783        n.update(dec!(10));
1784        n.update(dec!(20));
1785        n.update(dec!(30));
1786        // window = [10, 20, 30], sum = 60
1787        assert_eq!(n.sum().unwrap(), dec!(60));
1788    }
1789
1790    #[test]
1791    fn test_sum_evicts_old_values() {
1792        let mut n = znorm(2);
1793        n.update(dec!(10));
1794        n.update(dec!(20));
1795        n.update(dec!(30)); // evicts 10
1796        // window = [20, 30], sum = 50
1797        assert_eq!(n.sum().unwrap(), dec!(50));
1798    }
1799
1800    #[test]
1801    fn test_std_dev_single_observation_returns_some_zero() {
1802        let mut n = znorm(5);
1803        n.update(dec!(10));
1804        // Single sample → variance undefined, std_dev should return None or 0
1805        // ZScoreNormalizer::std_dev returns None for n < 2
1806        assert!(n.std_dev().is_none() || n.std_dev().unwrap() == 0.0);
1807    }
1808
1809    #[test]
1810    fn test_std_dev_constant_window_is_zero() {
1811        let mut n = znorm(4);
1812        for _ in 0..4 {
1813            n.update(dec!(5));
1814        }
1815        let sd = n.std_dev().unwrap();
1816        assert!(sd.abs() < 1e-9, "expected 0.0 got {sd}");
1817    }
1818
1819    #[test]
1820    fn test_std_dev_known_population() {
1821        // values [2, 4, 4, 4, 5, 5, 7, 9] → σ = 2.0
1822        let mut n = znorm(8);
1823        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
1824            n.update(v);
1825        }
1826        let sd = n.std_dev().unwrap();
1827        assert!((sd - 2.0).abs() < 1e-6, "expected ~2.0 got {sd}");
1828    }
1829
1830    // --- window_range / coefficient_of_variation ---
1831
1832    #[test]
1833    fn test_window_range_none_when_empty() {
1834        let n = znorm(5);
1835        assert!(n.window_range().is_none());
1836    }
1837
1838    #[test]
1839    fn test_window_range_correct_value() {
1840        let mut n = znorm(5);
1841        n.update(dec!(10));
1842        n.update(dec!(20));
1843        n.update(dec!(15));
1844        // max=20, min=10 → range=10
1845        assert_eq!(n.window_range().unwrap(), dec!(10));
1846    }
1847
1848    #[test]
1849    fn test_coefficient_of_variation_none_when_empty() {
1850        let n = znorm(5);
1851        assert!(n.coefficient_of_variation().is_none());
1852    }
1853
1854    #[test]
1855    fn test_coefficient_of_variation_none_when_mean_zero() {
1856        let mut n = znorm(5);
1857        n.update(dec!(-5));
1858        n.update(dec!(5)); // mean = 0
1859        assert!(n.coefficient_of_variation().is_none());
1860    }
1861
1862    #[test]
1863    fn test_coefficient_of_variation_positive_for_nonzero_mean() {
1864        let mut n = znorm(8);
1865        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
1866            n.update(v);
1867        }
1868        // mean = 5, std_dev = 2, cv = 2/5 = 0.4
1869        let cv = n.coefficient_of_variation().unwrap();
1870        assert!((cv - 0.4).abs() < 1e-5, "expected ~0.4 got {cv}");
1871    }
1872
1873    // --- sample_variance ---
1874
1875    #[test]
1876    fn test_sample_variance_none_when_empty() {
1877        let n = znorm(5);
1878        assert!(n.sample_variance().is_none());
1879    }
1880
1881    #[test]
1882    fn test_sample_variance_zero_for_constant_window() {
1883        let mut n = znorm(3);
1884        n.update(dec!(7));
1885        n.update(dec!(7));
1886        n.update(dec!(7));
1887        assert!(n.sample_variance().unwrap().abs() < 1e-10);
1888    }
1889
1890    #[test]
1891    fn test_sample_variance_equals_std_dev_squared() {
1892        let mut n = znorm(8);
1893        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
1894            n.update(v);
1895        }
1896        // std_dev ≈ 2.0, variance ≈ 4.0
1897        let variance = n.sample_variance().unwrap();
1898        let sd = n.std_dev().unwrap();
1899        assert!((variance - sd * sd).abs() < 1e-10);
1900    }
1901
1902    // --- window_mean_f64 ---
1903
1904    #[test]
1905    fn test_window_mean_f64_none_when_empty() {
1906        let n = znorm(5);
1907        assert!(n.window_mean_f64().is_none());
1908    }
1909
1910    #[test]
1911    fn test_window_mean_f64_correct_value() {
1912        let mut n = znorm(4);
1913        n.update(dec!(10));
1914        n.update(dec!(20));
1915        // mean = 15.0
1916        let m = n.window_mean_f64().unwrap();
1917        assert!((m - 15.0).abs() < 1e-10);
1918    }
1919
1920    #[test]
1921    fn test_window_mean_f64_matches_decimal_mean() {
1922        let mut n = znorm(8);
1923        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
1924            n.update(v);
1925        }
1926        use rust_decimal::prelude::ToPrimitive;
1927        let expected = n.mean().unwrap().to_f64().unwrap();
1928        assert!((n.window_mean_f64().unwrap() - expected).abs() < 1e-10);
1929    }
1930
1931    // ── ZScoreNormalizer::kurtosis ────────────────────────────────────────────
1932
1933    #[test]
1934    fn test_kurtosis_none_when_fewer_than_4_observations() {
1935        let mut n = znorm(5);
1936        n.update(dec!(1));
1937        n.update(dec!(2));
1938        n.update(dec!(3));
1939        assert!(n.kurtosis().is_none());
1940    }
1941
1942    #[test]
1943    fn test_kurtosis_returns_some_with_4_observations() {
1944        let mut n = znorm(4);
1945        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
1946            n.update(v);
1947        }
1948        assert!(n.kurtosis().is_some());
1949    }
1950
1951    #[test]
1952    fn test_kurtosis_none_when_all_same_value() {
1953        let mut n = znorm(4);
1954        for _ in 0..4 {
1955            n.update(dec!(5));
1956        }
1957        // std_dev = 0 → kurtosis is None
1958        assert!(n.kurtosis().is_none());
1959    }
1960
1961    #[test]
1962    fn test_kurtosis_uniform_distribution_is_negative() {
1963        // Uniform distribution has excess kurtosis of -1.2
1964        let mut n = znorm(10);
1965        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5),
1966                  dec!(6), dec!(7), dec!(8), dec!(9), dec!(10)] {
1967            n.update(v);
1968        }
1969        let k = n.kurtosis().unwrap();
1970        // Excess kurtosis of uniform dist over integers is negative
1971        assert!(k < 0.0, "expected negative excess kurtosis for uniform dist, got {k}");
1972    }
1973
1974    // --- ZScoreNormalizer::is_near_mean ---
1975    #[test]
1976    fn test_is_near_mean_false_with_fewer_than_two_obs() {
1977        let mut n = znorm(5);
1978        n.update(dec!(10));
1979        assert!(!n.is_near_mean(dec!(10), 1.0));
1980    }
1981
1982    #[test]
1983    fn test_is_near_mean_true_within_one_sigma() {
1984        let mut n = znorm(10);
1985        // Feed 10, 10, 10, ..., 10, 20 → mean≈11, std_dev small-ish
1986        for _ in 0..9 {
1987            n.update(dec!(10));
1988        }
1989        n.update(dec!(20));
1990        // mean = (90 + 20) / 10 = 11
1991        assert!(n.is_near_mean(dec!(11), 1.0));
1992    }
1993
1994    #[test]
1995    fn test_is_near_mean_false_when_far_from_mean() {
1996        let mut n = znorm(5);
1997        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] {
1998            n.update(v);
1999        }
2000        // mean = 3, std_dev ≈ 1.41; 100 is many sigmas away
2001        assert!(!n.is_near_mean(dec!(100), 2.0));
2002    }
2003
2004    #[test]
2005    fn test_is_near_mean_true_when_all_identical_any_value() {
2006        let mut n = znorm(4);
2007        for _ in 0..4 {
2008            n.update(dec!(7));
2009        }
2010        // std_dev = 0 → any value returns true
2011        assert!(n.is_near_mean(dec!(999), 0.0));
2012    }
2013
2014    // --- ZScoreNormalizer::window_sum_f64 ---
2015    #[test]
2016    fn test_window_sum_f64_zero_on_empty() {
2017        let n = znorm(5);
2018        assert_eq!(n.window_sum_f64(), 0.0);
2019    }
2020
2021    #[test]
2022    fn test_window_sum_f64_correct_after_updates() {
2023        let mut n = znorm(5);
2024        n.update(dec!(10));
2025        n.update(dec!(20));
2026        n.update(dec!(30));
2027        assert!((n.window_sum_f64() - 60.0).abs() < 1e-10);
2028    }
2029
2030    #[test]
2031    fn test_window_sum_f64_rolls_out_old_values() {
2032        let mut n = znorm(2);
2033        n.update(dec!(100));
2034        n.update(dec!(200));
2035        n.update(dec!(300)); // 100 rolls out
2036        // window contains 200, 300 → sum = 500
2037        assert!((n.window_sum_f64() - 500.0).abs() < 1e-10);
2038    }
2039
2040    // ── ZScoreNormalizer::latest ────────────────────────────────────────────
2041
2042    #[test]
2043    fn test_zscore_latest_none_when_empty() {
2044        let n = znorm(5);
2045        assert!(n.latest().is_none());
2046    }
2047
2048    #[test]
2049    fn test_zscore_latest_returns_most_recent() {
2050        let mut n = znorm(5);
2051        n.update(dec!(10));
2052        n.update(dec!(20));
2053        assert_eq!(n.latest(), Some(dec!(20)));
2054    }
2055
2056    #[test]
2057    fn test_zscore_latest_updates_on_roll() {
2058        let mut n = znorm(2);
2059        n.update(dec!(1));
2060        n.update(dec!(2));
2061        n.update(dec!(3)); // rolls out 1
2062        assert_eq!(n.latest(), Some(dec!(3)));
2063    }
2064
2065    // --- ZScoreNormalizer::window_max_f64 / window_min_f64 ---
2066    #[test]
2067    fn test_window_max_f64_none_on_empty() {
2068        let n = znorm(5);
2069        assert!(n.window_max_f64().is_none());
2070    }
2071
2072    #[test]
2073    fn test_window_max_f64_correct_value() {
2074        let mut n = znorm(5);
2075        for v in [dec!(3), dec!(7), dec!(1), dec!(5)] {
2076            n.update(v);
2077        }
2078        assert!((n.window_max_f64().unwrap() - 7.0).abs() < 1e-10);
2079    }
2080
2081    #[test]
2082    fn test_window_min_f64_none_on_empty() {
2083        let n = znorm(5);
2084        assert!(n.window_min_f64().is_none());
2085    }
2086
2087    #[test]
2088    fn test_window_min_f64_correct_value() {
2089        let mut n = znorm(5);
2090        for v in [dec!(3), dec!(7), dec!(1), dec!(5)] {
2091            n.update(v);
2092        }
2093        assert!((n.window_min_f64().unwrap() - 1.0).abs() < 1e-10);
2094    }
2095
2096    // ── ZScoreNormalizer::percentile ────────────────────────────────────────
2097
2098    #[test]
2099    fn test_percentile_none_when_empty() {
2100        let n = znorm(5);
2101        assert!(n.percentile(dec!(10)).is_none());
2102    }
2103
2104    #[test]
2105    fn test_percentile_one_when_all_lte_value() {
2106        let mut n = znorm(4);
2107        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
2108            n.update(v);
2109        }
2110        assert!((n.percentile(dec!(4)).unwrap() - 1.0).abs() < 1e-9);
2111    }
2112
2113    #[test]
2114    fn test_percentile_zero_when_all_gt_value() {
2115        let mut n = znorm(4);
2116        for v in [dec!(5), dec!(6), dec!(7), dec!(8)] {
2117            n.update(v);
2118        }
2119        // 0 of 4 values are ≤ 4
2120        assert_eq!(n.percentile(dec!(4)).unwrap(), 0.0);
2121    }
2122
2123    #[test]
2124    fn test_percentile_half_at_median() {
2125        let mut n = znorm(4);
2126        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
2127            n.update(v);
2128        }
2129        // 2 of 4 values ≤ 2 → 0.5
2130        assert!((n.percentile(dec!(2)).unwrap() - 0.5).abs() < 1e-9);
2131    }
2132
2133    // ── ZScoreNormalizer::z_score_of_latest / deviation_from_mean ───────────
2134
2135    #[test]
2136    fn test_z_score_of_latest_none_when_empty() {
2137        let n = znorm(5);
2138        assert!(n.z_score_of_latest().is_none());
2139    }
2140
2141    #[test]
2142    fn test_z_score_of_latest_zero_when_all_same() {
2143        let mut n = znorm(4);
2144        for _ in 0..4 {
2145            n.update(dec!(5));
2146        }
2147        // std_dev = 0 → normalize returns Ok(0.0) → z_score_of_latest returns Some(0.0)
2148        assert_eq!(n.z_score_of_latest(), Some(0.0));
2149    }
2150
2151    #[test]
2152    fn test_z_score_of_latest_returns_some_with_variance() {
2153        let mut n = znorm(4);
2154        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
2155            n.update(v);
2156        }
2157        // latest = 4; should produce Some
2158        assert!(n.z_score_of_latest().is_some());
2159    }
2160
2161    #[test]
2162    fn test_deviation_from_mean_none_when_empty() {
2163        let n = znorm(5);
2164        assert!(n.deviation_from_mean(dec!(10)).is_none());
2165    }
2166
2167    #[test]
2168    fn test_deviation_from_mean_correct() {
2169        let mut n = znorm(4);
2170        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
2171            n.update(v);
2172        }
2173        // mean = 2.5, value = 4 → deviation = 1.5
2174        let d = n.deviation_from_mean(dec!(4)).unwrap();
2175        assert!((d - 1.5).abs() < 1e-9);
2176    }
2177
2178    // ── ZScoreNormalizer::add_observation ─────────────────────────────────────
2179
2180    #[test]
2181    fn test_add_observation_same_as_update() {
2182        let mut n1 = znorm(4);
2183        let mut n2 = znorm(4);
2184        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
2185            n1.update(v);
2186            n2.add_observation(v);
2187        }
2188        assert_eq!(n1.mean(), n2.mean());
2189    }
2190
2191    #[test]
2192    fn test_add_observation_chainable() {
2193        let mut n = znorm(4);
2194        n.add_observation(dec!(1))
2195         .add_observation(dec!(2))
2196         .add_observation(dec!(3));
2197        assert_eq!(n.len(), 3);
2198    }
2199
2200    // ── ZScoreNormalizer::variance_f64 ────────────────────────────────────────
2201
2202    #[test]
2203    fn test_variance_f64_none_when_single_observation() {
2204        let mut n = znorm(4);
2205        n.update(dec!(5));
2206        assert!(n.variance_f64().is_none());
2207    }
2208
2209    #[test]
2210    fn test_variance_f64_zero_when_all_same() {
2211        let mut n = znorm(4);
2212        for _ in 0..4 { n.update(dec!(5)); }
2213        assert_eq!(n.variance_f64(), Some(0.0));
2214    }
2215
2216    #[test]
2217    fn test_variance_f64_positive_with_spread() {
2218        let mut n = znorm(4);
2219        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2220        assert!(n.variance_f64().unwrap() > 0.0);
2221    }
2222
2223    // ── ZScoreNormalizer::ema_of_z_scores ────────────────────────────────────
2224
2225    #[test]
2226    fn test_ema_of_z_scores_none_when_single_value() {
2227        let mut n = znorm(4);
2228        n.update(dec!(5));
2229        assert!(n.ema_of_z_scores(0.5).is_none());
2230    }
2231
2232    #[test]
2233    fn test_ema_of_z_scores_returns_some_with_variance() {
2234        let mut n = znorm(4);
2235        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
2236            n.update(v);
2237        }
2238        let ema = n.ema_of_z_scores(0.3);
2239        assert!(ema.is_some());
2240    }
2241
2242    #[test]
2243    fn test_ema_of_z_scores_zero_when_all_same() {
2244        let mut n = znorm(4);
2245        for _ in 0..4 { n.update(dec!(5)); }
2246        // All z-scores are 0.0 → EMA = 0.0
2247        assert_eq!(n.ema_of_z_scores(0.5), Some(0.0));
2248    }
2249
2250    // ── ZScoreNormalizer::std_dev_f64 ─────────────────────────────────────────
2251
2252    #[test]
2253    fn test_std_dev_f64_none_when_single_observation() {
2254        let mut n = znorm(4);
2255        n.update(dec!(5));
2256        assert!(n.std_dev_f64().is_none());
2257    }
2258
2259    #[test]
2260    fn test_std_dev_f64_zero_when_all_same() {
2261        let mut n = znorm(4);
2262        for _ in 0..4 { n.update(dec!(5)); }
2263        assert_eq!(n.std_dev_f64(), Some(0.0));
2264    }
2265
2266    #[test]
2267    fn test_std_dev_f64_equals_sqrt_of_variance() {
2268        let mut n = znorm(4);
2269        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2270        let var = n.variance_f64().unwrap();
2271        let std = n.std_dev_f64().unwrap();
2272        assert!((std - var.sqrt()).abs() < 1e-12);
2273    }
2274
2275    // ── rolling_mean_change ───────────────────────────────────────────────────
2276
2277    #[test]
2278    fn test_rolling_mean_change_none_when_one_observation() {
2279        let mut n = znorm(4);
2280        n.update(dec!(5));
2281        assert!(n.rolling_mean_change().is_none());
2282    }
2283
2284    #[test]
2285    fn test_rolling_mean_change_positive_when_rising() {
2286        let mut n = znorm(4);
2287        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2288        // first half [1,2] mean=1.5, second half [3,4] mean=3.5 → change=2.0
2289        let change = n.rolling_mean_change().unwrap();
2290        assert!((change - 2.0).abs() < 1e-9);
2291    }
2292
2293    #[test]
2294    fn test_rolling_mean_change_negative_when_falling() {
2295        let mut n = znorm(4);
2296        for v in [dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
2297        let change = n.rolling_mean_change().unwrap();
2298        assert!(change < 0.0);
2299    }
2300
2301    #[test]
2302    fn test_rolling_mean_change_zero_when_flat() {
2303        let mut n = znorm(4);
2304        for _ in 0..4 { n.update(dec!(7)); }
2305        let change = n.rolling_mean_change().unwrap();
2306        assert!(change.abs() < 1e-9);
2307    }
2308
2309    // ── window_span_f64 ───────────────────────────────────────────────────────
2310
2311    #[test]
2312    fn test_window_span_f64_none_when_empty() {
2313        let n = znorm(4);
2314        assert!(n.window_span_f64().is_none());
2315    }
2316
2317    #[test]
2318    fn test_window_span_f64_zero_when_all_same() {
2319        let mut n = znorm(4);
2320        for _ in 0..4 { n.update(dec!(5)); }
2321        assert_eq!(n.window_span_f64(), Some(0.0));
2322    }
2323
2324    #[test]
2325    fn test_window_span_f64_correct_value() {
2326        let mut n = znorm(4);
2327        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
2328        // max=40, min=10, span=30
2329        assert!((n.window_span_f64().unwrap() - 30.0).abs() < 1e-9);
2330    }
2331
2332    // ── count_positive_z_scores ───────────────────────────────────────────────
2333
2334    #[test]
2335    fn test_count_positive_z_scores_zero_when_empty() {
2336        let n = znorm(4);
2337        assert_eq!(n.count_positive_z_scores(), 0);
2338    }
2339
2340    #[test]
2341    fn test_count_positive_z_scores_zero_when_all_same() {
2342        let mut n = znorm(4);
2343        for _ in 0..4 { n.update(dec!(5)); }
2344        assert_eq!(n.count_positive_z_scores(), 0);
2345    }
2346
2347    #[test]
2348    fn test_count_positive_z_scores_half_above_mean() {
2349        let mut n = znorm(4);
2350        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2351        // mean=2.5, values above: 3 and 4
2352        assert_eq!(n.count_positive_z_scores(), 2);
2353    }
2354
2355    // ── above_threshold_count ─────────────────────────────────────────────────
2356
2357    #[test]
2358    fn test_above_threshold_count_zero_when_empty() {
2359        let n = znorm(4);
2360        assert_eq!(n.above_threshold_count(1.0), 0);
2361    }
2362
2363    #[test]
2364    fn test_above_threshold_count_zero_when_all_same() {
2365        let mut n = znorm(4);
2366        for _ in 0..4 { n.update(dec!(5)); }
2367        assert_eq!(n.above_threshold_count(0.5), 0);
2368    }
2369
2370    #[test]
2371    fn test_above_threshold_count_correct_with_extremes() {
2372        let mut n = znorm(6);
2373        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(100)] { n.update(v); }
2374        // 100 is many std devs from mean; threshold=1.0 should catch it
2375        assert!(n.above_threshold_count(1.0) >= 1);
2376    }
2377}
2378
2379#[cfg(test)]
2380mod minmax_extra_tests {
2381    use super::*;
2382    use rust_decimal_macros::dec;
2383
2384    fn norm(w: usize) -> MinMaxNormalizer {
2385        MinMaxNormalizer::new(w).unwrap()
2386    }
2387
2388    // ── fraction_above_mid ────────────────────────────────────────────────────
2389
2390    #[test]
2391    fn test_fraction_above_mid_none_when_empty() {
2392        let mut n = norm(4);
2393        assert!(n.fraction_above_mid().is_none());
2394    }
2395
2396    #[test]
2397    fn test_fraction_above_mid_zero_when_all_same() {
2398        let mut n = norm(4);
2399        for _ in 0..4 { n.update(dec!(5)); }
2400        assert_eq!(n.fraction_above_mid(), Some(0.0));
2401    }
2402
2403    #[test]
2404    fn test_fraction_above_mid_half_when_symmetric() {
2405        let mut n = norm(4);
2406        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2407        // mid = (1+4)/2 = 2.5, above: 3 and 4 = 2/4 = 0.5
2408        let f = n.fraction_above_mid().unwrap();
2409        assert!((f - 0.5).abs() < 1e-10);
2410    }
2411}
2412
2413#[cfg(test)]
2414mod zscore_stability_tests {
2415    use super::*;
2416    use rust_decimal_macros::dec;
2417
2418    fn znorm(w: usize) -> ZScoreNormalizer {
2419        ZScoreNormalizer::new(w).unwrap()
2420    }
2421
2422    // ── is_mean_stable ────────────────────────────────────────────────────────
2423
2424    #[test]
2425    fn test_is_mean_stable_false_when_window_too_small() {
2426        let n = znorm(4);
2427        assert!(!n.is_mean_stable(1.0));
2428    }
2429
2430    #[test]
2431    fn test_is_mean_stable_true_when_flat() {
2432        let mut n = znorm(4);
2433        for _ in 0..4 { n.update(dec!(5)); }
2434        assert!(n.is_mean_stable(0.001));
2435    }
2436
2437    #[test]
2438    fn test_is_mean_stable_false_when_trending() {
2439        let mut n = znorm(4);
2440        for v in [dec!(1), dec!(2), dec!(10), dec!(20)] { n.update(v); }
2441        assert!(!n.is_mean_stable(0.5));
2442    }
2443}