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        self.min_max().map_or(value, |(min, max)| value.max(min).min(max))
215    }
216
217    /// Midpoint of the current window: `(min + max) / 2`.
218    ///
219    /// Returns `None` if the window is empty.
220    pub fn midpoint(&mut self) -> Option<Decimal> {
221        let (min, max) = self.min_max()?;
222        Some((min + max) / Decimal::TWO)
223    }
224
225    /// Reset the normalizer, clearing all observations and the cache.
226    pub fn reset(&mut self) {
227        self.window.clear();
228        self.cached_min = Decimal::MAX;
229        self.cached_max = Decimal::MIN;
230        self.dirty = false;
231    }
232
233    /// Number of observations currently in the window.
234    pub fn len(&self) -> usize {
235        self.window.len()
236    }
237
238    /// Returns `true` if no observations have been added since construction or
239    /// the last reset.
240    pub fn is_empty(&self) -> bool {
241        self.window.is_empty()
242    }
243
244    /// The configured window size.
245    pub fn window_size(&self) -> usize {
246        self.window_size
247    }
248
249    /// Returns `true` when the window holds exactly `window_size` observations.
250    ///
251    /// At full capacity the normalizer has seen enough data for stable min/max
252    /// estimates; before this point early observations dominate the range.
253    pub fn is_full(&self) -> bool {
254        self.window.len() == self.window_size
255    }
256
257    /// Current window minimum, or `None` if the window is empty.
258    ///
259    /// Equivalent to `self.min_max().map(|(min, _)| min)` but avoids also
260    /// computing the max when only the min is needed.
261    pub fn min(&mut self) -> Option<Decimal> {
262        self.min_max().map(|(min, _)| min)
263    }
264
265    /// Current window maximum, or `None` if the window is empty.
266    ///
267    /// Equivalent to `self.min_max().map(|(_, max)| max)` but avoids also
268    /// computing the min when only the max is needed.
269    pub fn max(&mut self) -> Option<Decimal> {
270        self.min_max().map(|(_, max)| max)
271    }
272
273    /// Returns the arithmetic mean of the current window observations.
274    ///
275    /// Returns `None` if the window is empty.
276    pub fn mean(&self) -> Option<Decimal> {
277        if self.window.is_empty() {
278            return None;
279        }
280        let sum: Decimal = self.window.iter().copied().sum();
281        Some(sum / Decimal::from(self.window.len() as u64))
282    }
283
284    /// Population variance of the current window: `Σ(x − mean)² / n`.
285    ///
286    /// Returns `None` if the window has fewer than 2 observations.
287    pub fn variance(&self) -> Option<Decimal> {
288        let n = self.window.len();
289        if n < 2 {
290            return None;
291        }
292        let mean = self.mean()?;
293        let variance = self
294            .window
295            .iter()
296            .map(|&v| { let d = v - mean; d * d })
297            .sum::<Decimal>()
298            / Decimal::from(n as u64);
299        Some(variance)
300    }
301
302    /// Population standard deviation of the current window: `sqrt(variance)`.
303    ///
304    /// Returns `None` if the window has fewer than 2 observations or if the
305    /// variance cannot be converted to `f64`.
306    pub fn std_dev(&self) -> Option<f64> {
307        use rust_decimal::prelude::ToPrimitive;
308        self.variance()?.to_f64().map(f64::sqrt)
309    }
310
311    /// Coefficient of variation: `std_dev / |mean|`.
312    ///
313    /// A dimensionless measure of relative dispersion. Returns `None` when the
314    /// window has fewer than 2 observations or when the mean is zero.
315    pub fn coefficient_of_variation(&self) -> Option<f64> {
316        use rust_decimal::prelude::ToPrimitive;
317        let mean = self.mean()?;
318        if mean.is_zero() {
319            return None;
320        }
321        let std_dev = self.std_dev()?;
322        let mean_f = mean.abs().to_f64()?;
323        Some(std_dev / mean_f)
324    }
325
326    /// Feed a slice of values into the window and return normalized forms of each.
327    ///
328    /// Each value in `values` is first passed through [`update`](Self::update) to
329    /// advance the rolling window, then normalized against the current window state.
330    /// The output has the same length as `values`.
331    ///
332    /// # Errors
333    /// Propagates the first [`StreamError`] returned by [`normalize`](Self::normalize).
334    pub fn normalize_batch(
335        &mut self,
336        values: &[rust_decimal::Decimal],
337    ) -> Result<Vec<f64>, crate::error::StreamError> {
338        values
339            .iter()
340            .map(|&v| {
341                self.update(v);
342                self.normalize(v)
343            })
344            .collect()
345    }
346
347    /// Normalize `value` and clamp the result to `[0.0, 1.0]`.
348    ///
349    /// Identical to [`normalize`](Self::normalize) because `normalize` already
350    /// clamps its output to `[0.0, 1.0]`. Deprecated in favour of calling
351    /// `normalize` directly.
352    ///
353    /// # Errors
354    ///
355    /// Returns [`StreamError::NormalizationError`] if the window is empty.
356    #[deprecated(since = "2.2.0", note = "Use `normalize()` instead — it already clamps to [0.0, 1.0]")]
357    pub fn normalize_clamp(
358        &mut self,
359        value: rust_decimal::Decimal,
360    ) -> Result<f64, crate::error::StreamError> {
361        self.normalize(value)
362    }
363
364    /// Compute the z-score of `value` relative to the current window.
365    ///
366    /// `z = (value - mean) / stddev`
367    ///
368    /// Returns `None` if the window has fewer than 2 observations, or if the
369    /// standard deviation is zero (all values identical).
370    ///
371    /// Useful for detecting outliers and standardising features for ML models
372    /// when a bounded `[0, 1]` range is not required.
373    pub fn z_score(&self, value: Decimal) -> Option<f64> {
374        use rust_decimal::prelude::ToPrimitive;
375        let std_dev = self.std_dev()?; // None for < 2 obs
376        if std_dev == 0.0 {
377            return None;
378        }
379        let mean = self.mean()?;
380        let value_f64 = value.to_f64()?;
381        let mean_f64 = mean.to_f64()?;
382        Some((value_f64 - mean_f64) / std_dev)
383    }
384
385    /// Returns the percentile rank of `value` within the current window.
386    ///
387    /// The percentile rank is the fraction of window values that are `<= value`,
388    /// expressed in `[0.0, 1.0]`. Returns `None` if the window is empty.
389    ///
390    /// Useful for identifying whether the current value is historically high or low
391    /// relative to its recent context without requiring a min/max range.
392    pub fn percentile_rank(&self, value: rust_decimal::Decimal) -> Option<f64> {
393        if self.window.is_empty() {
394            return None;
395        }
396        let count_le = self
397            .window
398            .iter()
399            .filter(|&&v| v <= value)
400            .count();
401        Some(count_le as f64 / self.window.len() as f64)
402    }
403
404    /// Count of observations in the current window that are strictly above `threshold`.
405    pub fn count_above(&self, threshold: rust_decimal::Decimal) -> usize {
406        self.window.iter().filter(|&&v| v > threshold).count()
407    }
408
409    /// Count of observations in the current window that are strictly below `threshold`.
410    ///
411    /// Complement to [`count_above`](Self::count_above).
412    pub fn count_below(&self, threshold: rust_decimal::Decimal) -> usize {
413        self.window.iter().filter(|&&v| v < threshold).count()
414    }
415
416    /// Value at the p-th percentile of the current window (0.0 ≤ p ≤ 1.0).
417    ///
418    /// Uses linear interpolation between adjacent sorted values.
419    /// Returns `None` if the window is empty.
420    pub fn percentile_value(&self, p: f64) -> Option<Decimal> {
421        if self.window.is_empty() {
422            return None;
423        }
424        let p = p.clamp(0.0, 1.0);
425        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
426        sorted.sort();
427        let n = sorted.len();
428        if n == 1 {
429            return Some(sorted[0]);
430        }
431        let idx = p * (n - 1) as f64;
432        let lo = idx.floor() as usize;
433        let hi = idx.ceil() as usize;
434        if lo == hi {
435            Some(sorted[lo])
436        } else {
437            let frac = Decimal::try_from(idx - lo as f64).ok()?;
438            Some(sorted[lo] + (sorted[hi] - sorted[lo]) * frac)
439        }
440    }
441
442    /// Fraction of window values strictly above the midpoint `(min + max) / 2`.
443    ///
444    /// Returns `None` if the window is empty. Returns `0.0` if all values are equal.
445    pub fn fraction_above_mid(&mut self) -> Option<f64> {
446        let (min, max) = self.min_max()?;
447        let mid = (min + max) / rust_decimal::Decimal::TWO;
448        let above = self.window.iter().filter(|&&v| v > mid).count();
449        Some(above as f64 / self.window.len() as f64)
450    }
451
452    /// `(max - min) / max` as `f64` — the range as a fraction of the maximum.
453    ///
454    /// Measures how wide the window's spread is relative to its peak. Returns
455    /// `None` if the window is empty or the maximum is zero.
456    pub fn normalized_range(&mut self) -> Option<f64> {
457        use rust_decimal::prelude::ToPrimitive;
458        let (min, max) = self.min_max()?;
459        if max.is_zero() {
460            return None;
461        }
462        ((max - min) / max).to_f64()
463    }
464
465    /// Exponential weighted moving average of the current window values.
466    ///
467    /// Applies `alpha` as the smoothing factor (most-recent weight), scanning oldest→newest.
468    /// `alpha` is clamped to `(0, 1]`. Returns `None` if the window is empty.
469    pub fn ewma(&self, alpha: f64) -> Option<f64> {
470        if self.window.is_empty() {
471            return None;
472        }
473        let alpha = alpha.clamp(f64::MIN_POSITIVE, 1.0);
474        let one_minus = 1.0 - alpha;
475        let mut ewma = self.window[0].to_f64().unwrap_or(0.0);
476        for &v in self.window.iter().skip(1) {
477            ewma = alpha * v.to_f64().unwrap_or(ewma) + one_minus * ewma;
478        }
479        Some(ewma)
480    }
481
482    /// Interquartile range: Q3 (75th percentile) − Q1 (25th percentile) of the window.
483    ///
484    /// Returns `None` if the window has fewer than 4 observations.
485    /// The IQR is a robust spread measure less sensitive to outliers than range or std dev.
486    pub fn interquartile_range(&self) -> Option<Decimal> {
487        let n = self.window.len();
488        if n < 4 {
489            return None;
490        }
491        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
492        sorted.sort();
493        let q1_idx = n / 4;
494        let q3_idx = 3 * n / 4;
495        Some(sorted[q3_idx] - sorted[q1_idx])
496    }
497
498    /// Skewness of the window values: `Σ((x - mean)³/n) / std_dev³`.
499    ///
500    /// Positive skew means the tail is longer on the right; negative on the left.
501    /// Returns `None` if the window has fewer than 3 observations or std dev is zero.
502    pub fn skewness(&self) -> Option<f64> {
503        use rust_decimal::prelude::ToPrimitive;
504        let n = self.window.len();
505        if n < 3 {
506            return None;
507        }
508        let n_f = n as f64;
509        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
510        if vals.len() < n {
511            return None;
512        }
513        let mean = vals.iter().sum::<f64>() / n_f;
514        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n_f;
515        let std_dev = variance.sqrt();
516        if std_dev == 0.0 {
517            return None;
518        }
519        let skew = vals.iter().map(|v| ((v - mean) / std_dev).powi(3)).sum::<f64>() / n_f;
520        Some(skew)
521    }
522
523    /// Excess kurtosis of the window values: `(Σ((x−mean)⁴/n) / std_dev⁴) − 3`.
524    ///
525    /// Positive values indicate heavier-tailed distributions (leptokurtic);
526    /// negative values indicate lighter tails (platykurtic). A normal
527    /// distribution has excess kurtosis of `0`.
528    ///
529    /// Returns `None` if the window has fewer than 4 observations or if the
530    /// standard deviation is zero (all values identical).
531    pub fn kurtosis(&self) -> Option<f64> {
532        use rust_decimal::prelude::ToPrimitive;
533        let n = self.window.len();
534        if n < 4 {
535            return None;
536        }
537        let n_f = n as f64;
538        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
539        if vals.len() < n {
540            return None;
541        }
542        let mean = vals.iter().sum::<f64>() / n_f;
543        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n_f;
544        let std_dev = variance.sqrt();
545        if std_dev == 0.0 {
546            return None;
547        }
548        let kurt = vals.iter().map(|v| ((v - mean) / std_dev).powi(4)).sum::<f64>() / n_f - 3.0;
549        Some(kurt)
550    }
551
552    /// Median of the current window values, or `None` if the window is empty.
553    ///
554    /// For an even-length window the median is the mean of the two middle values.
555    pub fn median(&self) -> Option<Decimal> {
556        if self.window.is_empty() {
557            return None;
558        }
559        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
560        sorted.sort();
561        let n = sorted.len();
562        if n % 2 == 1 {
563            Some(sorted[n / 2])
564        } else {
565            Some((sorted[n / 2 - 1] + sorted[n / 2]) / Decimal::from(2u64))
566        }
567    }
568
569    /// Bessel-corrected (sample) variance of the window — divides by `n − 1`.
570    ///
571    /// Returns `None` for fewer than 2 observations.
572    pub fn sample_variance(&self) -> Option<f64> {
573        use rust_decimal::prelude::ToPrimitive;
574        let n = self.window.len();
575        if n < 2 {
576            return None;
577        }
578        let mean = self.mean()?.to_f64()?;
579        let sum_sq: f64 = self.window.iter()
580            .filter_map(|v| v.to_f64())
581            .map(|v| (v - mean).powi(2))
582            .sum();
583        Some(sum_sq / (n - 1) as f64)
584    }
585
586    /// Median absolute deviation (MAD) of the window.
587    ///
588    /// Returns `None` if the window is empty.
589    pub fn mad(&self) -> Option<Decimal> {
590        let med = self.median()?;
591        let mut deviations: Vec<Decimal> = self.window.iter()
592            .map(|&v| (v - med).abs())
593            .collect();
594        deviations.sort();
595        let n = deviations.len();
596        if n % 2 == 1 {
597            Some(deviations[n / 2])
598        } else {
599            Some((deviations[n / 2 - 1] + deviations[n / 2]) / Decimal::from(2u64))
600        }
601    }
602
603    /// Robust z-score of `value` using median and MAD instead of mean and std-dev.
604    ///
605    /// `robust_z = 0.6745 × (value − median) / MAD`
606    ///
607    /// The `0.6745` factor makes the scale consistent with the standard normal.
608    /// Returns `None` if the window is empty or MAD is zero (all values identical).
609    pub fn robust_z_score(&self, value: Decimal) -> Option<f64> {
610        use rust_decimal::prelude::ToPrimitive;
611        let med = self.median()?;
612        let mad = self.mad()?;
613        if mad.is_zero() {
614            return None;
615        }
616        let diff = (value - med) / mad;
617        Some(0.674_5 * diff.to_f64()?)
618    }
619
620    /// The most recently added value, or `None` if the window is empty.
621    pub fn latest(&self) -> Option<Decimal> {
622        self.window.back().copied()
623    }
624
625    /// Sum of all values currently in the window.
626    ///
627    /// Returns `None` if the window is empty.
628    pub fn sum(&self) -> Option<Decimal> {
629        if self.window.is_empty() {
630            return None;
631        }
632        Some(self.window.iter().copied().sum())
633    }
634
635    /// Returns `true` if the absolute z-score of `value` exceeds `z_threshold`.
636    pub fn is_outlier(&self, value: Decimal, z_threshold: f64) -> bool {
637        self.z_score(value).map_or(false, |z| z.abs() > z_threshold)
638    }
639
640    /// Returns a copy of the window values that fall within `sigma` standard
641    /// deviations of the mean. Values whose absolute z-score exceeds `sigma`
642    /// are excluded.
643    ///
644    /// Returns an empty `Vec` if the window has fewer than 2 elements.
645    pub fn trim_outliers(&self, sigma: f64) -> Vec<Decimal> {
646        self.window
647            .iter()
648            .copied()
649            .filter(|&v| !self.is_outlier(v, sigma))
650            .collect()
651    }
652
653    /// Z-score of the most recently added value.
654    ///
655    /// Returns `None` if the window has fewer than 2 elements or standard
656    /// deviation is zero.
657    pub fn z_score_of_latest(&self) -> Option<f64> {
658        self.z_score(self.latest()?)
659    }
660
661    /// Signed deviation of `value` from the window mean, expressed as a
662    /// floating-point number.
663    ///
664    /// Returns `None` if the window is empty.
665    pub fn deviation_from_mean(&self, value: Decimal) -> Option<f64> {
666        use rust_decimal::prelude::ToPrimitive;
667        let mean = self.mean()?;
668        (value - mean).to_f64()
669    }
670
671    /// Window range (max − min) as a `f64`.
672    ///
673    /// Returns `None` if the window has fewer than 1 element.
674    pub fn range_f64(&mut self) -> Option<f64> {
675        use rust_decimal::prelude::ToPrimitive;
676        self.range()?.to_f64()
677    }
678
679    /// Sum of all values in the window as a `f64`.
680    ///
681    /// Returns `None` if the window is empty.
682    pub fn sum_f64(&self) -> Option<f64> {
683        use rust_decimal::prelude::ToPrimitive;
684        self.sum()?.to_f64()
685    }
686
687    /// All current window values as a `Vec<Decimal>`, in insertion order (oldest first).
688    pub fn values(&self) -> Vec<Decimal> {
689        self.window.iter().copied().collect()
690    }
691
692    /// Normalize the window midpoint ((min + max) / 2) and return it as a `f64`.
693    ///
694    /// Returns `None` if the window is empty or min == max.
695    pub fn normalized_midpoint(&mut self) -> Option<f64> {
696        let mid = self.midpoint()?;
697        self.normalize(mid).ok()
698    }
699
700    /// Returns `true` if `value` equals the current window minimum.
701    ///
702    /// Returns `false` if the window is empty.
703    pub fn is_at_min(&mut self, value: Decimal) -> bool {
704        self.min().map_or(false, |m| value == m)
705    }
706
707    /// Returns `true` if `value` equals the current window maximum.
708    ///
709    /// Returns `false` if the window is empty.
710    pub fn is_at_max(&mut self, value: Decimal) -> bool {
711        self.max().map_or(false, |m| value == m)
712    }
713
714    /// Fraction of window values strictly above `threshold`.
715    ///
716    /// Returns `None` if the window is empty.
717    pub fn fraction_above(&self, threshold: Decimal) -> Option<f64> {
718        if self.window.is_empty() {
719            return None;
720        }
721        Some(self.count_above(threshold) as f64 / self.window.len() as f64)
722    }
723
724    /// Fraction of window values strictly below `threshold`.
725    ///
726    /// Returns `None` if the window is empty.
727    pub fn fraction_below(&self, threshold: Decimal) -> Option<f64> {
728        if self.window.is_empty() {
729            return None;
730        }
731        Some(self.count_below(threshold) as f64 / self.window.len() as f64)
732    }
733
734    /// Returns all window values strictly above `threshold`.
735    pub fn window_values_above(&self, threshold: Decimal) -> Vec<Decimal> {
736        self.window.iter().copied().filter(|&v| v > threshold).collect()
737    }
738
739    /// Returns all window values strictly below `threshold`.
740    pub fn window_values_below(&self, threshold: Decimal) -> Vec<Decimal> {
741        self.window.iter().copied().filter(|&v| v < threshold).collect()
742    }
743
744    /// Count of window values equal to `value`.
745    pub fn count_equal(&self, value: Decimal) -> usize {
746        self.window.iter().filter(|&&v| v == value).count()
747    }
748
749    /// Range of the current window: `max - min`.
750    ///
751    /// Returns `None` if the window is empty.
752    pub fn rolling_range(&self) -> Option<Decimal> {
753        if self.window.is_empty() {
754            return None;
755        }
756        let lo = self.window.iter().copied().reduce(Decimal::min)?;
757        let hi = self.window.iter().copied().reduce(Decimal::max)?;
758        Some(hi - lo)
759    }
760
761    /// Lag-1 autocorrelation of the window values.
762    ///
763    /// Measures how much each value predicts the next.
764    /// Returns `None` if fewer than 2 values or variance is zero.
765    pub fn autocorrelation_lag1(&self) -> Option<f64> {
766        use rust_decimal::prelude::ToPrimitive;
767        let n = self.window.len();
768        if n < 2 {
769            return None;
770        }
771        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
772        if vals.len() < 2 {
773            return None;
774        }
775        let mean = vals.iter().sum::<f64>() / vals.len() as f64;
776        let var: f64 = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / vals.len() as f64;
777        if var == 0.0 {
778            return None;
779        }
780        let cov: f64 = vals.windows(2).map(|w| (w[0] - mean) * (w[1] - mean)).sum::<f64>()
781            / (vals.len() - 1) as f64;
782        Some(cov / var)
783    }
784
785    /// Fraction of consecutive pairs where the second value > first (trending upward).
786    ///
787    /// Returns `None` if fewer than 2 values.
788    pub fn trend_consistency(&self) -> Option<f64> {
789        let n = self.window.len();
790        if n < 2 {
791            return None;
792        }
793        let up = self.window.iter().collect::<Vec<_>>().windows(2)
794            .filter(|w| w[1] > w[0]).count();
795        Some(up as f64 / (n - 1) as f64)
796    }
797
798    /// Mean absolute deviation of the window values.
799    ///
800    /// Returns `None` if window is empty.
801    pub fn mean_absolute_deviation(&self) -> Option<f64> {
802        use rust_decimal::prelude::ToPrimitive;
803        if self.window.is_empty() {
804            return None;
805        }
806        let n = self.window.len();
807        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
808        let mean = vals.iter().sum::<f64>() / n as f64;
809        let mad = vals.iter().map(|v| (v - mean).abs()).sum::<f64>() / n as f64;
810        Some(mad)
811    }
812
813    /// Percentile rank of the most recently added value within the window.
814    ///
815    /// Returns `None` if no value has been added yet. Uses the same `<=`
816    /// semantics as [`percentile_rank`](Self::percentile_rank).
817    pub fn percentile_of_latest(&self) -> Option<f64> {
818        let latest = self.latest()?;
819        self.percentile_rank(latest)
820    }
821
822    /// Tail ratio: `max(window) / 75th-percentile(window)`.
823    ///
824    /// A simple fat-tail indicator. Values well above 1.0 signal that the
825    /// maximum observation is an outlier relative to the upper quartile.
826    /// Returns `None` if the window is empty or the 75th percentile is zero.
827    pub fn tail_ratio(&self) -> Option<f64> {
828        use rust_decimal::prelude::ToPrimitive;
829        let max = self.window.iter().copied().reduce(Decimal::max)?;
830        let p75 = self.percentile_value(0.75)?;
831        if p75.is_zero() {
832            return None;
833        }
834        (max / p75).to_f64()
835    }
836
837    /// Z-score of the window minimum relative to the current mean and std dev.
838    ///
839    /// Returns `None` if the window is empty or std dev is zero.
840    pub fn z_score_of_min(&self) -> Option<f64> {
841        let min = self.window.iter().copied().reduce(Decimal::min)?;
842        self.z_score(min)
843    }
844
845    /// Z-score of the window maximum relative to the current mean and std dev.
846    ///
847    /// Returns `None` if the window is empty or std dev is zero.
848    pub fn z_score_of_max(&self) -> Option<f64> {
849        let max = self.window.iter().copied().reduce(Decimal::max)?;
850        self.z_score(max)
851    }
852
853    /// Shannon entropy of the window values.
854    ///
855    /// Each unique value is treated as a category. Returns `None` if the
856    /// window is empty. A uniform distribution maximises entropy; identical
857    /// values give `Some(0.0)`.
858    pub fn window_entropy(&self) -> Option<f64> {
859        if self.window.is_empty() {
860            return None;
861        }
862        let n = self.window.len() as f64;
863        let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
864        for v in &self.window {
865            *counts.entry(v.to_string()).or_insert(0) += 1;
866        }
867        let entropy: f64 = counts.values().map(|&c| {
868            let p = c as f64 / n;
869            -p * p.ln()
870        }).sum();
871        Some(entropy)
872    }
873
874    /// Normalised standard deviation (alias for [`coefficient_of_variation`](Self::coefficient_of_variation)).
875    pub fn normalized_std_dev(&self) -> Option<f64> {
876        self.coefficient_of_variation()
877    }
878
879    /// Count of window values that are strictly above the window mean.
880    ///
881    /// Returns `None` if the window is empty or the mean cannot be computed.
882    pub fn value_above_mean_count(&self) -> Option<usize> {
883        let mean = self.mean()?;
884        Some(self.window.iter().filter(|&&v| v > mean).count())
885    }
886
887    /// Length of the longest consecutive run of values above the window mean.
888    ///
889    /// Returns `None` if the window is empty or the mean cannot be computed.
890    pub fn consecutive_above_mean(&self) -> Option<usize> {
891        let mean = self.mean()?;
892        let mut max_run = 0usize;
893        let mut current = 0usize;
894        for &v in &self.window {
895            if v > mean {
896                current += 1;
897                if current > max_run {
898                    max_run = current;
899                }
900            } else {
901                current = 0;
902            }
903        }
904        Some(max_run)
905    }
906
907    /// Fraction of window values above `threshold`.
908    ///
909    /// Returns `None` if the window is empty.
910    pub fn above_threshold_fraction(&self, threshold: Decimal) -> Option<f64> {
911        if self.window.is_empty() {
912            return None;
913        }
914        let count = self.window.iter().filter(|&&v| v > threshold).count();
915        Some(count as f64 / self.window.len() as f64)
916    }
917
918    /// Fraction of window values below `threshold`.
919    ///
920    /// Returns `None` if the window is empty.
921    pub fn below_threshold_fraction(&self, threshold: Decimal) -> Option<f64> {
922        if self.window.is_empty() {
923            return None;
924        }
925        let count = self.window.iter().filter(|&&v| v < threshold).count();
926        Some(count as f64 / self.window.len() as f64)
927    }
928
929    /// Autocorrelation at lag `k` of the window values.
930    ///
931    /// Returns `None` if `k == 0`, `k >= window.len()`, or variance is zero.
932    pub fn lag_k_autocorrelation(&self, k: usize) -> Option<f64> {
933        use rust_decimal::prelude::ToPrimitive;
934        let n = self.window.len();
935        if k == 0 || k >= n {
936            return None;
937        }
938        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
939        if vals.len() != n {
940            return None;
941        }
942        let mean = vals.iter().sum::<f64>() / n as f64;
943        let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
944        if var == 0.0 {
945            return None;
946        }
947        let m = n - k;
948        let cov: f64 = (0..m).map(|i| (vals[i] - mean) * (vals[i + k] - mean)).sum::<f64>() / m as f64;
949        Some(cov / var)
950    }
951
952    /// Estimated half-life of mean reversion using a simple AR(1) regression.
953    ///
954    /// Half-life ≈ `-ln(2) / ln(|β|)` where β is the AR(1) coefficient from
955    /// regressing `Δy_t` on `y_{t-1}`. Returns `None` if the window has fewer
956    /// than 3 values, the regression denominator is zero, or β ≥ 0 (no
957    /// mean-reversion signal).
958    pub fn half_life_estimate(&self) -> Option<f64> {
959        use rust_decimal::prelude::ToPrimitive;
960        let n = self.window.len();
961        if n < 3 {
962            return None;
963        }
964        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
965        if vals.len() != n {
966            return None;
967        }
968        let diffs: Vec<f64> = vals.windows(2).map(|w| w[1] - w[0]).collect();
969        let lagged: Vec<f64> = vals[..n - 1].to_vec();
970        let nf = diffs.len() as f64;
971        let mean_l = lagged.iter().sum::<f64>() / nf;
972        let mean_d = diffs.iter().sum::<f64>() / nf;
973        let cov: f64 = lagged.iter().zip(diffs.iter()).map(|(l, d)| (l - mean_l) * (d - mean_d)).sum::<f64>();
974        let var: f64 = lagged.iter().map(|l| (l - mean_l).powi(2)).sum::<f64>();
975        if var == 0.0 {
976            return None;
977        }
978        let beta = cov / var;
979        // beta should be negative for mean-reversion
980        if beta >= 0.0 {
981            return None;
982        }
983        let lambda = (1.0 + beta).abs().ln();
984        if lambda == 0.0 {
985            return None;
986        }
987        Some(-std::f64::consts::LN_2 / lambda)
988    }
989
990    /// Geometric mean of the window values.
991    ///
992    /// `exp(mean(ln(v_i)))`. Returns `None` if the window is empty or any
993    /// value is non-positive.
994    pub fn geometric_mean(&self) -> Option<f64> {
995        use rust_decimal::prelude::ToPrimitive;
996        if self.window.is_empty() {
997            return None;
998        }
999        let logs: Vec<f64> = self.window.iter()
1000            .filter_map(|v| v.to_f64())
1001            .filter_map(|f| if f > 0.0 { Some(f.ln()) } else { None })
1002            .collect();
1003        if logs.len() != self.window.len() {
1004            return None;
1005        }
1006        Some((logs.iter().sum::<f64>() / logs.len() as f64).exp())
1007    }
1008
1009    /// Harmonic mean of the window values.
1010    ///
1011    /// `n / sum(1/v_i)`. Returns `None` if the window is empty or any value
1012    /// is zero.
1013    pub fn harmonic_mean(&self) -> Option<f64> {
1014        use rust_decimal::prelude::ToPrimitive;
1015        if self.window.is_empty() {
1016            return None;
1017        }
1018        let reciprocals: Vec<f64> = self.window.iter()
1019            .filter_map(|v| v.to_f64())
1020            .filter_map(|f| if f != 0.0 { Some(1.0 / f) } else { None })
1021            .collect();
1022        if reciprocals.len() != self.window.len() {
1023            return None;
1024        }
1025        let n = reciprocals.len() as f64;
1026        Some(n / reciprocals.iter().sum::<f64>())
1027    }
1028
1029    /// Normalise `value` to the window's observed `[min, max]` range.
1030    ///
1031    /// Returns `None` if the window is empty or the range is zero.
1032    pub fn range_normalized_value(&self, value: Decimal) -> Option<f64> {
1033        use rust_decimal::prelude::ToPrimitive;
1034        let min = self.window.iter().copied().reduce(Decimal::min)?;
1035        let max = self.window.iter().copied().reduce(Decimal::max)?;
1036        let range = max - min;
1037        if range.is_zero() {
1038            return None;
1039        }
1040        ((value - min) / range).to_f64()
1041    }
1042
1043    /// Signed distance of `value` from the window median.
1044    ///
1045    /// `value - median`. Returns `None` if the window is empty.
1046    pub fn distance_from_median(&self, value: Decimal) -> Option<f64> {
1047        use rust_decimal::prelude::ToPrimitive;
1048        let med = self.median()?;
1049        (value - med).to_f64()
1050    }
1051
1052    /// Momentum: difference between the latest and oldest value in the window.
1053    ///
1054    /// Positive = window trended up; negative = trended down. Returns `None`
1055    /// if fewer than 2 values are in the window.
1056    pub fn momentum(&self) -> Option<f64> {
1057        use rust_decimal::prelude::ToPrimitive;
1058        if self.window.len() < 2 {
1059            return None;
1060        }
1061        let oldest = *self.window.front()?;
1062        let latest = *self.window.back()?;
1063        (latest - oldest).to_f64()
1064    }
1065
1066    /// Rank of `value` within the current window, normalised to `[0.0, 1.0]`.
1067    ///
1068    /// 0.0 means `value` is ≤ all window values; 1.0 means it is ≥ all window
1069    /// values. Returns `None` if the window is empty.
1070    pub fn value_rank(&self, value: Decimal) -> Option<f64> {
1071        if self.window.is_empty() {
1072            return None;
1073        }
1074        let n = self.window.len();
1075        let below = self.window.iter().filter(|&&v| v < value).count();
1076        Some(below as f64 / n as f64)
1077    }
1078
1079    /// Coefficient of variation: `std_dev / |mean|`.
1080    ///
1081    /// Returns `None` if the window has fewer than 2 values or the mean is
1082    /// zero.
1083    pub fn coeff_of_variation(&self) -> Option<f64> {
1084        use rust_decimal::prelude::ToPrimitive;
1085        let n = self.window.len();
1086        if n < 2 {
1087            return None;
1088        }
1089        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
1090        if vals.len() < 2 {
1091            return None;
1092        }
1093        let nf = vals.len() as f64;
1094        let mean = vals.iter().sum::<f64>() / nf;
1095        if mean == 0.0 {
1096            return None;
1097        }
1098        let std_dev = (vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (nf - 1.0)).sqrt();
1099        Some(std_dev / mean.abs())
1100    }
1101
1102    /// Inter-quartile range: Q3 (75th percentile) minus Q1 (25th percentile).
1103    ///
1104    /// Returns `None` if the window is empty.
1105    pub fn quantile_range(&self) -> Option<f64> {
1106        use rust_decimal::prelude::ToPrimitive;
1107        let q3 = self.percentile_value(0.75)?;
1108        let q1 = self.percentile_value(0.25)?;
1109        (q3 - q1).to_f64()
1110    }
1111
1112    /// Upper quartile (Q3, 75th percentile) of the window values.
1113    ///
1114    /// Returns `None` if the window is empty.
1115    pub fn upper_quartile(&self) -> Option<Decimal> {
1116        self.percentile_value(0.75)
1117    }
1118
1119    /// Lower quartile (Q1, 25th percentile) of the window values.
1120    ///
1121    /// Returns `None` if the window is empty.
1122    pub fn lower_quartile(&self) -> Option<Decimal> {
1123        self.percentile_value(0.25)
1124    }
1125
1126    /// Fraction of consecutive first-difference pairs whose sign flips.
1127    ///
1128    /// Computes `Δx_i = x_i − x_{i−1}`, then counts pairs `(Δx_i, Δx_{i+1})`
1129    /// where both are non-zero and have opposite signs. The result is in
1130    /// `[0.0, 1.0]`. A high value indicates a rapidly oscillating series;
1131    /// a low value indicates persistent trends. Returns `None` for fewer than
1132    /// 3 observations.
1133    pub fn sign_change_rate(&self) -> Option<f64> {
1134        let n = self.window.len();
1135        if n < 3 {
1136            return None;
1137        }
1138        let vals: Vec<&Decimal> = self.window.iter().collect();
1139        let diffs: Vec<i32> = vals
1140            .windows(2)
1141            .map(|w| {
1142                if w[1] > w[0] { 1 } else if w[1] < w[0] { -1 } else { 0 }
1143            })
1144            .collect();
1145        let total_pairs = (diffs.len() - 1) as f64;
1146        if total_pairs == 0.0 {
1147            return None;
1148        }
1149        let changes = diffs
1150            .windows(2)
1151            .filter(|w| w[0] != 0 && w[1] != 0 && w[0] != w[1])
1152            .count();
1153        Some(changes as f64 / total_pairs)
1154    }
1155
1156    // ── round-79 ─────────────────────────────────────────────────────────────
1157
1158    /// Count of trailing values (from the newest end of the window) that
1159    /// are strictly below the window mean.
1160    ///
1161    /// Returns `None` if the window has fewer than 2 values.
1162    pub fn consecutive_below_mean(&self) -> Option<usize> {
1163        if self.window.len() < 2 {
1164            return None;
1165        }
1166        let mean = self.mean()?;
1167        let count = self.window.iter().rev().take_while(|&&v| v < mean).count();
1168        Some(count)
1169    }
1170
1171    /// Drift rate: `(mean_of_second_half − mean_of_first_half) / |mean_of_first_half|`.
1172    ///
1173    /// Splits the window at its midpoint and compares the two halves.
1174    /// Returns `None` if the window has fewer than 2 values or the
1175    /// first-half mean is zero.
1176    pub fn drift_rate(&self) -> Option<f64> {
1177        use rust_decimal::prelude::ToPrimitive;
1178        let n = self.window.len();
1179        if n < 2 {
1180            return None;
1181        }
1182        let mid = n / 2;
1183        let first_sum: Decimal = self.window.iter().take(mid).copied().sum();
1184        let second_sum: Decimal = self.window.iter().skip(mid).copied().sum();
1185        let mean1 = first_sum / Decimal::from(mid as i64);
1186        let mean2 = second_sum / Decimal::from((n - mid) as i64);
1187        if mean1.is_zero() {
1188            return None;
1189        }
1190        ((mean2 - mean1) / mean1.abs()).to_f64()
1191    }
1192
1193    /// Ratio of the window maximum to the window minimum: `max / min`.
1194    ///
1195    /// Returns `None` if the window is empty or the minimum is zero.
1196    pub fn peak_to_trough_ratio(&self) -> Option<f64> {
1197        use rust_decimal::prelude::ToPrimitive;
1198        let mut tmp = MinMaxNormalizer::new(self.window_size).ok()?;
1199        for &v in &self.window {
1200            tmp.update(v);
1201        }
1202        let (min, max) = tmp.min_max()?;
1203        if min.is_zero() {
1204            return None;
1205        }
1206        (max / min).to_f64()
1207    }
1208
1209    /// Normalised deviation of the latest value: `(latest − mean) / range`.
1210    ///
1211    /// Returns `None` if the window is empty, has fewer than 2 values,
1212    /// the range is zero, or there is no latest value.
1213    pub fn normalized_deviation(&self) -> Option<f64> {
1214        use rust_decimal::prelude::ToPrimitive;
1215        let latest = self.latest()?;
1216        let mean = self.mean()?;
1217        let range = self.rolling_range()?;
1218        if range.is_zero() {
1219            return None;
1220        }
1221        ((latest - mean) / range).to_f64()
1222    }
1223
1224    /// Coefficient of variation as a percentage: `(std_dev / |mean|) × 100`.
1225    ///
1226    /// Returns `None` if the window has fewer than 2 values or the mean is zero.
1227    pub fn window_cv_pct(&self) -> Option<f64> {
1228        let cv = self.coefficient_of_variation()?;
1229        Some(cv * 100.0)
1230    }
1231
1232    /// Rank of the latest value in the sorted window as a fraction in
1233    /// `[0, 1]`. Computed as `(number of values strictly below latest) /
1234    /// (window_len − 1)`.
1235    ///
1236    /// Returns `None` if the window has fewer than 2 values.
1237    pub fn latest_rank_pct(&self) -> Option<f64> {
1238        if self.window.len() < 2 {
1239            return None;
1240        }
1241        let latest = self.latest()?;
1242        let below = self.window.iter().filter(|&&v| v < latest).count();
1243        Some(below as f64 / (self.window.len() - 1) as f64)
1244    }
1245
1246    // ── round-80 ─────────────────────────────────────────────────────────────
1247
1248    /// Trimmed mean: arithmetic mean after discarding the bottom and top
1249    /// `p` fraction of window values.
1250    ///
1251    /// `p` is clamped to `[0.0, 0.499]`. Returns `None` if the window is
1252    /// empty or trimming removes all observations.
1253    pub fn trimmed_mean(&self, p: f64) -> Option<f64> {
1254        use rust_decimal::prelude::ToPrimitive;
1255        if self.window.is_empty() {
1256            return None;
1257        }
1258        let p = p.clamp(0.0, 0.499);
1259        let mut sorted: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
1260        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1261        let n = sorted.len();
1262        let trim = (n as f64 * p).floor() as usize;
1263        let trimmed = &sorted[trim..n - trim];
1264        if trimmed.is_empty() {
1265            return None;
1266        }
1267        Some(trimmed.iter().sum::<f64>() / trimmed.len() as f64)
1268    }
1269
1270    /// OLS linear trend slope of window values over their insertion index.
1271    ///
1272    /// A positive slope indicates an upward trend; negative indicates downward.
1273    /// Returns `None` if the window has fewer than 2 observations.
1274    pub fn linear_trend_slope(&self) -> Option<f64> {
1275        use rust_decimal::prelude::ToPrimitive;
1276        let n = self.window.len();
1277        if n < 2 {
1278            return None;
1279        }
1280        let n_f = n as f64;
1281        let x_mean = (n_f - 1.0) / 2.0;
1282        let y_vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
1283        if y_vals.len() < 2 {
1284            return None;
1285        }
1286        let y_mean = y_vals.iter().sum::<f64>() / y_vals.len() as f64;
1287        let numerator: f64 = y_vals
1288            .iter()
1289            .enumerate()
1290            .map(|(i, &y)| (i as f64 - x_mean) * (y - y_mean))
1291            .sum();
1292        let denominator: f64 = (0..n).map(|i| (i as f64 - x_mean).powi(2)).sum();
1293        if denominator == 0.0 {
1294            return None;
1295        }
1296        Some(numerator / denominator)
1297    }
1298
1299}
1300
1301#[cfg(test)]
1302mod tests {
1303    use super::*;
1304    use rust_decimal_macros::dec;
1305
1306    fn norm(w: usize) -> MinMaxNormalizer {
1307        MinMaxNormalizer::new(w).unwrap()
1308    }
1309
1310    // ── Construction ─────────────────────────────────────────────────────────
1311
1312    #[test]
1313    fn test_new_normalizer_is_empty() {
1314        let n = norm(4);
1315        assert!(n.is_empty());
1316        assert_eq!(n.len(), 0);
1317    }
1318
1319    #[test]
1320    fn test_minmax_is_full_false_before_capacity() {
1321        let mut n = norm(3);
1322        assert!(!n.is_full());
1323        n.update(dec!(1));
1324        n.update(dec!(2));
1325        assert!(!n.is_full());
1326        n.update(dec!(3));
1327        assert!(n.is_full());
1328    }
1329
1330    #[test]
1331    fn test_minmax_is_full_stays_true_after_eviction() {
1332        let mut n = norm(3);
1333        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
1334            n.update(v);
1335        }
1336        assert!(n.is_full()); // window stays at capacity after eviction
1337    }
1338
1339    #[test]
1340    fn test_new_zero_window_returns_error() {
1341        let result = MinMaxNormalizer::new(0);
1342        assert!(matches!(result, Err(StreamError::ConfigError { .. })));
1343    }
1344
1345    // ── Normalization range [0, 1] ────────────────────────────────────────────
1346
1347    #[test]
1348    fn test_normalize_min_is_zero() {
1349        let mut n = norm(4);
1350        n.update(dec!(10));
1351        n.update(dec!(20));
1352        n.update(dec!(30));
1353        n.update(dec!(40));
1354        let v = n.normalize(dec!(10)).unwrap();
1355        assert!(
1356            (v - 0.0).abs() < 1e-10,
1357            "min should normalize to 0.0, got {v}"
1358        );
1359    }
1360
1361    #[test]
1362    fn test_normalize_max_is_one() {
1363        let mut n = norm(4);
1364        n.update(dec!(10));
1365        n.update(dec!(20));
1366        n.update(dec!(30));
1367        n.update(dec!(40));
1368        let v = n.normalize(dec!(40)).unwrap();
1369        assert!(
1370            (v - 1.0).abs() < 1e-10,
1371            "max should normalize to 1.0, got {v}"
1372        );
1373    }
1374
1375    #[test]
1376    fn test_normalize_midpoint_is_half() {
1377        let mut n = norm(4);
1378        n.update(dec!(0));
1379        n.update(dec!(100));
1380        let v = n.normalize(dec!(50)).unwrap();
1381        assert!((v - 0.5).abs() < 1e-10);
1382    }
1383
1384    #[test]
1385    fn test_normalize_result_clamped_below_zero() {
1386        let mut n = norm(4);
1387        n.update(dec!(50));
1388        n.update(dec!(100));
1389        // 10 is below the window min of 50
1390        let v = n.normalize(dec!(10)).unwrap();
1391        assert!(v >= 0.0);
1392        assert_eq!(v, 0.0);
1393    }
1394
1395    #[test]
1396    fn test_normalize_result_clamped_above_one() {
1397        let mut n = norm(4);
1398        n.update(dec!(50));
1399        n.update(dec!(100));
1400        // 200 is above the window max of 100
1401        let v = n.normalize(dec!(200)).unwrap();
1402        assert!(v <= 1.0);
1403        assert_eq!(v, 1.0);
1404    }
1405
1406    #[test]
1407    fn test_normalize_all_same_values_returns_zero() {
1408        let mut n = norm(4);
1409        n.update(dec!(5));
1410        n.update(dec!(5));
1411        n.update(dec!(5));
1412        let v = n.normalize(dec!(5)).unwrap();
1413        assert_eq!(v, 0.0);
1414    }
1415
1416    // ── Empty window error ────────────────────────────────────────────────────
1417
1418    #[test]
1419    fn test_normalize_empty_window_returns_error() {
1420        let mut n = norm(4);
1421        let err = n.normalize(dec!(1)).unwrap_err();
1422        assert!(matches!(err, StreamError::NormalizationError { .. }));
1423    }
1424
1425    #[test]
1426    fn test_min_max_empty_returns_none() {
1427        let mut n = norm(4);
1428        assert!(n.min_max().is_none());
1429    }
1430
1431    // ── Rolling window eviction ───────────────────────────────────────────────
1432
1433    /// After the window fills and the minimum is evicted, the new min must
1434    /// reflect the remaining values.
1435    #[test]
1436    fn test_rolling_window_evicts_oldest() {
1437        let mut n = norm(3);
1438        n.update(dec!(1)); // will be evicted
1439        n.update(dec!(5));
1440        n.update(dec!(10));
1441        n.update(dec!(20)); // evicts 1
1442        let (min, max) = n.min_max().unwrap();
1443        assert_eq!(min, dec!(5));
1444        assert_eq!(max, dec!(20));
1445    }
1446
1447    #[test]
1448    fn test_rolling_window_len_does_not_exceed_capacity() {
1449        let mut n = norm(3);
1450        for i in 0..10 {
1451            n.update(Decimal::from(i));
1452        }
1453        assert_eq!(n.len(), 3);
1454    }
1455
1456    // ── Reset behavior ────────────────────────────────────────────────────────
1457
1458    #[test]
1459    fn test_reset_clears_window() {
1460        let mut n = norm(4);
1461        n.update(dec!(10));
1462        n.update(dec!(20));
1463        n.reset();
1464        assert!(n.is_empty());
1465        assert!(n.min_max().is_none());
1466    }
1467
1468    #[test]
1469    fn test_normalize_works_after_reset() {
1470        let mut n = norm(4);
1471        n.update(dec!(10));
1472        n.reset();
1473        n.update(dec!(0));
1474        n.update(dec!(100));
1475        let v = n.normalize(dec!(100)).unwrap();
1476        assert!((v - 1.0).abs() < 1e-10);
1477    }
1478
1479    // ── Streaming update ──────────────────────────────────────────────────────
1480
1481    #[test]
1482    fn test_streaming_updates_monotone_sequence() {
1483        let mut n = norm(5);
1484        let prices = [dec!(100), dec!(101), dec!(102), dec!(103), dec!(104), dec!(105)];
1485        for &p in &prices {
1486            n.update(p);
1487        }
1488        // Window now holds [101, 102, 103, 104, 105]; min=101, max=105
1489        let v_min = n.normalize(dec!(101)).unwrap();
1490        let v_max = n.normalize(dec!(105)).unwrap();
1491        assert!((v_min - 0.0).abs() < 1e-10);
1492        assert!((v_max - 1.0).abs() < 1e-10);
1493    }
1494
1495    #[test]
1496    fn test_normalization_monotonicity_in_window() {
1497        let mut n = norm(10);
1498        for i in 0..10 {
1499            n.update(Decimal::from(i * 10));
1500        }
1501        // Values 0, 10, 20, ..., 90 in window; min=0, max=90
1502        let v0 = n.normalize(dec!(0)).unwrap();
1503        let v50 = n.normalize(dec!(50)).unwrap();
1504        let v90 = n.normalize(dec!(90)).unwrap();
1505        assert!(v0 < v50, "normalized values should be monotone");
1506        assert!(v50 < v90, "normalized values should be monotone");
1507    }
1508
1509    #[test]
1510    fn test_high_precision_input_preserved() {
1511        // Verify that a value like 50000.12345678 is handled without f64 loss.
1512        let mut n = norm(2);
1513        n.update(dec!(50000.00000000));
1514        n.update(dec!(50000.12345678));
1515        let (min, max) = n.min_max().unwrap();
1516        assert_eq!(min, dec!(50000.00000000));
1517        assert_eq!(max, dec!(50000.12345678));
1518    }
1519
1520    // ── denormalize ───────────────────────────────────────────────────────────
1521
1522    #[test]
1523    fn test_denormalize_empty_window_returns_error() {
1524        let mut n = norm(4);
1525        assert!(matches!(n.denormalize(0.5), Err(StreamError::NormalizationError { .. })));
1526    }
1527
1528    #[test]
1529    fn test_denormalize_roundtrip_min() {
1530        let mut n = norm(4);
1531        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
1532            n.update(v);
1533        }
1534        let normalized = n.normalize(dec!(10)).unwrap(); // should be ~0.0
1535        let back = n.denormalize(normalized).unwrap();
1536        assert!((back - dec!(10)).abs() < dec!(0.0001));
1537    }
1538
1539    #[test]
1540    fn test_denormalize_roundtrip_max() {
1541        let mut n = norm(4);
1542        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
1543            n.update(v);
1544        }
1545        let normalized = n.normalize(dec!(40)).unwrap(); // should be ~1.0
1546        let back = n.denormalize(normalized).unwrap();
1547        assert!((back - dec!(40)).abs() < dec!(0.0001));
1548    }
1549
1550    // ── range ─────────────────────────────────────────────────────────────────
1551
1552    #[test]
1553    fn test_range_none_when_empty() {
1554        let mut n = norm(4);
1555        assert!(n.range().is_none());
1556    }
1557
1558    #[test]
1559    fn test_range_zero_when_all_same() {
1560        let mut n = norm(3);
1561        n.update(dec!(5));
1562        n.update(dec!(5));
1563        n.update(dec!(5));
1564        assert_eq!(n.range(), Some(dec!(0)));
1565    }
1566
1567    #[test]
1568    fn test_range_correct() {
1569        let mut n = norm(4);
1570        for v in [dec!(10), dec!(40), dec!(20), dec!(30)] {
1571            n.update(v);
1572        }
1573        assert_eq!(n.range(), Some(dec!(30))); // 40 - 10
1574    }
1575
1576    // ── MinMaxNormalizer::midpoint ────────────────────────────────────────────
1577
1578    #[test]
1579    fn test_midpoint_none_when_empty() {
1580        let mut n = norm(4);
1581        assert!(n.midpoint().is_none());
1582    }
1583
1584    #[test]
1585    fn test_midpoint_correct() {
1586        let mut n = norm(4);
1587        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
1588            n.update(v);
1589        }
1590        // min=10, max=40 → midpoint = 25
1591        assert_eq!(n.midpoint(), Some(dec!(25)));
1592    }
1593
1594    #[test]
1595    fn test_midpoint_single_value() {
1596        let mut n = norm(4);
1597        n.update(dec!(42));
1598        assert_eq!(n.midpoint(), Some(dec!(42)));
1599    }
1600
1601    // ── MinMaxNormalizer::clamp_to_window ─────────────────────────────────────
1602
1603    #[test]
1604    fn test_clamp_to_window_returns_value_unchanged_when_empty() {
1605        let mut n = norm(4);
1606        assert_eq!(n.clamp_to_window(dec!(50)), dec!(50));
1607    }
1608
1609    #[test]
1610    fn test_clamp_to_window_clamps_above_max() {
1611        let mut n = norm(4);
1612        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
1613        assert_eq!(n.clamp_to_window(dec!(100)), dec!(30));
1614    }
1615
1616    #[test]
1617    fn test_clamp_to_window_clamps_below_min() {
1618        let mut n = norm(4);
1619        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
1620        assert_eq!(n.clamp_to_window(dec!(5)), dec!(10));
1621    }
1622
1623    #[test]
1624    fn test_clamp_to_window_passthrough_when_in_range() {
1625        let mut n = norm(4);
1626        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
1627        assert_eq!(n.clamp_to_window(dec!(15)), dec!(15));
1628    }
1629
1630    // ── MinMaxNormalizer::count_above ─────────────────────────────────────────
1631
1632    #[test]
1633    fn test_count_above_zero_when_empty() {
1634        let n = norm(4);
1635        assert_eq!(n.count_above(dec!(5)), 0);
1636    }
1637
1638    #[test]
1639    fn test_count_above_counts_strictly_above() {
1640        let mut n = norm(8);
1641        for v in [dec!(1), dec!(5), dec!(10), dec!(15)] { n.update(v); }
1642        assert_eq!(n.count_above(dec!(5)), 2); // 10 and 15
1643    }
1644
1645    #[test]
1646    fn test_count_above_all_when_threshold_below_all() {
1647        let mut n = norm(4);
1648        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
1649        assert_eq!(n.count_above(dec!(5)), 3);
1650    }
1651
1652    #[test]
1653    fn test_count_above_zero_when_threshold_above_all() {
1654        let mut n = norm(4);
1655        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
1656        assert_eq!(n.count_above(dec!(100)), 0);
1657    }
1658
1659    // ── MinMaxNormalizer::count_below ─────────────────────────────────────────
1660
1661    #[test]
1662    fn test_count_below_zero_when_empty() {
1663        let n = norm(4);
1664        assert_eq!(n.count_below(dec!(5)), 0);
1665    }
1666
1667    #[test]
1668    fn test_count_below_counts_strictly_below() {
1669        let mut n = norm(8);
1670        for v in [dec!(1), dec!(5), dec!(10), dec!(15)] { n.update(v); }
1671        assert_eq!(n.count_below(dec!(10)), 2); // 1 and 5
1672    }
1673
1674    #[test]
1675    fn test_count_below_all_when_threshold_above_all() {
1676        let mut n = norm(4);
1677        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
1678        assert_eq!(n.count_below(dec!(100)), 3);
1679    }
1680
1681    #[test]
1682    fn test_count_below_zero_when_threshold_below_all() {
1683        let mut n = norm(4);
1684        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
1685        assert_eq!(n.count_below(dec!(5)), 0);
1686    }
1687
1688    #[test]
1689    fn test_count_above_plus_count_below_leq_len() {
1690        let mut n = norm(5);
1691        for v in [dec!(1), dec!(5), dec!(5), dec!(10), dec!(20)] { n.update(v); }
1692        // threshold = 5: above = {10,20} = 2, below = {1} = 1, equal = {5,5} = 2
1693        // above + below = 3 <= 5 = len
1694        assert_eq!(n.count_above(dec!(5)) + n.count_below(dec!(5)), 3);
1695    }
1696
1697    // ── MinMaxNormalizer::normalized_range ────────────────────────────────────
1698
1699    #[test]
1700    fn test_normalized_range_none_when_empty() {
1701        let mut n = norm(4);
1702        assert!(n.normalized_range().is_none());
1703    }
1704
1705    #[test]
1706    fn test_normalized_range_zero_when_all_same() {
1707        let mut n = norm(4);
1708        for _ in 0..4 { n.update(dec!(5)); }
1709        assert_eq!(n.normalized_range(), Some(0.0));
1710    }
1711
1712    #[test]
1713    fn test_normalized_range_correct_value() {
1714        let mut n = norm(4);
1715        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
1716        // (40-10)/40 = 0.75
1717        let nr = n.normalized_range().unwrap();
1718        assert!((nr - 0.75).abs() < 1e-10);
1719    }
1720
1721    // ── MinMaxNormalizer::normalize_clamp ─────────────────────────────────────
1722
1723    #[test]
1724    fn test_normalize_clamp_in_range_equals_normalize() {
1725        let mut n = norm(4);
1726        for v in [dec!(0), dec!(25), dec!(75), dec!(100)] {
1727            n.update(v);
1728        }
1729        let clamped = n.normalize_clamp(dec!(50)).unwrap();
1730        let normal = n.normalize(dec!(50)).unwrap();
1731        assert!((clamped - normal).abs() < 1e-9);
1732    }
1733
1734    #[test]
1735    fn test_normalize_clamp_above_max_clamped_to_one() {
1736        let mut n = norm(3);
1737        for v in [dec!(0), dec!(50), dec!(100)] {
1738            n.update(v);
1739        }
1740        // 200 is above the window max of 100; normalize would return > 1.0
1741        let clamped = n.normalize_clamp(dec!(200)).unwrap();
1742        assert!((clamped - 1.0).abs() < 1e-9, "expected 1.0 got {clamped}");
1743    }
1744
1745    #[test]
1746    fn test_normalize_clamp_below_min_clamped_to_zero() {
1747        let mut n = norm(3);
1748        for v in [dec!(10), dec!(50), dec!(100)] {
1749            n.update(v);
1750        }
1751        // -50 is below the window min of 10; normalize would return < 0.0
1752        let clamped = n.normalize_clamp(dec!(-50)).unwrap();
1753        assert!((clamped - 0.0).abs() < 1e-9, "expected 0.0 got {clamped}");
1754    }
1755
1756    #[test]
1757    fn test_normalize_clamp_empty_window_returns_error() {
1758        let mut n = norm(4);
1759        assert!(n.normalize_clamp(dec!(5)).is_err());
1760    }
1761
1762    // ── MinMaxNormalizer::latest ──────────────────────────────────────────────
1763
1764    #[test]
1765    fn test_latest_none_when_empty() {
1766        let n = norm(5);
1767        assert_eq!(n.latest(), None);
1768    }
1769
1770    #[test]
1771    fn test_latest_returns_most_recent_value() {
1772        let mut n = norm(5);
1773        n.update(dec!(10));
1774        n.update(dec!(20));
1775        n.update(dec!(30));
1776        assert_eq!(n.latest(), Some(dec!(30)));
1777    }
1778
1779    #[test]
1780    fn test_latest_updates_on_each_push() {
1781        let mut n = norm(3);
1782        n.update(dec!(1));
1783        assert_eq!(n.latest(), Some(dec!(1)));
1784        n.update(dec!(5));
1785        assert_eq!(n.latest(), Some(dec!(5)));
1786    }
1787
1788    #[test]
1789    fn test_latest_returns_last_after_window_overflow() {
1790        let mut n = norm(2); // window_size = 2
1791        n.update(dec!(100));
1792        n.update(dec!(200));
1793        n.update(dec!(300)); // oldest (100) evicted
1794        assert_eq!(n.latest(), Some(dec!(300)));
1795    }
1796
1797    // ── MinMaxNormalizer::coefficient_of_variation ────────────────────────────
1798
1799    #[test]
1800    fn test_minmax_cv_none_fewer_than_2_obs() {
1801        let mut n = norm(4);
1802        n.update(dec!(10));
1803        assert!(n.coefficient_of_variation().is_none());
1804    }
1805
1806    #[test]
1807    fn test_minmax_cv_none_when_mean_zero() {
1808        let mut n = norm(4);
1809        for v in [dec!(-5), dec!(5)] { n.update(v); }
1810        assert!(n.coefficient_of_variation().is_none());
1811    }
1812
1813    #[test]
1814    fn test_minmax_cv_positive_for_positive_mean() {
1815        let mut n = norm(4);
1816        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
1817        let cv = n.coefficient_of_variation().unwrap();
1818        assert!(cv > 0.0, "CV should be positive");
1819    }
1820
1821    // ── MinMaxNormalizer::variance / std_dev ─────────────────────────────────
1822
1823    #[test]
1824    fn test_minmax_variance_none_fewer_than_2_obs() {
1825        let mut n = norm(5);
1826        n.update(dec!(10));
1827        assert!(n.variance().is_none());
1828    }
1829
1830    #[test]
1831    fn test_minmax_variance_zero_all_same() {
1832        let mut n = norm(4);
1833        for _ in 0..4 { n.update(dec!(5)); }
1834        assert_eq!(n.variance(), Some(dec!(0)));
1835    }
1836
1837    #[test]
1838    fn test_minmax_variance_correct_value() {
1839        let mut n = norm(4);
1840        // values [2, 4, 4, 4, 5, 5, 7, 9] — classic example, pop variance = 4
1841        // use window=4, push last 4: [5, 5, 7, 9], mean=6.5, var=2.75
1842        for v in [dec!(5), dec!(5), dec!(7), dec!(9)] { n.update(v); }
1843        let var = n.variance().unwrap();
1844        // mean = (5+5+7+9)/4 = 6.5; deviations: -1.5,-1.5,0.5,2.5; sum_sq_devs: 2.25+2.25+0.25+6.25=11; var=11/4=2.75
1845        assert!((var.to_f64().unwrap() - 2.75).abs() < 1e-9);
1846    }
1847
1848    #[test]
1849    fn test_minmax_std_dev_none_fewer_than_2_obs() {
1850        let n = norm(4);
1851        assert!(n.std_dev().is_none());
1852    }
1853
1854    #[test]
1855    fn test_minmax_std_dev_zero_all_same() {
1856        let mut n = norm(3);
1857        for _ in 0..3 { n.update(dec!(7)); }
1858        assert_eq!(n.std_dev(), Some(0.0));
1859    }
1860
1861    #[test]
1862    fn test_minmax_std_dev_sqrt_of_variance() {
1863        let mut n = norm(4);
1864        for v in [dec!(5), dec!(5), dec!(7), dec!(9)] { n.update(v); }
1865        let sd = n.std_dev().unwrap();
1866        let var = n.variance().unwrap().to_f64().unwrap();
1867        assert!((sd - var.sqrt()).abs() < 1e-9);
1868    }
1869
1870    // ── MinMaxNormalizer::kurtosis ────────────────────────────────────────────
1871
1872    #[test]
1873    fn test_minmax_kurtosis_none_fewer_than_4_observations() {
1874        let mut n = norm(5);
1875        n.update(dec!(1));
1876        n.update(dec!(2));
1877        n.update(dec!(3));
1878        assert!(n.kurtosis().is_none());
1879    }
1880
1881    #[test]
1882    fn test_minmax_kurtosis_some_with_4_observations() {
1883        let mut n = norm(4);
1884        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
1885            n.update(v);
1886        }
1887        assert!(n.kurtosis().is_some());
1888    }
1889
1890    #[test]
1891    fn test_minmax_kurtosis_none_all_same_value() {
1892        let mut n = norm(4);
1893        for _ in 0..4 {
1894            n.update(dec!(5));
1895        }
1896        // std_dev = 0 → kurtosis undefined
1897        assert!(n.kurtosis().is_none());
1898    }
1899
1900    #[test]
1901    fn test_minmax_kurtosis_uniform_distribution_is_negative() {
1902        // Uniform distribution has excess kurtosis ≈ -1.2
1903        let mut n = norm(10);
1904        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5),
1905                  dec!(6), dec!(7), dec!(8), dec!(9), dec!(10)] {
1906            n.update(v);
1907        }
1908        let k = n.kurtosis().unwrap();
1909        assert!(k < 0.0, "uniform distribution should have negative excess kurtosis, got {k}");
1910    }
1911
1912    // ── MinMaxNormalizer::median ──────────────────────────────────────────────
1913
1914    #[test]
1915    fn test_minmax_median_none_for_empty_window() {
1916        assert!(norm(4).median().is_none());
1917    }
1918
1919    #[test]
1920    fn test_minmax_median_odd_window() {
1921        let mut n = norm(5);
1922        for v in [dec!(3), dec!(1), dec!(5), dec!(2), dec!(4)] { n.update(v); }
1923        // sorted: [1, 2, 3, 4, 5] → median = 3
1924        assert_eq!(n.median(), Some(dec!(3)));
1925    }
1926
1927    #[test]
1928    fn test_minmax_median_even_window() {
1929        let mut n = norm(4);
1930        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
1931        // sorted: [1, 2, 3, 4] → median = (2+3)/2 = 2.5
1932        assert_eq!(n.median(), Some(dec!(2.5)));
1933    }
1934
1935    // ── MinMaxNormalizer::sample_variance ─────────────────────────────────────
1936
1937    #[test]
1938    fn test_minmax_sample_variance_none_for_single_obs() {
1939        let mut n = norm(4);
1940        n.update(dec!(10));
1941        assert!(n.sample_variance().is_none());
1942    }
1943
1944    #[test]
1945    fn test_minmax_sample_variance_larger_than_population_variance() {
1946        let mut n = norm(4);
1947        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
1948        use rust_decimal::prelude::ToPrimitive;
1949        let pop_var = n.variance().unwrap().to_f64().unwrap();
1950        let sample_var = n.sample_variance().unwrap();
1951        assert!(sample_var > pop_var, "sample variance should exceed population variance");
1952    }
1953
1954    // ── MinMaxNormalizer::mad ─────────────────────────────────────────────────
1955
1956    #[test]
1957    fn test_minmax_mad_none_for_empty_window() {
1958        assert!(norm(4).mad().is_none());
1959    }
1960
1961    #[test]
1962    fn test_minmax_mad_zero_for_identical_values() {
1963        let mut n = norm(4);
1964        for _ in 0..4 { n.update(dec!(5)); }
1965        assert_eq!(n.mad(), Some(dec!(0)));
1966    }
1967
1968    #[test]
1969    fn test_minmax_mad_correct_for_known_distribution() {
1970        let mut n = norm(5);
1971        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
1972        // median = 3; deviations = [2,1,0,1,2] sorted → [0,1,1,2,2]; MAD = 1
1973        assert_eq!(n.mad(), Some(dec!(1)));
1974    }
1975
1976    // ── MinMaxNormalizer::robust_z_score ──────────────────────────────────────
1977
1978    #[test]
1979    fn test_minmax_robust_z_none_for_empty_window() {
1980        assert!(norm(4).robust_z_score(dec!(10)).is_none());
1981    }
1982
1983    #[test]
1984    fn test_minmax_robust_z_none_when_mad_is_zero() {
1985        let mut n = norm(4);
1986        for _ in 0..4 { n.update(dec!(5)); }
1987        assert!(n.robust_z_score(dec!(5)).is_none());
1988    }
1989
1990    #[test]
1991    fn test_minmax_robust_z_positive_above_median() {
1992        let mut n = norm(5);
1993        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
1994        let rz = n.robust_z_score(dec!(5)).unwrap();
1995        assert!(rz > 0.0, "robust z-score should be positive for value above median");
1996    }
1997
1998    #[test]
1999    fn test_minmax_robust_z_negative_below_median() {
2000        let mut n = norm(5);
2001        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2002        let rz = n.robust_z_score(dec!(1)).unwrap();
2003        assert!(rz < 0.0, "robust z-score should be negative for value below median");
2004    }
2005
2006    // ── MinMaxNormalizer::percentile_value ────────────────────────────────────
2007
2008    #[test]
2009    fn test_percentile_value_none_for_empty_window() {
2010        assert!(norm(4).percentile_value(0.5).is_none());
2011    }
2012
2013    #[test]
2014    fn test_percentile_value_min_at_zero() {
2015        let mut n = norm(5);
2016        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
2017        assert_eq!(n.percentile_value(0.0), Some(dec!(10)));
2018    }
2019
2020    #[test]
2021    fn test_percentile_value_max_at_one() {
2022        let mut n = norm(5);
2023        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
2024        assert_eq!(n.percentile_value(1.0), Some(dec!(50)));
2025    }
2026
2027    #[test]
2028    fn test_percentile_value_median_at_half() {
2029        let mut n = norm(5);
2030        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
2031        // p=0.5 → idx=2.0 → exact middle = 30
2032        assert_eq!(n.percentile_value(0.5), Some(dec!(30)));
2033    }
2034
2035    // ── MinMaxNormalizer::sum ─────────────────────────────────────────────────
2036
2037    #[test]
2038    fn test_minmax_sum_none_for_empty_window() {
2039        assert!(norm(3).sum().is_none());
2040    }
2041
2042    #[test]
2043    fn test_minmax_sum_single_value() {
2044        let mut n = norm(3);
2045        n.update(dec!(7));
2046        assert_eq!(n.sum(), Some(dec!(7)));
2047    }
2048
2049    #[test]
2050    fn test_minmax_sum_multiple_values() {
2051        let mut n = norm(4);
2052        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2053        assert_eq!(n.sum(), Some(dec!(10)));
2054    }
2055
2056    // ── MinMaxNormalizer::is_outlier ──────────────────────────────────────────
2057
2058    #[test]
2059    fn test_minmax_is_outlier_false_for_empty_window() {
2060        assert!(!norm(3).is_outlier(dec!(100), 2.0));
2061    }
2062
2063    #[test]
2064    fn test_minmax_is_outlier_false_for_in_range_value() {
2065        let mut n = norm(5);
2066        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2067        assert!(!n.is_outlier(dec!(3), 2.0));
2068    }
2069
2070    #[test]
2071    fn test_minmax_is_outlier_true_for_extreme_value() {
2072        let mut n = norm(5);
2073        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2074        assert!(n.is_outlier(dec!(100), 2.0));
2075    }
2076
2077    // ── MinMaxNormalizer::trim_outliers ───────────────────────────────────────
2078
2079    #[test]
2080    fn test_minmax_trim_outliers_returns_all_when_no_outliers() {
2081        let mut n = norm(5);
2082        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2083        let trimmed = n.trim_outliers(10.0);
2084        assert_eq!(trimmed.len(), 5);
2085    }
2086
2087    #[test]
2088    fn test_minmax_trim_outliers_removes_extreme_values() {
2089        let mut n = norm(5);
2090        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2091        // sigma=0 means everything is an outlier
2092        let trimmed = n.trim_outliers(0.0);
2093        assert_eq!(trimmed.len(), 1); // only the mean value (z=0) passes
2094    }
2095
2096    // ── MinMaxNormalizer::z_score_of_latest ───────────────────────────────────
2097
2098    #[test]
2099    fn test_minmax_z_score_of_latest_none_for_empty_window() {
2100        assert!(norm(3).z_score_of_latest().is_none());
2101    }
2102
2103    #[test]
2104    fn test_minmax_z_score_of_latest_zero_for_single_value() {
2105        let mut n = norm(5);
2106        n.update(dec!(10));
2107        // std_dev is zero for single value → None
2108        assert!(n.z_score_of_latest().is_none());
2109    }
2110
2111    #[test]
2112    fn test_minmax_z_score_of_latest_positive_for_above_mean() {
2113        let mut n = norm(5);
2114        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(10)] { n.update(v); }
2115        let z = n.z_score_of_latest().unwrap();
2116        assert!(z > 0.0, "latest value is above mean → positive z-score");
2117    }
2118
2119    // ── MinMaxNormalizer::deviation_from_mean ─────────────────────────────────
2120
2121    #[test]
2122    fn test_minmax_deviation_from_mean_none_for_empty_window() {
2123        assert!(norm(3).deviation_from_mean(dec!(5)).is_none());
2124    }
2125
2126    #[test]
2127    fn test_minmax_deviation_from_mean_zero_at_mean() {
2128        let mut n = norm(4);
2129        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2130        // mean = 2.5
2131        let dev = n.deviation_from_mean(dec!(2.5)).unwrap();
2132        assert!(dev.abs() < 1e-9);
2133    }
2134
2135    #[test]
2136    fn test_minmax_deviation_from_mean_positive_above_mean() {
2137        let mut n = norm(4);
2138        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2139        let dev = n.deviation_from_mean(dec!(5)).unwrap();
2140        assert!(dev > 0.0);
2141    }
2142
2143    // ── MinMaxNormalizer::range_f64 / sum_f64 ─────────────────────────────────
2144
2145    #[test]
2146    fn test_minmax_range_f64_none_for_empty_window() {
2147        assert!(norm(3).range_f64().is_none());
2148    }
2149
2150    #[test]
2151    fn test_minmax_range_f64_correct() {
2152        let mut n = norm(4);
2153        for v in [dec!(5), dec!(15), dec!(10), dec!(20)] { n.update(v); }
2154        // range = 20 - 5 = 15
2155        let r = n.range_f64().unwrap();
2156        assert!((r - 15.0).abs() < 1e-9);
2157    }
2158
2159    #[test]
2160    fn test_minmax_sum_f64_none_for_empty_window() {
2161        assert!(norm(3).sum_f64().is_none());
2162    }
2163
2164    #[test]
2165    fn test_minmax_sum_f64_correct() {
2166        let mut n = norm(4);
2167        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2168        let s = n.sum_f64().unwrap();
2169        assert!((s - 10.0).abs() < 1e-9);
2170    }
2171
2172    // ── MinMaxNormalizer::values ──────────────────────────────────────────────
2173
2174    #[test]
2175    fn test_minmax_values_empty_for_empty_window() {
2176        assert!(norm(3).values().is_empty());
2177    }
2178
2179    #[test]
2180    fn test_minmax_values_preserves_insertion_order() {
2181        let mut n = norm(5);
2182        for v in [dec!(3), dec!(1), dec!(4), dec!(1), dec!(5)] { n.update(v); }
2183        assert_eq!(n.values(), vec![dec!(3), dec!(1), dec!(4), dec!(1), dec!(5)]);
2184    }
2185
2186    // ── MinMaxNormalizer::normalized_midpoint ─────────────────────────────────
2187
2188    #[test]
2189    fn test_minmax_normalized_midpoint_none_for_empty_window() {
2190        assert!(norm(3).normalized_midpoint().is_none());
2191    }
2192
2193    #[test]
2194    fn test_minmax_normalized_midpoint_half_for_uniform_range() {
2195        let mut n = norm(4);
2196        for v in [dec!(0), dec!(10), dec!(20), dec!(30)] { n.update(v); }
2197        // midpoint = (0+30)/2 = 15; normalize(15) with min=0, max=30 = 0.5
2198        let mid = n.normalized_midpoint().unwrap();
2199        assert!((mid - 0.5).abs() < 1e-9);
2200    }
2201
2202    // ── MinMaxNormalizer::is_at_min / is_at_max ───────────────────────────────
2203
2204    #[test]
2205    fn test_minmax_is_at_min_false_for_empty_window() {
2206        assert!(!norm(3).is_at_min(dec!(5)));
2207    }
2208
2209    #[test]
2210    fn test_minmax_is_at_min_true_for_minimum_value() {
2211        let mut n = norm(4);
2212        for v in [dec!(10), dec!(20), dec!(5), dec!(30)] { n.update(v); }
2213        assert!(n.is_at_min(dec!(5)));
2214    }
2215
2216    #[test]
2217    fn test_minmax_is_at_min_false_for_non_minimum() {
2218        let mut n = norm(4);
2219        for v in [dec!(10), dec!(20), dec!(5), dec!(30)] { n.update(v); }
2220        assert!(!n.is_at_min(dec!(10)));
2221    }
2222
2223    #[test]
2224    fn test_minmax_is_at_max_false_for_empty_window() {
2225        assert!(!norm(3).is_at_max(dec!(5)));
2226    }
2227
2228    #[test]
2229    fn test_minmax_is_at_max_true_for_maximum_value() {
2230        let mut n = norm(4);
2231        for v in [dec!(10), dec!(20), dec!(5), dec!(30)] { n.update(v); }
2232        assert!(n.is_at_max(dec!(30)));
2233    }
2234
2235    #[test]
2236    fn test_minmax_is_at_max_false_for_non_maximum() {
2237        let mut n = norm(4);
2238        for v in [dec!(10), dec!(20), dec!(5), dec!(30)] { n.update(v); }
2239        assert!(!n.is_at_max(dec!(20)));
2240    }
2241
2242    // ── MinMaxNormalizer::fraction_above / fraction_below ─────────────────────
2243
2244    #[test]
2245    fn test_minmax_fraction_above_none_for_empty_window() {
2246        assert!(norm(3).fraction_above(dec!(5)).is_none());
2247    }
2248
2249    #[test]
2250    fn test_minmax_fraction_above_correct() {
2251        let mut n = norm(5);
2252        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2253        // above 3: {4, 5} = 2/5 = 0.4
2254        let frac = n.fraction_above(dec!(3)).unwrap();
2255        assert!((frac - 0.4).abs() < 1e-9);
2256    }
2257
2258    #[test]
2259    fn test_minmax_fraction_below_none_for_empty_window() {
2260        assert!(norm(3).fraction_below(dec!(5)).is_none());
2261    }
2262
2263    #[test]
2264    fn test_minmax_fraction_below_correct() {
2265        let mut n = norm(5);
2266        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2267        // below 3: {1, 2} = 2/5 = 0.4
2268        let frac = n.fraction_below(dec!(3)).unwrap();
2269        assert!((frac - 0.4).abs() < 1e-9);
2270    }
2271
2272    // ── MinMaxNormalizer::window_values_above / window_values_below ───────────
2273
2274    #[test]
2275    fn test_minmax_window_values_above_empty_window() {
2276        assert!(norm(3).window_values_above(dec!(5)).is_empty());
2277    }
2278
2279    #[test]
2280    fn test_minmax_window_values_above_filters_correctly() {
2281        let mut n = norm(5);
2282        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
2283        let above = n.window_values_above(dec!(5));
2284        assert_eq!(above.len(), 2);
2285        assert!(above.contains(&dec!(7)));
2286        assert!(above.contains(&dec!(9)));
2287    }
2288
2289    #[test]
2290    fn test_minmax_window_values_below_empty_window() {
2291        assert!(norm(3).window_values_below(dec!(5)).is_empty());
2292    }
2293
2294    #[test]
2295    fn test_minmax_window_values_below_filters_correctly() {
2296        let mut n = norm(5);
2297        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
2298        let below = n.window_values_below(dec!(5));
2299        assert_eq!(below.len(), 2);
2300        assert!(below.contains(&dec!(1)));
2301        assert!(below.contains(&dec!(3)));
2302    }
2303
2304    // ── MinMaxNormalizer::percentile_rank ─────────────────────────────────────
2305
2306    #[test]
2307    fn test_minmax_percentile_rank_none_for_empty_window() {
2308        assert!(norm(3).percentile_rank(dec!(5)).is_none());
2309    }
2310
2311    #[test]
2312    fn test_minmax_percentile_rank_correct() {
2313        let mut n = norm(5);
2314        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2315        // <= 3: {1, 2, 3} = 3/5 = 0.6
2316        let rank = n.percentile_rank(dec!(3)).unwrap();
2317        assert!((rank - 0.6).abs() < 1e-9);
2318    }
2319
2320    // ── MinMaxNormalizer::count_equal ─────────────────────────────────────────
2321
2322    #[test]
2323    fn test_minmax_count_equal_zero_for_no_match() {
2324        let mut n = norm(3);
2325        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
2326        assert_eq!(n.count_equal(dec!(99)), 0);
2327    }
2328
2329    #[test]
2330    fn test_minmax_count_equal_counts_duplicates() {
2331        let mut n = norm(5);
2332        for v in [dec!(5), dec!(5), dec!(3), dec!(5), dec!(2)] { n.update(v); }
2333        assert_eq!(n.count_equal(dec!(5)), 3);
2334    }
2335
2336    // ── MinMaxNormalizer::rolling_range ───────────────────────────────────────
2337
2338    #[test]
2339    fn test_minmax_rolling_range_none_for_empty() {
2340        assert!(norm(3).rolling_range().is_none());
2341    }
2342
2343    #[test]
2344    fn test_minmax_rolling_range_correct() {
2345        let mut n = norm(5);
2346        for v in [dec!(10), dec!(50), dec!(30), dec!(20), dec!(40)] { n.update(v); }
2347        assert_eq!(n.rolling_range(), Some(dec!(40)));
2348    }
2349
2350    // ── MinMaxNormalizer::skewness ─────────────────────────────────────────────
2351
2352    #[test]
2353    fn test_minmax_skewness_none_for_fewer_than_3() {
2354        let mut n = norm(5);
2355        n.update(dec!(1)); n.update(dec!(2));
2356        assert!(n.skewness().is_none());
2357    }
2358
2359    #[test]
2360    fn test_minmax_skewness_near_zero_for_symmetric_data() {
2361        let mut n = norm(5);
2362        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2363        let s = n.skewness().unwrap();
2364        assert!(s.abs() < 0.5);
2365    }
2366
2367    // ── MinMaxNormalizer::kurtosis ─────────────────────────────────────
2368
2369    #[test]
2370    fn test_minmax_kurtosis_none_for_fewer_than_4() {
2371        let mut n = norm(5);
2372        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
2373        assert!(n.kurtosis().is_none());
2374    }
2375
2376    #[test]
2377    fn test_minmax_kurtosis_returns_f64_for_populated_window() {
2378        let mut n = norm(5);
2379        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2380        assert!(n.kurtosis().is_some());
2381    }
2382
2383    // ── MinMaxNormalizer::autocorrelation_lag1 ────────────────────────────────
2384
2385    #[test]
2386    fn test_minmax_autocorrelation_none_for_single_value() {
2387        let mut n = norm(3);
2388        n.update(dec!(1));
2389        assert!(n.autocorrelation_lag1().is_none());
2390    }
2391
2392    #[test]
2393    fn test_minmax_autocorrelation_positive_for_trending_data() {
2394        let mut n = norm(5);
2395        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2396        let ac = n.autocorrelation_lag1().unwrap();
2397        assert!(ac > 0.0);
2398    }
2399
2400    // ── MinMaxNormalizer::trend_consistency ───────────────────────────────────
2401
2402    #[test]
2403    fn test_minmax_trend_consistency_none_for_single_value() {
2404        let mut n = norm(3);
2405        n.update(dec!(1));
2406        assert!(n.trend_consistency().is_none());
2407    }
2408
2409    #[test]
2410    fn test_minmax_trend_consistency_one_for_strictly_rising() {
2411        let mut n = norm(5);
2412        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2413        let tc = n.trend_consistency().unwrap();
2414        assert!((tc - 1.0).abs() < 1e-9);
2415    }
2416
2417    #[test]
2418    fn test_minmax_trend_consistency_zero_for_strictly_falling() {
2419        let mut n = norm(5);
2420        for v in [dec!(5), dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
2421        let tc = n.trend_consistency().unwrap();
2422        assert!((tc - 0.0).abs() < 1e-9);
2423    }
2424
2425    // ── MinMaxNormalizer::coefficient_of_variation ────────────────────────────
2426
2427    #[test]
2428    fn test_minmax_cov_none_for_single_value() {
2429        let mut n = norm(3);
2430        n.update(dec!(10));
2431        assert!(n.coefficient_of_variation().is_none());
2432    }
2433
2434    #[test]
2435    fn test_minmax_cov_positive_for_varied_data() {
2436        let mut n = norm(5);
2437        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
2438        let cov = n.coefficient_of_variation().unwrap();
2439        assert!(cov > 0.0);
2440    }
2441
2442    // ── MinMaxNormalizer::mean_absolute_deviation ─────────────────────────────
2443
2444    #[test]
2445    fn test_minmax_mean_absolute_deviation_none_for_empty() {
2446        assert!(norm(3).mean_absolute_deviation().is_none());
2447    }
2448
2449    #[test]
2450    fn test_minmax_mean_absolute_deviation_zero_for_identical_values() {
2451        let mut n = norm(3);
2452        for v in [dec!(5), dec!(5), dec!(5)] { n.update(v); }
2453        let mad = n.mean_absolute_deviation().unwrap();
2454        assert!((mad - 0.0).abs() < 1e-9);
2455    }
2456
2457    #[test]
2458    fn test_minmax_mean_absolute_deviation_positive_for_varied_data() {
2459        let mut n = norm(4);
2460        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2461        let mad = n.mean_absolute_deviation().unwrap();
2462        assert!(mad > 0.0);
2463    }
2464
2465    // ── MinMaxNormalizer::percentile_of_latest ────────────────────────────────
2466
2467    #[test]
2468    fn test_minmax_percentile_of_latest_none_for_empty() {
2469        assert!(norm(3).percentile_of_latest().is_none());
2470    }
2471
2472    #[test]
2473    fn test_minmax_percentile_of_latest_returns_some_after_update() {
2474        let mut n = norm(4);
2475        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2476        assert!(n.percentile_of_latest().is_some());
2477    }
2478
2479    #[test]
2480    fn test_minmax_percentile_of_latest_max_has_high_rank() {
2481        let mut n = norm(5);
2482        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2483        let rank = n.percentile_of_latest().unwrap();
2484        assert!(rank >= 0.9, "max value should have rank near 1.0, got {}", rank);
2485    }
2486
2487    // ── MinMaxNormalizer::tail_ratio ──────────────────────────────────────────
2488
2489    #[test]
2490    fn test_minmax_tail_ratio_none_for_empty() {
2491        assert!(norm(4).tail_ratio().is_none());
2492    }
2493
2494    #[test]
2495    fn test_minmax_tail_ratio_one_for_identical_values() {
2496        let mut n = norm(4);
2497        for _ in 0..4 { n.update(dec!(7)); }
2498        // p75 == max == 7 → ratio = 1.0
2499        let r = n.tail_ratio().unwrap();
2500        assert!((r - 1.0).abs() < 1e-9, "expected 1.0, got {}", r);
2501    }
2502
2503    #[test]
2504    fn test_minmax_tail_ratio_above_one_with_outlier() {
2505        let mut n = norm(5);
2506        for v in [dec!(1), dec!(1), dec!(1), dec!(1), dec!(10)] { n.update(v); }
2507        let r = n.tail_ratio().unwrap();
2508        assert!(r > 1.0, "outlier should push ratio above 1.0, got {}", r);
2509    }
2510
2511    // ── MinMaxNormalizer::z_score_of_min / z_score_of_max ─────────────────────
2512
2513    #[test]
2514    fn test_minmax_z_score_of_min_none_for_empty() {
2515        assert!(norm(4).z_score_of_min().is_none());
2516    }
2517
2518    #[test]
2519    fn test_minmax_z_score_of_max_none_for_empty() {
2520        assert!(norm(4).z_score_of_max().is_none());
2521    }
2522
2523    #[test]
2524    fn test_minmax_z_score_of_min_negative_for_varied_window() {
2525        let mut n = norm(5);
2526        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2527        // min=1, mean=3, so z-score should be negative
2528        let z = n.z_score_of_min().unwrap();
2529        assert!(z < 0.0, "z-score of min should be negative, got {}", z);
2530    }
2531
2532    #[test]
2533    fn test_minmax_z_score_of_max_positive_for_varied_window() {
2534        let mut n = norm(5);
2535        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2536        // max=5, mean=3, so z-score should be positive
2537        let z = n.z_score_of_max().unwrap();
2538        assert!(z > 0.0, "z-score of max should be positive, got {}", z);
2539    }
2540
2541    // ── MinMaxNormalizer::window_entropy ──────────────────────────────────────
2542
2543    #[test]
2544    fn test_minmax_window_entropy_none_for_empty() {
2545        assert!(norm(4).window_entropy().is_none());
2546    }
2547
2548    #[test]
2549    fn test_minmax_window_entropy_zero_for_identical_values() {
2550        let mut n = norm(3);
2551        for _ in 0..3 { n.update(dec!(5)); }
2552        let e = n.window_entropy().unwrap();
2553        assert!((e - 0.0).abs() < 1e-9, "identical values should have zero entropy, got {}", e);
2554    }
2555
2556    #[test]
2557    fn test_minmax_window_entropy_positive_for_varied_values() {
2558        let mut n = norm(4);
2559        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2560        let e = n.window_entropy().unwrap();
2561        assert!(e > 0.0, "varied values should have positive entropy, got {}", e);
2562    }
2563
2564    // ── MinMaxNormalizer::normalized_std_dev ──────────────────────────────────
2565
2566    #[test]
2567    fn test_minmax_normalized_std_dev_none_for_single_value() {
2568        let mut n = norm(4);
2569        n.update(dec!(5));
2570        assert!(n.normalized_std_dev().is_none());
2571    }
2572
2573    #[test]
2574    fn test_minmax_normalized_std_dev_positive_for_varied_values() {
2575        let mut n = norm(4);
2576        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2577        let r = n.normalized_std_dev().unwrap();
2578        assert!(r > 0.0, "expected positive normalized std dev, got {}", r);
2579    }
2580
2581    // ── MinMaxNormalizer::value_above_mean_count ──────────────────────────────
2582
2583    #[test]
2584    fn test_minmax_value_above_mean_count_none_for_empty() {
2585        assert!(norm(4).value_above_mean_count().is_none());
2586    }
2587
2588    #[test]
2589    fn test_minmax_value_above_mean_count_correct() {
2590        // values: 1,2,3,4 → mean=2.5 → above: 3,4 → count=2
2591        let mut n = norm(4);
2592        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2593        assert_eq!(n.value_above_mean_count().unwrap(), 2);
2594    }
2595
2596    // ── MinMaxNormalizer::consecutive_above_mean ──────────────────────────────
2597
2598    #[test]
2599    fn test_minmax_consecutive_above_mean_none_for_empty() {
2600        assert!(norm(4).consecutive_above_mean().is_none());
2601    }
2602
2603    #[test]
2604    fn test_minmax_consecutive_above_mean_correct() {
2605        // values: 1,5,6,7 → mean=4.75 → above: 5,6,7 → run=3
2606        let mut n = norm(4);
2607        for v in [dec!(1), dec!(5), dec!(6), dec!(7)] { n.update(v); }
2608        assert_eq!(n.consecutive_above_mean().unwrap(), 3);
2609    }
2610
2611    // ── MinMaxNormalizer::above_threshold_fraction / below_threshold_fraction ─
2612
2613    #[test]
2614    fn test_minmax_above_threshold_fraction_none_for_empty() {
2615        assert!(norm(4).above_threshold_fraction(dec!(5)).is_none());
2616    }
2617
2618    #[test]
2619    fn test_minmax_above_threshold_fraction_correct() {
2620        let mut n = norm(4);
2621        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2622        // above 2: values 3,4 → fraction = 0.5
2623        let f = n.above_threshold_fraction(dec!(2)).unwrap();
2624        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
2625    }
2626
2627    #[test]
2628    fn test_minmax_below_threshold_fraction_none_for_empty() {
2629        assert!(norm(4).below_threshold_fraction(dec!(5)).is_none());
2630    }
2631
2632    #[test]
2633    fn test_minmax_below_threshold_fraction_correct() {
2634        let mut n = norm(4);
2635        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2636        // below 3: values 1,2 → fraction = 0.5
2637        let f = n.below_threshold_fraction(dec!(3)).unwrap();
2638        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
2639    }
2640
2641    // ── MinMaxNormalizer::lag_k_autocorrelation ───────────────────────────────
2642
2643    #[test]
2644    fn test_minmax_lag_k_autocorrelation_none_for_zero_k() {
2645        let mut n = norm(5);
2646        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2647        assert!(n.lag_k_autocorrelation(0).is_none());
2648    }
2649
2650    #[test]
2651    fn test_minmax_lag_k_autocorrelation_none_when_k_gte_len() {
2652        let mut n = norm(3);
2653        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
2654        assert!(n.lag_k_autocorrelation(3).is_none());
2655    }
2656
2657    #[test]
2658    fn test_minmax_lag_k_autocorrelation_positive_for_trend() {
2659        // Monotone increasing → strong positive autocorrelation
2660        let mut n = norm(6);
2661        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(6)] { n.update(v); }
2662        let ac = n.lag_k_autocorrelation(1).unwrap();
2663        assert!(ac > 0.0, "trending series should have positive AC, got {}", ac);
2664    }
2665
2666    // ── MinMaxNormalizer::half_life_estimate ──────────────────────────────────
2667
2668    #[test]
2669    fn test_minmax_half_life_estimate_none_for_fewer_than_3() {
2670        let mut n = norm(3);
2671        n.update(dec!(1)); n.update(dec!(2));
2672        assert!(n.half_life_estimate().is_none());
2673    }
2674
2675    #[test]
2676    fn test_minmax_half_life_estimate_some_for_mean_reverting() {
2677        // Alternating series: strong mean-reversion signal
2678        let mut n = norm(6);
2679        for v in [dec!(10), dec!(5), dec!(10), dec!(5), dec!(10), dec!(5)] { n.update(v); }
2680        // May or may not return Some depending on AR coefficient sign; just ensure no panic
2681        let _ = n.half_life_estimate();
2682    }
2683
2684    // ── MinMaxNormalizer::geometric_mean ──────────────────────────────────────
2685
2686    #[test]
2687    fn test_minmax_geometric_mean_none_for_empty() {
2688        assert!(norm(4).geometric_mean().is_none());
2689    }
2690
2691    #[test]
2692    fn test_minmax_geometric_mean_correct_for_powers_of_2() {
2693        // geomean(1,2,4,8) = (1*2*4*8)^(1/4) = 64^0.25 = 2.828...
2694        let mut n = norm(4);
2695        for v in [dec!(1), dec!(2), dec!(4), dec!(8)] { n.update(v); }
2696        let gm = n.geometric_mean().unwrap();
2697        assert!((gm - 64.0f64.powf(0.25)).abs() < 1e-6, "got {}", gm);
2698    }
2699
2700    // ── MinMaxNormalizer::harmonic_mean ───────────────────────────────────────
2701
2702    #[test]
2703    fn test_minmax_harmonic_mean_none_for_empty() {
2704        assert!(norm(4).harmonic_mean().is_none());
2705    }
2706
2707    #[test]
2708    fn test_minmax_harmonic_mean_none_when_any_zero() {
2709        let mut n = norm(2);
2710        n.update(dec!(0)); n.update(dec!(5));
2711        assert!(n.harmonic_mean().is_none());
2712    }
2713
2714    #[test]
2715    fn test_minmax_harmonic_mean_positive_for_positive_values() {
2716        let mut n = norm(4);
2717        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2718        let hm = n.harmonic_mean().unwrap();
2719        assert!(hm > 0.0 && hm < 4.0, "HM should be in (0, max), got {}", hm);
2720    }
2721
2722    // ── MinMaxNormalizer::range_normalized_value ──────────────────────────────
2723
2724    #[test]
2725    fn test_minmax_range_normalized_value_none_for_empty() {
2726        assert!(norm(4).range_normalized_value(dec!(5)).is_none());
2727    }
2728
2729    #[test]
2730    fn test_minmax_range_normalized_value_zero_for_min() {
2731        let mut n = norm(4);
2732        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2733        let r = n.range_normalized_value(dec!(1)).unwrap();
2734        assert!((r - 0.0).abs() < 1e-9, "min value should normalize to 0, got {}", r);
2735    }
2736
2737    #[test]
2738    fn test_minmax_range_normalized_value_one_for_max() {
2739        let mut n = norm(4);
2740        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2741        let r = n.range_normalized_value(dec!(4)).unwrap();
2742        assert!((r - 1.0).abs() < 1e-9, "max value should normalize to 1, got {}", r);
2743    }
2744
2745    // ── MinMaxNormalizer::distance_from_median ────────────────────────────────
2746
2747    #[test]
2748    fn test_minmax_distance_from_median_none_for_empty() {
2749        assert!(norm(4).distance_from_median(dec!(5)).is_none());
2750    }
2751
2752    #[test]
2753    fn test_minmax_distance_from_median_zero_at_median() {
2754        let mut n = norm(5);
2755        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2756        // median=3
2757        let d = n.distance_from_median(dec!(3)).unwrap();
2758        assert!((d - 0.0).abs() < 1e-9, "distance from median should be 0, got {}", d);
2759    }
2760
2761    #[test]
2762    fn test_minmax_distance_from_median_positive_above() {
2763        let mut n = norm(5);
2764        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2765        let d = n.distance_from_median(dec!(5)).unwrap();
2766        assert!(d > 0.0, "value above median should give positive distance, got {}", d);
2767    }
2768
2769    #[test]
2770    fn test_minmax_momentum_none_for_single_value() {
2771        let mut n = norm(5);
2772        n.update(dec!(10));
2773        assert!(n.momentum().is_none());
2774    }
2775
2776    #[test]
2777    fn test_minmax_momentum_positive_for_rising_window() {
2778        let mut n = norm(3);
2779        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
2780        let m = n.momentum().unwrap();
2781        assert!(m > 0.0, "rising window → positive momentum, got {}", m);
2782    }
2783
2784    #[test]
2785    fn test_minmax_value_rank_none_for_empty() {
2786        assert!(norm(4).value_rank(dec!(5)).is_none());
2787    }
2788
2789    #[test]
2790    fn test_minmax_value_rank_extremes() {
2791        let mut n = norm(4);
2792        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2793        // value below all: rank = 0/4 = 0.0
2794        let low = n.value_rank(dec!(0)).unwrap();
2795        assert!((low - 0.0).abs() < 1e-9, "got {}", low);
2796        // value above all: rank = 4/4 = 1.0
2797        let high = n.value_rank(dec!(5)).unwrap();
2798        assert!((high - 1.0).abs() < 1e-9, "got {}", high);
2799    }
2800
2801    #[test]
2802    fn test_minmax_coeff_of_variation_none_for_single_value() {
2803        let mut n = norm(5);
2804        n.update(dec!(10));
2805        assert!(n.coeff_of_variation().is_none());
2806    }
2807
2808    #[test]
2809    fn test_minmax_coeff_of_variation_positive_for_spread() {
2810        let mut n = norm(4);
2811        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
2812        let cv = n.coeff_of_variation().unwrap();
2813        assert!(cv > 0.0, "expected positive CV, got {}", cv);
2814    }
2815
2816    #[test]
2817    fn test_minmax_quantile_range_none_for_empty() {
2818        assert!(norm(4).quantile_range().is_none());
2819    }
2820
2821    #[test]
2822    fn test_minmax_quantile_range_non_negative() {
2823        let mut n = norm(5);
2824        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2825        let iqr = n.quantile_range().unwrap();
2826        assert!(iqr >= 0.0, "IQR should be non-negative, got {}", iqr);
2827    }
2828
2829    // ── MinMaxNormalizer::upper_quartile / lower_quartile ─────────────────────
2830
2831    #[test]
2832    fn test_minmax_upper_quartile_none_for_empty() {
2833        assert!(norm(4).upper_quartile().is_none());
2834    }
2835
2836    #[test]
2837    fn test_minmax_lower_quartile_none_for_empty() {
2838        assert!(norm(4).lower_quartile().is_none());
2839    }
2840
2841    #[test]
2842    fn test_minmax_upper_ge_lower_quartile() {
2843        let mut n = norm(8);
2844        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(6), dec!(7), dec!(8)] {
2845            n.update(v);
2846        }
2847        let q3 = n.upper_quartile().unwrap();
2848        let q1 = n.lower_quartile().unwrap();
2849        assert!(q3 >= q1, "Q3 ({}) should be >= Q1 ({})", q3, q1);
2850    }
2851
2852    // ── MinMaxNormalizer::sign_change_rate ────────────────────────────────────
2853
2854    #[test]
2855    fn test_minmax_sign_change_rate_none_for_fewer_than_3() {
2856        let mut n = norm(4);
2857        n.update(dec!(1));
2858        n.update(dec!(2));
2859        assert!(n.sign_change_rate().is_none());
2860    }
2861
2862    #[test]
2863    fn test_minmax_sign_change_rate_one_for_zigzag() {
2864        let mut n = norm(5);
2865        // 1, 3, 1, 3, 1 → diffs: +,−,+,− → every adjacent pair flips → 1.0
2866        for v in [dec!(1), dec!(3), dec!(1), dec!(3), dec!(1)] { n.update(v); }
2867        let r = n.sign_change_rate().unwrap();
2868        assert!((r - 1.0).abs() < 1e-9, "zigzag should give 1.0, got {}", r);
2869    }
2870
2871    #[test]
2872    fn test_minmax_sign_change_rate_zero_for_monotone() {
2873        let mut n = norm(5);
2874        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2875        let r = n.sign_change_rate().unwrap();
2876        assert!((r - 0.0).abs() < 1e-9, "monotone should give 0.0, got {}", r);
2877    }
2878
2879    // ── round-79 ─────────────────────────────────────────────────────────────
2880
2881    // ── MinMaxNormalizer::consecutive_below_mean ──────────────────────────────
2882
2883    #[test]
2884    fn test_consecutive_below_mean_none_for_single_value() {
2885        let mut n = norm(5);
2886        n.update(dec!(10));
2887        assert!(n.consecutive_below_mean().is_none());
2888    }
2889
2890    #[test]
2891    fn test_consecutive_below_mean_zero_when_latest_above_mean() {
2892        let mut n = norm(5);
2893        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(100)] { n.update(v); }
2894        let c = n.consecutive_below_mean().unwrap();
2895        assert_eq!(c, 0, "latest above mean → streak=0, got {}", c);
2896    }
2897
2898    #[test]
2899    fn test_consecutive_below_mean_counts_trailing_below() {
2900        let mut n = norm(5);
2901        for v in [dec!(100), dec!(100), dec!(1), dec!(1), dec!(1)] { n.update(v); }
2902        let c = n.consecutive_below_mean().unwrap();
2903        assert!(c >= 3, "last 3 below mean → streak>=3, got {}", c);
2904    }
2905
2906    // ── MinMaxNormalizer::drift_rate ──────────────────────────────────────────
2907
2908    #[test]
2909    fn test_drift_rate_none_for_single_value() {
2910        let mut n = norm(5);
2911        n.update(dec!(10));
2912        assert!(n.drift_rate().is_none());
2913    }
2914
2915    #[test]
2916    fn test_drift_rate_positive_for_rising_series() {
2917        let mut n = norm(6);
2918        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(6)] { n.update(v); }
2919        let d = n.drift_rate().unwrap();
2920        assert!(d > 0.0, "rising series → positive drift, got {}", d);
2921    }
2922
2923    #[test]
2924    fn test_drift_rate_negative_for_falling_series() {
2925        let mut n = norm(6);
2926        for v in [dec!(6), dec!(5), dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
2927        let d = n.drift_rate().unwrap();
2928        assert!(d < 0.0, "falling series → negative drift, got {}", d);
2929    }
2930
2931    // ── MinMaxNormalizer::peak_to_trough_ratio ────────────────────────────────
2932
2933    #[test]
2934    fn test_peak_to_trough_ratio_none_for_empty() {
2935        assert!(norm(4).peak_to_trough_ratio().is_none());
2936    }
2937
2938    #[test]
2939    fn test_peak_to_trough_ratio_one_for_constant() {
2940        let mut n = norm(4);
2941        for v in [dec!(10), dec!(10), dec!(10), dec!(10)] { n.update(v); }
2942        let r = n.peak_to_trough_ratio().unwrap();
2943        assert!((r - 1.0).abs() < 1e-9, "constant → ratio=1, got {}", r);
2944    }
2945
2946    #[test]
2947    fn test_peak_to_trough_ratio_above_one_for_spread() {
2948        let mut n = norm(4);
2949        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
2950        let r = n.peak_to_trough_ratio().unwrap();
2951        assert!(r > 1.0, "spread → ratio>1, got {}", r);
2952    }
2953
2954    // ── MinMaxNormalizer::normalized_deviation ────────────────────────────────
2955
2956    #[test]
2957    fn test_normalized_deviation_none_for_empty() {
2958        assert!(norm(4).normalized_deviation().is_none());
2959    }
2960
2961    #[test]
2962    fn test_normalized_deviation_none_for_constant() {
2963        let mut n = norm(4);
2964        for v in [dec!(5), dec!(5), dec!(5), dec!(5)] { n.update(v); }
2965        assert!(n.normalized_deviation().is_none());
2966    }
2967
2968    #[test]
2969    fn test_normalized_deviation_positive_for_latest_above_mean() {
2970        let mut n = norm(5);
2971        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(10)] { n.update(v); }
2972        let d = n.normalized_deviation().unwrap();
2973        assert!(d > 0.0, "latest above mean → positive deviation, got {}", d);
2974    }
2975
2976    // ── MinMaxNormalizer::window_cv_pct ───────────────────────────────────────
2977
2978    #[test]
2979    fn test_window_cv_pct_none_for_single_value() {
2980        let mut n = norm(5);
2981        n.update(dec!(10));
2982        assert!(n.window_cv_pct().is_none());
2983    }
2984
2985    #[test]
2986    fn test_window_cv_pct_positive_for_varied_values() {
2987        let mut n = norm(4);
2988        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
2989        let cv = n.window_cv_pct().unwrap();
2990        assert!(cv > 0.0, "expected positive CV%, got {}", cv);
2991    }
2992
2993    // ── MinMaxNormalizer::latest_rank_pct ─────────────────────────────────────
2994
2995    #[test]
2996    fn test_latest_rank_pct_none_for_single_value() {
2997        let mut n = norm(5);
2998        n.update(dec!(10));
2999        assert!(n.latest_rank_pct().is_none());
3000    }
3001
3002    #[test]
3003    fn test_latest_rank_pct_one_for_max_value() {
3004        let mut n = norm(4);
3005        for v in [dec!(1), dec!(2), dec!(3), dec!(100)] { n.update(v); }
3006        let r = n.latest_rank_pct().unwrap();
3007        assert!((r - 1.0).abs() < 1e-9, "latest is max → rank=1, got {}", r);
3008    }
3009
3010    #[test]
3011    fn test_latest_rank_pct_zero_for_min_value() {
3012        let mut n = norm(4);
3013        for v in [dec!(10), dec!(20), dec!(30), dec!(1)] { n.update(v); }
3014        let r = n.latest_rank_pct().unwrap();
3015        assert!(r.abs() < 1e-9, "latest is min → rank=0, got {}", r);
3016    }
3017}
3018
3019/// Rolling z-score normalizer over a sliding window of [`Decimal`] observations.
3020///
3021/// Maps each new sample to its z-score: `(x - mean) / std_dev`. The rolling
3022/// window maintains an O(1) mean and variance via incremental sum/sum-of-squares
3023/// tracking, with O(W) recompute only when a value is evicted.
3024///
3025/// Returns 0.0 when the window has fewer than 2 observations (variance is 0).
3026///
3027/// # Example
3028///
3029/// ```rust
3030/// use fin_stream::norm::ZScoreNormalizer;
3031/// use rust_decimal_macros::dec;
3032///
3033/// let mut norm = ZScoreNormalizer::new(5).unwrap();
3034/// for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] {
3035///     norm.update(v);
3036/// }
3037/// // 30 is the mean; normalize returns 0.0
3038/// let z = norm.normalize(dec!(30)).unwrap();
3039/// assert!((z - 0.0).abs() < 1e-9);
3040/// ```
3041pub struct ZScoreNormalizer {
3042    window_size: usize,
3043    window: VecDeque<Decimal>,
3044    sum: Decimal,
3045    sum_sq: Decimal,
3046}
3047
3048impl ZScoreNormalizer {
3049    /// Create a new z-score normalizer with the given rolling window size.
3050    ///
3051    /// # Errors
3052    ///
3053    /// Returns [`StreamError::ConfigError`] if `window_size == 0`.
3054    pub fn new(window_size: usize) -> Result<Self, StreamError> {
3055        if window_size == 0 {
3056            return Err(StreamError::ConfigError {
3057                reason: "ZScoreNormalizer window_size must be > 0".into(),
3058            });
3059        }
3060        Ok(Self {
3061            window_size,
3062            window: VecDeque::with_capacity(window_size),
3063            sum: Decimal::ZERO,
3064            sum_sq: Decimal::ZERO,
3065        })
3066    }
3067
3068    /// Add a new observation to the rolling window.
3069    ///
3070    /// Evicts the oldest value when the window is full, adjusting running sums
3071    /// in O(1). No full recompute is needed unless eviction causes sum drift;
3072    /// the implementation recomputes exactly when necessary via `recompute`.
3073    pub fn update(&mut self, value: Decimal) {
3074        if self.window.len() == self.window_size {
3075            let evicted = self.window.pop_front().unwrap_or(Decimal::ZERO);
3076            self.sum -= evicted;
3077            self.sum_sq -= evicted * evicted;
3078        }
3079        self.window.push_back(value);
3080        self.sum += value;
3081        self.sum_sq += value * value;
3082    }
3083
3084    /// Normalize `value` to a z-score using the current window's mean and std dev.
3085    ///
3086    /// Returns 0.0 if:
3087    /// - The window has fewer than 2 observations (std dev undefined).
3088    /// - The standard deviation is effectively zero (all window values identical).
3089    ///
3090    /// # Errors
3091    ///
3092    /// Returns [`StreamError::NormalizationError`] if the window is empty.
3093    ///
3094    /// # Complexity: O(1)
3095    #[must_use = "z-score is returned; ignoring it loses the normalized value"]
3096    pub fn normalize(&self, value: Decimal) -> Result<f64, StreamError> {
3097        let n = self.window.len();
3098        if n == 0 {
3099            return Err(StreamError::NormalizationError {
3100                reason: "window is empty; call update() before normalize()".into(),
3101            });
3102        }
3103        if n < 2 {
3104            return Ok(0.0);
3105        }
3106        let std_dev = self.std_dev().unwrap_or(0.0);
3107        if std_dev < f64::EPSILON {
3108            return Ok(0.0);
3109        }
3110        let mean = self.mean().ok_or_else(|| StreamError::NormalizationError {
3111            reason: "mean unavailable".into(),
3112        })?;
3113        let diff = value - mean;
3114        let diff_f64 = diff.to_f64().ok_or_else(|| StreamError::NormalizationError {
3115            reason: "Decimal-to-f64 conversion failed for diff".into(),
3116        })?;
3117        Ok(diff_f64 / std_dev)
3118    }
3119
3120    /// Current rolling mean of the window, or `None` if the window is empty.
3121    pub fn mean(&self) -> Option<Decimal> {
3122        if self.window.is_empty() {
3123            return None;
3124        }
3125        let n = Decimal::from(self.window.len() as u64);
3126        Some(self.sum / n)
3127    }
3128
3129    /// Current population standard deviation of the window.
3130    ///
3131    /// Returns `None` if the window is empty. Returns `Some(0.0)` if fewer
3132    /// than 2 observations are present (undefined variance, treated as zero)
3133    /// or if all values are identical.
3134    pub fn std_dev(&self) -> Option<f64> {
3135        let n = self.window.len();
3136        if n == 0 {
3137            return None;
3138        }
3139        if n < 2 {
3140            return Some(0.0);
3141        }
3142        self.variance_f64().map(f64::sqrt)
3143    }
3144
3145    /// Reset the normalizer, clearing all observations and sums.
3146    pub fn reset(&mut self) {
3147        self.window.clear();
3148        self.sum = Decimal::ZERO;
3149        self.sum_sq = Decimal::ZERO;
3150    }
3151
3152    /// Number of observations currently in the window.
3153    pub fn len(&self) -> usize {
3154        self.window.len()
3155    }
3156
3157    /// Returns `true` if no observations have been added since construction or reset.
3158    pub fn is_empty(&self) -> bool {
3159        self.window.is_empty()
3160    }
3161
3162    /// The configured window size.
3163    pub fn window_size(&self) -> usize {
3164        self.window_size
3165    }
3166
3167    /// Returns `true` when the window holds exactly `window_size` observations.
3168    ///
3169    /// At full capacity the z-score calculation is stable; before this point
3170    /// the window may not be representative of the underlying distribution.
3171    pub fn is_full(&self) -> bool {
3172        self.window.len() == self.window_size
3173    }
3174
3175    /// Running sum of all values currently in the window.
3176    ///
3177    /// Returns `None` if the window is empty. Useful for deriving a rolling
3178    /// mean without calling [`normalize`](Self::normalize).
3179    pub fn sum(&self) -> Option<Decimal> {
3180        if self.window.is_empty() {
3181            return None;
3182        }
3183        Some(self.sum)
3184    }
3185
3186    /// Current population variance of the window.
3187    ///
3188    /// Computed as `E[X²] − (E[X])²` from running sums in O(1). Returns
3189    /// `None` if fewer than 2 observations are present (variance undefined).
3190    pub fn variance(&self) -> Option<Decimal> {
3191        let n = self.window.len();
3192        if n < 2 {
3193            return None;
3194        }
3195        let n_dec = Decimal::from(n as u64);
3196        let mean = self.sum / n_dec;
3197        let v = (self.sum_sq / n_dec) - mean * mean;
3198        Some(if v < Decimal::ZERO { Decimal::ZERO } else { v })
3199    }
3200
3201    /// Standard deviation of the current window as `f64`.
3202    ///
3203    /// Returns `None` if the window has fewer than 2 observations.
3204    pub fn std_dev_f64(&self) -> Option<f64> {
3205        self.variance_f64().map(|v| v.sqrt())
3206    }
3207
3208    /// Current window variance as `f64` (convenience wrapper around [`variance`](Self::variance)).
3209    ///
3210    /// Returns `None` if the window has fewer than 2 observations.
3211    pub fn variance_f64(&self) -> Option<f64> {
3212        use rust_decimal::prelude::ToPrimitive;
3213        self.variance()?.to_f64()
3214    }
3215
3216    /// Feed a slice of values into the window and return z-scores for each.
3217    ///
3218    /// Each value is first passed through [`update`](Self::update) to advance
3219    /// the rolling window, then normalized. The output has the same length as
3220    /// `values`.
3221    ///
3222    /// # Errors
3223    ///
3224    /// Propagates the first [`StreamError`] returned by [`normalize`](Self::normalize).
3225    pub fn normalize_batch(
3226        &mut self,
3227        values: &[Decimal],
3228    ) -> Result<Vec<f64>, StreamError> {
3229        values
3230            .iter()
3231            .map(|&v| {
3232                self.update(v);
3233                self.normalize(v)
3234            })
3235            .collect()
3236    }
3237
3238    /// Returns `true` if `value` is an outlier: its z-score exceeds `z_threshold` in magnitude.
3239    ///
3240    /// Returns `false` when the window has fewer than 2 observations (z-score undefined).
3241    /// A typical threshold is `2.0` (95th percentile) or `3.0` (99.7th percentile).
3242    pub fn is_outlier(&self, value: Decimal, z_threshold: f64) -> bool {
3243        use rust_decimal::prelude::ToPrimitive;
3244        if self.window.len() < 2 {
3245            return false;
3246        }
3247        let sd = self.std_dev().unwrap_or(0.0);
3248        if sd == 0.0 {
3249            return false;
3250        }
3251        let Some(mean_f64) = self.mean().and_then(|m| m.to_f64()) else { return false; };
3252        let val_f64 = value.to_f64().unwrap_or(mean_f64);
3253        ((val_f64 - mean_f64) / sd).abs() > z_threshold
3254    }
3255
3256    /// Percentile rank: fraction of window observations that are ≤ `value`.
3257    ///
3258    /// Returns `None` if the window is empty. Range: `[0.0, 1.0]`.
3259    pub fn percentile_rank(&self, value: Decimal) -> Option<f64> {
3260        if self.window.is_empty() {
3261            return None;
3262        }
3263        let count = self.window.iter().filter(|&&v| v <= value).count();
3264        Some(count as f64 / self.window.len() as f64)
3265    }
3266
3267    /// Minimum value seen in the current window.
3268    ///
3269    /// Returns `None` when the window is empty.
3270    pub fn running_min(&self) -> Option<Decimal> {
3271        self.window.iter().copied().reduce(Decimal::min)
3272    }
3273
3274    /// Maximum value seen in the current window.
3275    ///
3276    /// Returns `None` when the window is empty.
3277    pub fn running_max(&self) -> Option<Decimal> {
3278        self.window.iter().copied().reduce(Decimal::max)
3279    }
3280
3281    /// Range of values in the current window: `running_max − running_min`.
3282    ///
3283    /// Returns `None` when the window is empty.
3284    pub fn window_range(&self) -> Option<Decimal> {
3285        let min = self.running_min()?;
3286        let max = self.running_max()?;
3287        Some(max - min)
3288    }
3289
3290    /// Coefficient of variation: `std_dev / |mean|`.
3291    ///
3292    /// A dimensionless measure of relative dispersion. Returns `None` when the
3293    /// window has fewer than 2 observations or when the mean is zero.
3294    pub fn coefficient_of_variation(&self) -> Option<f64> {
3295        let mean = self.mean()?;
3296        if mean.is_zero() {
3297            return None;
3298        }
3299        let std_dev = self.std_dev()?;
3300        let mean_f = mean.abs().to_f64()?;
3301        Some(std_dev / mean_f)
3302    }
3303
3304    /// Population variance of the current window as `f64`: `std_dev²`.
3305    ///
3306    /// Note: despite the name, this computes *population* variance (divides by
3307    /// `n`), consistent with [`std_dev`](Self::std_dev) and
3308    /// [`variance`](Self::variance). Returns `None` when the window has fewer
3309    /// than 2 observations.
3310    pub fn sample_variance(&self) -> Option<f64> {
3311        let sd = self.std_dev()?;
3312        Some(sd * sd)
3313    }
3314
3315    /// Current window mean as `f64`.
3316    ///
3317    /// A convenience over calling `mean()` and then converting to `f64`.
3318    /// Returns `None` when the window is empty.
3319    pub fn window_mean_f64(&self) -> Option<f64> {
3320        use rust_decimal::prelude::ToPrimitive;
3321        self.mean()?.to_f64()
3322    }
3323
3324    /// Returns `true` if `value` is within `sigma_tolerance` standard
3325    /// deviations of the window mean (inclusive).
3326    ///
3327    /// Equivalent to `|z_score(value)| <= sigma_tolerance`.  Returns `false`
3328    /// when the window has fewer than 2 observations (z-score undefined).
3329    pub fn is_near_mean(&self, value: Decimal, sigma_tolerance: f64) -> bool {
3330        // Requires at least 2 observations; with n < 2 the z-score is undefined.
3331        if self.window.len() < 2 {
3332            return false;
3333        }
3334        let Some(std_dev) = self.std_dev() else { return false; };
3335        if std_dev == 0.0 {
3336            return true;
3337        }
3338        let Some(mean) = self.mean() else { return false; };
3339        use rust_decimal::prelude::ToPrimitive;
3340        let diff = (value - mean).abs().to_f64().unwrap_or(f64::MAX);
3341        diff / std_dev <= sigma_tolerance
3342    }
3343
3344    /// Sum of all values currently in the window as `Decimal`.
3345    ///
3346    /// Returns `Decimal::ZERO` on an empty window.
3347    pub fn window_sum(&self) -> Decimal {
3348        self.sum
3349    }
3350
3351    /// Sum of all values currently in the window as `f64`.
3352    ///
3353    /// Returns `0.0` on an empty window.
3354    pub fn window_sum_f64(&self) -> f64 {
3355        use rust_decimal::prelude::ToPrimitive;
3356        self.sum.to_f64().unwrap_or(0.0)
3357    }
3358
3359    /// Maximum value currently in the window as `f64`.
3360    ///
3361    /// Returns `None` when the window is empty.
3362    pub fn window_max_f64(&self) -> Option<f64> {
3363        use rust_decimal::prelude::ToPrimitive;
3364        self.running_max()?.to_f64()
3365    }
3366
3367    /// Minimum value currently in the window as `f64`.
3368    ///
3369    /// Returns `None` when the window is empty.
3370    pub fn window_min_f64(&self) -> Option<f64> {
3371        use rust_decimal::prelude::ToPrimitive;
3372        self.running_min()?.to_f64()
3373    }
3374
3375    /// Difference between the window maximum and minimum, as `f64`.
3376    ///
3377    /// Returns `None` if the window is empty.
3378    pub fn window_span_f64(&self) -> Option<f64> {
3379        use rust_decimal::prelude::ToPrimitive;
3380        self.window_range()?.to_f64()
3381    }
3382
3383    /// Excess kurtosis of the window: `(Σ((x-mean)⁴/n) / std_dev⁴) - 3`.
3384    ///
3385    /// Returns `None` if the window has fewer than 4 observations or std dev is zero.
3386    /// A normal distribution has excess kurtosis of 0; positive values indicate
3387    /// heavier tails (leptokurtic); negative values indicate lighter tails (platykurtic).
3388    pub fn kurtosis(&self) -> Option<f64> {
3389        use rust_decimal::prelude::ToPrimitive;
3390        let n = self.window.len();
3391        if n < 4 {
3392            return None;
3393        }
3394        let n_f = n as f64;
3395        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
3396        if vals.len() < n {
3397            return None;
3398        }
3399        let mean = vals.iter().sum::<f64>() / n_f;
3400        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n_f;
3401        let std_dev = variance.sqrt();
3402        if std_dev == 0.0 {
3403            return None;
3404        }
3405        let kurt = vals.iter().map(|v| ((v - mean) / std_dev).powi(4)).sum::<f64>() / n_f - 3.0;
3406        Some(kurt)
3407    }
3408
3409    /// Fisher-Pearson skewness of the rolling window.
3410    ///
3411    /// Positive values indicate a right-tailed distribution; negative values
3412    /// indicate a left-tailed distribution. Returns `None` for fewer than 3
3413    /// observations or zero standard deviation.
3414    pub fn skewness(&self) -> Option<f64> {
3415        use rust_decimal::prelude::ToPrimitive;
3416        let n = self.window.len();
3417        if n < 3 {
3418            return None;
3419        }
3420        let n_f = n as f64;
3421        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
3422        if vals.len() < n {
3423            return None;
3424        }
3425        let mean = vals.iter().sum::<f64>() / n_f;
3426        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n_f;
3427        let std_dev = variance.sqrt();
3428        if std_dev == 0.0 {
3429            return None;
3430        }
3431        let skew = vals.iter().map(|v| ((v - mean) / std_dev).powi(3)).sum::<f64>() / n_f;
3432        Some(skew)
3433    }
3434
3435    /// Returns `true` if the z-score of `value` exceeds `sigma` in absolute terms.
3436    ///
3437    /// Convenience wrapper around [`normalize`](Self::normalize) for alert logic.
3438    /// Returns `false` if the normalizer window is empty or std-dev is zero.
3439    pub fn is_extreme(&self, value: Decimal, sigma: f64) -> bool {
3440        self.normalize(value).ok().map_or(false, |z| z.abs() > sigma)
3441    }
3442
3443    /// The most recently added value, or `None` if the window is empty.
3444    pub fn latest(&self) -> Option<Decimal> {
3445        self.window.back().copied()
3446    }
3447
3448    /// Median of the current window, or `None` if empty.
3449    pub fn median(&self) -> Option<Decimal> {
3450        if self.window.is_empty() { return None; }
3451        let mut vals: Vec<Decimal> = self.window.iter().copied().collect();
3452        vals.sort();
3453        let mid = vals.len() / 2;
3454        if vals.len() % 2 == 0 {
3455            Some((vals[mid - 1] + vals[mid]) / Decimal::TWO)
3456        } else {
3457            Some(vals[mid])
3458        }
3459    }
3460
3461    /// Empirical percentile of `value` within the current window: fraction of values ≤ `value`.
3462    ///
3463    /// Alias for [`percentile_rank`](Self::percentile_rank).
3464    pub fn percentile(&self, value: Decimal) -> Option<f64> {
3465        self.percentile_rank(value)
3466    }
3467
3468    /// Interquartile range: Q3 (75th percentile) − Q1 (25th percentile) of the window.
3469    ///
3470    /// Returns `None` if the window has fewer than 4 observations.
3471    /// The IQR is a robust spread measure less sensitive to outliers than range or std dev.
3472    pub fn interquartile_range(&self) -> Option<Decimal> {
3473        let n = self.window.len();
3474        if n < 4 {
3475            return None;
3476        }
3477        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
3478        sorted.sort();
3479        let q1_idx = n / 4;
3480        let q3_idx = 3 * n / 4;
3481        Some(sorted[q3_idx] - sorted[q1_idx])
3482    }
3483
3484    /// Stateless EMA z-score helper: updates running `ema_mean` and `ema_var` and returns
3485    /// the z-score `(value - ema_mean) / sqrt(ema_var)`.
3486    ///
3487    /// `alpha ∈ (0, 1]` controls smoothing speed (higher = faster adaptation).
3488    /// Initialize `ema_mean = 0.0` and `ema_var = 0.0` before first call.
3489    /// Returns `None` if `value` cannot be converted to f64 or variance is still zero.
3490    pub fn ema_z_score(value: Decimal, alpha: f64, ema_mean: &mut f64, ema_var: &mut f64) -> Option<f64> {
3491        use rust_decimal::prelude::ToPrimitive;
3492        let v = value.to_f64()?;
3493        let delta = v - *ema_mean;
3494        *ema_mean += alpha * delta;
3495        *ema_var = (1.0 - alpha) * (*ema_var + alpha * delta * delta);
3496        let std = ema_var.sqrt();
3497        if std == 0.0 { return None; }
3498        Some((v - *ema_mean) / std)
3499    }
3500
3501    /// Z-score of the most recently added value.
3502    ///
3503    /// Returns `None` if the window is empty or std-dev is zero.
3504    pub fn z_score_of_latest(&self) -> Option<f64> {
3505        let latest = self.latest()?;
3506        self.normalize(latest).ok()
3507    }
3508
3509    /// Exponential moving average of z-scores for all values in the current window.
3510    ///
3511    /// `alpha` is the smoothing factor (0 < alpha ≤ 1). Higher alpha gives more weight
3512    /// to recent z-scores. Returns `None` if the window has fewer than 2 observations.
3513    pub fn ema_of_z_scores(&self, alpha: f64) -> Option<f64> {
3514        let n = self.window.len();
3515        if n < 2 {
3516            return None;
3517        }
3518        let mut ema: Option<f64> = None;
3519        for &value in &self.window {
3520            if let Ok(z) = self.normalize(value) {
3521                ema = Some(match ema {
3522                    None => z,
3523                    Some(prev) => alpha * z + (1.0 - alpha) * prev,
3524                });
3525            }
3526        }
3527        ema
3528    }
3529
3530    /// Chainable alias for `update`: feeds `value` into the window and returns `&mut Self`.
3531    pub fn add_observation(&mut self, value: Decimal) -> &mut Self {
3532        self.update(value);
3533        self
3534    }
3535
3536    /// Signed deviation of `value` from the window mean, as `f64`.
3537    ///
3538    /// Returns `None` if the window is empty.
3539    pub fn deviation_from_mean(&self, value: Decimal) -> Option<f64> {
3540        use rust_decimal::prelude::ToPrimitive;
3541        let mean = self.mean()?.to_f64()?;
3542        value.to_f64().map(|v| v - mean)
3543    }
3544
3545    /// Returns a `Vec` of window values that are within `sigma` standard deviations of the mean.
3546    ///
3547    /// Useful for robust statistics after removing extreme outliers.
3548    /// Returns all values if std-dev is zero (no outliers possible), empty vec if window is empty.
3549    pub fn trim_outliers(&self, sigma: f64) -> Vec<Decimal> {
3550        use rust_decimal::prelude::ToPrimitive;
3551        if self.window.is_empty() { return vec![]; }
3552        let Some(mean) = self.mean() else { return vec![]; };
3553        let std = match self.std_dev() {
3554            Some(s) if s > 0.0 => s,
3555            _ => return self.window.iter().copied().collect(),
3556        };
3557        let Some(mean_f64) = mean.to_f64() else { return vec![]; };
3558        self.window.iter().copied()
3559            .filter(|v| {
3560                v.to_f64().map_or(false, |vf| ((vf - mean_f64) / std).abs() <= sigma)
3561            })
3562            .collect()
3563    }
3564
3565    /// Batch normalize: returns z-scores for each value as if they were added one-by-one.
3566    ///
3567    /// Each z-score uses only the window state after incorporating that value.
3568    /// The internal state is modified; call `reset()` if you need to restore it.
3569    /// Returns `None` entries where normalization fails (window warming up or zero std-dev).
3570    pub fn rolling_zscore_batch(&mut self, values: &[Decimal]) -> Vec<Option<f64>> {
3571        values.iter().map(|&v| {
3572            self.update(v);
3573            self.normalize(v).ok()
3574        }).collect()
3575    }
3576
3577    /// Change in mean between the first half and second half of the current window.
3578    ///
3579    /// Splits the window in two, computes the mean of each half, and returns
3580    /// `second_half_mean - first_half_mean` as `f64`. Returns `None` if the
3581    /// window has fewer than 2 observations.
3582    pub fn rolling_mean_change(&self) -> Option<f64> {
3583        let n = self.window.len();
3584        if n < 2 {
3585            return None;
3586        }
3587        let mid = n / 2;
3588        let first: Decimal = self.window.iter().take(mid).copied().sum::<Decimal>()
3589            / Decimal::from(mid as u64);
3590        let second: Decimal = self.window.iter().skip(mid).copied().sum::<Decimal>()
3591            / Decimal::from((n - mid) as u64);
3592        (second - first).to_f64()
3593    }
3594
3595    /// Count of window values whose z-score is strictly positive (above the mean).
3596    ///
3597    /// Returns `0` if the window is empty or all values are equal (z-scores are all 0).
3598    pub fn count_positive_z_scores(&self) -> usize {
3599        self.window
3600            .iter()
3601            .filter(|&&v| self.normalize(v).map_or(false, |z| z > 0.0))
3602            .count()
3603    }
3604
3605    /// Returns `true` if the absolute change between first-half and second-half window means
3606    /// is below `threshold`. A stable mean indicates the distribution is not trending.
3607    ///
3608    /// Returns `false` if the window has fewer than 2 observations.
3609    pub fn is_mean_stable(&self, threshold: f64) -> bool {
3610        self.rolling_mean_change().map_or(false, |c| c.abs() < threshold)
3611    }
3612
3613    /// Count of window values whose absolute z-score exceeds `z_threshold`.
3614    ///
3615    /// Returns `0` if the window has fewer than 2 observations or std-dev is zero.
3616    pub fn above_threshold_count(&self, z_threshold: f64) -> usize {
3617        self.window
3618            .iter()
3619            .filter(|&&v| {
3620                self.normalize(v)
3621                    .map_or(false, |z| z.abs() > z_threshold)
3622            })
3623            .count()
3624    }
3625
3626    /// Median Absolute Deviation (MAD) of the current window.
3627    ///
3628    /// `MAD = median(|x_i - median(window)|)`. Returns `None` if the window
3629    /// is empty.
3630    pub fn mad(&self) -> Option<Decimal> {
3631        let med = self.median()?;
3632        let mut deviations: Vec<Decimal> = self.window.iter().map(|&x| (x - med).abs()).collect();
3633        deviations.sort();
3634        let n = deviations.len();
3635        if n == 0 { return None; }
3636        let mid = n / 2;
3637        if n % 2 == 0 {
3638            Some((deviations[mid - 1] + deviations[mid]) / Decimal::TWO)
3639        } else {
3640            Some(deviations[mid])
3641        }
3642    }
3643
3644    /// Robust z-score: `(value - median) / MAD`.
3645    ///
3646    /// More resistant to outliers than the standard z-score. Returns `None`
3647    /// when the window is empty or MAD is zero.
3648    pub fn robust_z_score(&self, value: Decimal) -> Option<f64> {
3649        use rust_decimal::prelude::ToPrimitive;
3650        let med = self.median()?;
3651        let mad = self.mad()?;
3652        if mad.is_zero() { return None; }
3653        ((value - med) / mad).to_f64()
3654    }
3655
3656    /// Count of window values strictly above `threshold`.
3657    pub fn count_above(&self, threshold: Decimal) -> usize {
3658        self.window.iter().filter(|&&v| v > threshold).count()
3659    }
3660
3661    /// Count of window values strictly below `threshold`.
3662    pub fn count_below(&self, threshold: Decimal) -> usize {
3663        self.window.iter().filter(|&&v| v < threshold).count()
3664    }
3665
3666    /// Value at the p-th percentile of the current window (0.0 ≤ p ≤ 1.0).
3667    ///
3668    /// Uses linear interpolation between adjacent sorted values.
3669    /// Returns `None` if the window is empty.
3670    pub fn percentile_value(&self, p: f64) -> Option<Decimal> {
3671        if self.window.is_empty() {
3672            return None;
3673        }
3674        let p = p.clamp(0.0, 1.0);
3675        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
3676        sorted.sort();
3677        let n = sorted.len();
3678        if n == 1 {
3679            return Some(sorted[0]);
3680        }
3681        let idx = p * (n - 1) as f64;
3682        let lo = idx.floor() as usize;
3683        let hi = idx.ceil() as usize;
3684        if lo == hi {
3685            Some(sorted[lo])
3686        } else {
3687            let frac = Decimal::try_from(idx - lo as f64).ok()?;
3688            Some(sorted[lo] + (sorted[hi] - sorted[lo]) * frac)
3689        }
3690    }
3691
3692    /// Exponentially-weighted moving average (EWMA) of the window values.
3693    ///
3694    /// `alpha` is the smoothing factor in (0.0, 1.0]; higher values weight
3695    /// recent observations more. Processes values in insertion order (oldest first).
3696    /// Returns `None` if the window is empty.
3697    pub fn ewma(&self, alpha: f64) -> Option<f64> {
3698        use rust_decimal::prelude::ToPrimitive;
3699        let alpha = alpha.clamp(1e-9, 1.0);
3700        let mut iter = self.window.iter();
3701        let first = iter.next()?.to_f64()?;
3702        let result = iter.fold(first, |acc, &v| {
3703            let vf = v.to_f64().unwrap_or(acc);
3704            alpha * vf + (1.0 - alpha) * acc
3705        });
3706        Some(result)
3707    }
3708
3709    /// Midpoint between the running minimum and maximum in the window.
3710    ///
3711    /// Returns `None` if the window is empty.
3712    pub fn midpoint(&self) -> Option<Decimal> {
3713        let lo = self.running_min()?;
3714        let hi = self.running_max()?;
3715        Some((lo + hi) / Decimal::from(2u64))
3716    }
3717
3718    /// Clamps `value` to the [running_min, running_max] range of the window.
3719    ///
3720    /// Returns `value` unchanged if the window is empty.
3721    pub fn clamp_to_window(&self, value: Decimal) -> Decimal {
3722        match (self.running_min(), self.running_max()) {
3723            (Some(lo), Some(hi)) => value.max(lo).min(hi),
3724            _ => value,
3725        }
3726    }
3727
3728    /// Fraction of window values strictly above the midpoint between the
3729    /// running minimum and maximum.
3730    ///
3731    /// Returns `None` if the window is empty or min == max.
3732    pub fn fraction_above_mid(&self) -> Option<f64> {
3733        let lo = self.running_min()?;
3734        let hi = self.running_max()?;
3735        if lo == hi {
3736            return None;
3737        }
3738        let mid = (lo + hi) / Decimal::from(2u64);
3739        let above = self.window.iter().filter(|&&v| v > mid).count();
3740        Some(above as f64 / self.window.len() as f64)
3741    }
3742
3743    /// Ratio of the window span (max − min) to the mean.
3744    ///
3745    /// Returns `None` if the window is empty, has fewer than 2 elements,
3746    /// or the mean is zero.
3747    pub fn normalized_range(&self) -> Option<f64> {
3748        use rust_decimal::prelude::ToPrimitive;
3749        let span = self.window_range()?;
3750        let mean = self.mean()?;
3751        if mean.is_zero() {
3752            return None;
3753        }
3754        (span / mean).to_f64()
3755    }
3756
3757    /// Returns the (running_min, running_max) pair for the current window.
3758    ///
3759    /// Returns `None` if the window is empty.
3760    pub fn min_max(&self) -> Option<(Decimal, Decimal)> {
3761        Some((self.running_min()?, self.running_max()?))
3762    }
3763
3764    /// All current window values as a `Vec<Decimal>`, in insertion order (oldest first).
3765    pub fn values(&self) -> Vec<Decimal> {
3766        self.window.iter().copied().collect()
3767    }
3768
3769    /// Fraction of window values that are strictly positive (> 0).
3770    ///
3771    /// Returns `None` if the window is empty.
3772    pub fn above_zero_fraction(&self) -> Option<f64> {
3773        if self.window.is_empty() {
3774            return None;
3775        }
3776        let above = self.window.iter().filter(|&&v| v > Decimal::ZERO).count();
3777        Some(above as f64 / self.window.len() as f64)
3778    }
3779
3780    /// Z-score of `value` relative to the current window, returned as `Option<f64>`.
3781    ///
3782    /// Returns `None` if the window has fewer than 2 elements or variance is zero.
3783    /// Unlike [`normalize`], this never returns an error for empty windows — it
3784    /// simply returns `None`.
3785    pub fn z_score_opt(&self, value: Decimal) -> Option<f64> {
3786        self.normalize(value).ok()
3787    }
3788
3789    /// Returns `true` if the absolute z-score of the latest observation is
3790    /// within `z_threshold` standard deviations of the mean.
3791    ///
3792    /// Returns `false` if the window is empty or has insufficient data.
3793    pub fn is_stable(&self, z_threshold: f64) -> bool {
3794        self.z_score_of_latest()
3795            .map_or(false, |z| z.abs() <= z_threshold)
3796    }
3797
3798    /// Fraction of window values strictly above `threshold`.
3799    ///
3800    /// Returns `None` if the window is empty.
3801    pub fn fraction_above(&self, threshold: Decimal) -> Option<f64> {
3802        if self.window.is_empty() {
3803            return None;
3804        }
3805        Some(self.count_above(threshold) as f64 / self.window.len() as f64)
3806    }
3807
3808    /// Fraction of window values strictly below `threshold`.
3809    ///
3810    /// Returns `None` if the window is empty.
3811    pub fn fraction_below(&self, threshold: Decimal) -> Option<f64> {
3812        if self.window.is_empty() {
3813            return None;
3814        }
3815        Some(self.count_below(threshold) as f64 / self.window.len() as f64)
3816    }
3817
3818    /// Returns all window values strictly above `threshold`.
3819    pub fn window_values_above(&self, threshold: Decimal) -> Vec<Decimal> {
3820        self.window.iter().copied().filter(|&v| v > threshold).collect()
3821    }
3822
3823    /// Returns all window values strictly below `threshold`.
3824    pub fn window_values_below(&self, threshold: Decimal) -> Vec<Decimal> {
3825        self.window.iter().copied().filter(|&v| v < threshold).collect()
3826    }
3827
3828    /// Count of window values equal to `value`.
3829    pub fn count_equal(&self, value: Decimal) -> usize {
3830        self.window.iter().filter(|&&v| v == value).count()
3831    }
3832
3833    /// Range of the current window: `running_max - running_min`.
3834    ///
3835    /// Returns `None` if the window is empty.
3836    pub fn rolling_range(&self) -> Option<Decimal> {
3837        let lo = self.running_min()?;
3838        let hi = self.running_max()?;
3839        Some(hi - lo)
3840    }
3841
3842    /// Lag-1 autocorrelation of the window values.
3843    ///
3844    /// Returns `None` if fewer than 2 values or variance is zero.
3845    pub fn autocorrelation_lag1(&self) -> Option<f64> {
3846        use rust_decimal::prelude::ToPrimitive;
3847        let n = self.window.len();
3848        if n < 2 {
3849            return None;
3850        }
3851        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
3852        if vals.len() < 2 {
3853            return None;
3854        }
3855        let mean = vals.iter().sum::<f64>() / vals.len() as f64;
3856        let var: f64 = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / vals.len() as f64;
3857        if var == 0.0 {
3858            return None;
3859        }
3860        let cov: f64 = vals.windows(2).map(|w| (w[0] - mean) * (w[1] - mean)).sum::<f64>()
3861            / (vals.len() - 1) as f64;
3862        Some(cov / var)
3863    }
3864
3865    /// Fraction of consecutive pairs where the second value > first (trending upward).
3866    ///
3867    /// Returns `None` if fewer than 2 values.
3868    pub fn trend_consistency(&self) -> Option<f64> {
3869        let n = self.window.len();
3870        if n < 2 {
3871            return None;
3872        }
3873        let up = self.window.iter().collect::<Vec<_>>().windows(2)
3874            .filter(|w| w[1] > w[0]).count();
3875        Some(up as f64 / (n - 1) as f64)
3876    }
3877
3878    /// Mean absolute deviation of the window values.
3879    ///
3880    /// Returns `None` if window is empty.
3881    pub fn mean_absolute_deviation(&self) -> Option<f64> {
3882        use rust_decimal::prelude::ToPrimitive;
3883        if self.window.is_empty() {
3884            return None;
3885        }
3886        let n = self.window.len();
3887        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
3888        let mean = vals.iter().sum::<f64>() / n as f64;
3889        let mad = vals.iter().map(|v| (v - mean).abs()).sum::<f64>() / n as f64;
3890        Some(mad)
3891    }
3892
3893    /// Percentile rank of the most recently added value within the window.
3894    ///
3895    /// Returns `None` if no value has been added yet. Uses the same `<=`
3896    /// semantics as [`percentile`](Self::percentile).
3897    pub fn percentile_of_latest(&self) -> Option<f64> {
3898        let latest = self.latest()?;
3899        self.percentile(latest)
3900    }
3901
3902    /// Tail ratio: `max(window) / 75th-percentile(window)`.
3903    ///
3904    /// A simple fat-tail indicator. Values well above 1.0 signal that the
3905    /// maximum observation is an outlier relative to the upper quartile.
3906    /// Returns `None` if the window is empty or the 75th percentile is zero.
3907    pub fn tail_ratio(&self) -> Option<f64> {
3908        use rust_decimal::prelude::ToPrimitive;
3909        let max = self.running_max()?;
3910        let p75 = self.percentile_value(0.75)?;
3911        if p75.is_zero() {
3912            return None;
3913        }
3914        (max / p75).to_f64()
3915    }
3916
3917    /// Z-score of the window minimum relative to the current mean and std dev.
3918    ///
3919    /// Returns `None` if the window is empty or std dev is zero.
3920    pub fn z_score_of_min(&self) -> Option<f64> {
3921        let min = self.running_min()?;
3922        self.z_score_opt(min)
3923    }
3924
3925    /// Z-score of the window maximum relative to the current mean and std dev.
3926    ///
3927    /// Returns `None` if the window is empty or std dev is zero.
3928    pub fn z_score_of_max(&self) -> Option<f64> {
3929        let max = self.running_max()?;
3930        self.z_score_opt(max)
3931    }
3932
3933    /// Shannon entropy of the window values.
3934    ///
3935    /// Each unique value is treated as a category. Returns `None` if the
3936    /// window is empty. A uniform distribution maximises entropy; identical
3937    /// values give `Some(0.0)`.
3938    pub fn window_entropy(&self) -> Option<f64> {
3939        if self.window.is_empty() {
3940            return None;
3941        }
3942        let n = self.window.len() as f64;
3943        let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
3944        for v in &self.window {
3945            *counts.entry(v.to_string()).or_insert(0) += 1;
3946        }
3947        let entropy: f64 = counts.values().map(|&c| {
3948            let p = c as f64 / n;
3949            -p * p.ln()
3950        }).sum();
3951        Some(entropy)
3952    }
3953
3954    /// Normalised standard deviation (alias for [`coefficient_of_variation`](Self::coefficient_of_variation)).
3955    pub fn normalized_std_dev(&self) -> Option<f64> {
3956        self.coefficient_of_variation()
3957    }
3958
3959    /// Count of window values that are strictly above the window mean.
3960    ///
3961    /// Returns `None` if the window is empty or the mean cannot be computed.
3962    pub fn value_above_mean_count(&self) -> Option<usize> {
3963        let mean = self.mean()?;
3964        Some(self.window.iter().filter(|&&v| v > mean).count())
3965    }
3966
3967    /// Length of the longest consecutive run of values above the window mean.
3968    ///
3969    /// Returns `None` if the window is empty or the mean cannot be computed.
3970    pub fn consecutive_above_mean(&self) -> Option<usize> {
3971        let mean = self.mean()?;
3972        let mut max_run = 0usize;
3973        let mut current = 0usize;
3974        for &v in &self.window {
3975            if v > mean {
3976                current += 1;
3977                if current > max_run {
3978                    max_run = current;
3979                }
3980            } else {
3981                current = 0;
3982            }
3983        }
3984        Some(max_run)
3985    }
3986
3987    /// Fraction of window values above `threshold`.
3988    ///
3989    /// Returns `None` if the window is empty.
3990    pub fn above_threshold_fraction(&self, threshold: Decimal) -> Option<f64> {
3991        if self.window.is_empty() {
3992            return None;
3993        }
3994        let count = self.window.iter().filter(|&&v| v > threshold).count();
3995        Some(count as f64 / self.window.len() as f64)
3996    }
3997
3998    /// Fraction of window values below `threshold`.
3999    ///
4000    /// Returns `None` if the window is empty.
4001    pub fn below_threshold_fraction(&self, threshold: Decimal) -> Option<f64> {
4002        if self.window.is_empty() {
4003            return None;
4004        }
4005        let count = self.window.iter().filter(|&&v| v < threshold).count();
4006        Some(count as f64 / self.window.len() as f64)
4007    }
4008
4009    /// Autocorrelation at lag `k` of the window values.
4010    ///
4011    /// Returns `None` if `k == 0`, `k >= window.len()`, or variance is zero.
4012    pub fn lag_k_autocorrelation(&self, k: usize) -> Option<f64> {
4013        use rust_decimal::prelude::ToPrimitive;
4014        let n = self.window.len();
4015        if k == 0 || k >= n {
4016            return None;
4017        }
4018        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
4019        if vals.len() != n {
4020            return None;
4021        }
4022        let mean = vals.iter().sum::<f64>() / n as f64;
4023        let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
4024        if var == 0.0 {
4025            return None;
4026        }
4027        let m = n - k;
4028        let cov: f64 = (0..m).map(|i| (vals[i] - mean) * (vals[i + k] - mean)).sum::<f64>() / m as f64;
4029        Some(cov / var)
4030    }
4031
4032    /// Estimated half-life of mean reversion using a simple AR(1) regression.
4033    ///
4034    /// Half-life ≈ `-ln(2) / ln(|β|)` where β is the AR(1) coefficient. Returns
4035    /// `None` if the window has fewer than 3 values, the regression denominator
4036    /// is zero, or β ≥ 0 (no mean-reversion signal).
4037    pub fn half_life_estimate(&self) -> Option<f64> {
4038        use rust_decimal::prelude::ToPrimitive;
4039        let n = self.window.len();
4040        if n < 3 {
4041            return None;
4042        }
4043        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
4044        if vals.len() != n {
4045            return None;
4046        }
4047        let diffs: Vec<f64> = vals.windows(2).map(|w| w[1] - w[0]).collect();
4048        let lagged: Vec<f64> = vals[..n - 1].to_vec();
4049        let nf = diffs.len() as f64;
4050        let mean_l = lagged.iter().sum::<f64>() / nf;
4051        let mean_d = diffs.iter().sum::<f64>() / nf;
4052        let cov: f64 = lagged.iter().zip(diffs.iter()).map(|(l, d)| (l - mean_l) * (d - mean_d)).sum::<f64>();
4053        let var: f64 = lagged.iter().map(|l| (l - mean_l).powi(2)).sum::<f64>();
4054        if var == 0.0 {
4055            return None;
4056        }
4057        let beta = cov / var;
4058        if beta >= 0.0 {
4059            return None;
4060        }
4061        let lambda = (1.0 + beta).abs().ln();
4062        if lambda == 0.0 {
4063            return None;
4064        }
4065        Some(-std::f64::consts::LN_2 / lambda)
4066    }
4067
4068    /// Geometric mean of the window values.
4069    ///
4070    /// `exp(mean(ln(v_i)))`. Returns `None` if the window is empty or any
4071    /// value is non-positive.
4072    pub fn geometric_mean(&self) -> Option<f64> {
4073        use rust_decimal::prelude::ToPrimitive;
4074        if self.window.is_empty() {
4075            return None;
4076        }
4077        let logs: Vec<f64> = self.window.iter()
4078            .filter_map(|v| v.to_f64())
4079            .filter_map(|f| if f > 0.0 { Some(f.ln()) } else { None })
4080            .collect();
4081        if logs.len() != self.window.len() {
4082            return None;
4083        }
4084        Some((logs.iter().sum::<f64>() / logs.len() as f64).exp())
4085    }
4086
4087    /// Harmonic mean of the window values.
4088    ///
4089    /// `n / sum(1/v_i)`. Returns `None` if the window is empty or any value
4090    /// is zero.
4091    pub fn harmonic_mean(&self) -> Option<f64> {
4092        use rust_decimal::prelude::ToPrimitive;
4093        if self.window.is_empty() {
4094            return None;
4095        }
4096        let reciprocals: Vec<f64> = self.window.iter()
4097            .filter_map(|v| v.to_f64())
4098            .filter_map(|f| if f != 0.0 { Some(1.0 / f) } else { None })
4099            .collect();
4100        if reciprocals.len() != self.window.len() {
4101            return None;
4102        }
4103        let n = reciprocals.len() as f64;
4104        Some(n / reciprocals.iter().sum::<f64>())
4105    }
4106
4107    /// Normalise `value` to the window's observed `[min, max]` range.
4108    ///
4109    /// Returns `None` if the window is empty or the range is zero.
4110    pub fn range_normalized_value(&self, value: Decimal) -> Option<f64> {
4111        use rust_decimal::prelude::ToPrimitive;
4112        let min = self.running_min()?;
4113        let max = self.running_max()?;
4114        let range = max - min;
4115        if range.is_zero() {
4116            return None;
4117        }
4118        ((value - min) / range).to_f64()
4119    }
4120
4121    /// Signed distance of `value` from the window median.
4122    ///
4123    /// `value - median`. Returns `None` if the window is empty.
4124    pub fn distance_from_median(&self, value: Decimal) -> Option<f64> {
4125        use rust_decimal::prelude::ToPrimitive;
4126        let med = self.median()?;
4127        (value - med).to_f64()
4128    }
4129
4130    /// Momentum: difference between the latest and oldest value in the window.
4131    ///
4132    /// Positive = window trended up; negative = trended down. Returns `None`
4133    /// if fewer than 2 values are in the window.
4134    pub fn momentum(&self) -> Option<f64> {
4135        use rust_decimal::prelude::ToPrimitive;
4136        if self.window.len() < 2 {
4137            return None;
4138        }
4139        let oldest = *self.window.front()?;
4140        let latest = *self.window.back()?;
4141        (latest - oldest).to_f64()
4142    }
4143
4144    /// Rank of `value` within the current window, normalised to `[0.0, 1.0]`.
4145    ///
4146    /// 0.0 means `value` is ≤ all window values; 1.0 means it is ≥ all window
4147    /// values. Returns `None` if the window is empty.
4148    pub fn value_rank(&self, value: Decimal) -> Option<f64> {
4149        if self.window.is_empty() {
4150            return None;
4151        }
4152        let n = self.window.len();
4153        let below = self.window.iter().filter(|&&v| v < value).count();
4154        Some(below as f64 / n as f64)
4155    }
4156
4157    /// Coefficient of variation: `std_dev / |mean|`.
4158    ///
4159    /// Returns `None` if the window has fewer than 2 values or the mean is
4160    /// zero.
4161    pub fn coeff_of_variation(&self) -> Option<f64> {
4162        use rust_decimal::prelude::ToPrimitive;
4163        let n = self.window.len();
4164        if n < 2 {
4165            return None;
4166        }
4167        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
4168        if vals.len() < 2 {
4169            return None;
4170        }
4171        let nf = vals.len() as f64;
4172        let mean = vals.iter().sum::<f64>() / nf;
4173        if mean == 0.0 {
4174            return None;
4175        }
4176        let std_dev = (vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (nf - 1.0)).sqrt();
4177        Some(std_dev / mean.abs())
4178    }
4179
4180    /// Inter-quartile range: Q3 (75th percentile) minus Q1 (25th percentile).
4181    ///
4182    /// Returns `None` if the window is empty.
4183    pub fn quantile_range(&self) -> Option<f64> {
4184        use rust_decimal::prelude::ToPrimitive;
4185        let q3 = self.percentile_value(0.75)?;
4186        let q1 = self.percentile_value(0.25)?;
4187        (q3 - q1).to_f64()
4188    }
4189
4190    /// Upper quartile (Q3, 75th percentile) of the window values.
4191    ///
4192    /// Returns `None` if the window is empty.
4193    pub fn upper_quartile(&self) -> Option<Decimal> {
4194        self.percentile_value(0.75)
4195    }
4196
4197    /// Lower quartile (Q1, 25th percentile) of the window values.
4198    ///
4199    /// Returns `None` if the window is empty.
4200    pub fn lower_quartile(&self) -> Option<Decimal> {
4201        self.percentile_value(0.25)
4202    }
4203
4204    /// Fraction of consecutive first-difference pairs whose sign flips.
4205    ///
4206    /// A high value indicates a rapidly oscillating series;
4207    /// a low value indicates persistent trends. Returns `None` for fewer than
4208    /// 3 observations.
4209    pub fn sign_change_rate(&self) -> Option<f64> {
4210        let n = self.window.len();
4211        if n < 3 {
4212            return None;
4213        }
4214        let vals: Vec<&Decimal> = self.window.iter().collect();
4215        let diffs: Vec<i32> = vals
4216            .windows(2)
4217            .map(|w| {
4218                if w[1] > w[0] { 1 } else if w[1] < w[0] { -1 } else { 0 }
4219            })
4220            .collect();
4221        let total_pairs = (diffs.len() - 1) as f64;
4222        if total_pairs == 0.0 {
4223            return None;
4224        }
4225        let changes = diffs
4226            .windows(2)
4227            .filter(|w| w[0] != 0 && w[1] != 0 && w[0] != w[1])
4228            .count();
4229        Some(changes as f64 / total_pairs)
4230    }
4231
4232    // ── round-80 ─────────────────────────────────────────────────────────────
4233
4234    /// Trimmed mean: arithmetic mean after discarding the bottom and top
4235    /// `p` fraction of window values.
4236    ///
4237    /// `p` is clamped to `[0.0, 0.499]`. Returns `None` if the window is
4238    /// empty or trimming removes all observations.
4239    pub fn trimmed_mean(&self, p: f64) -> Option<f64> {
4240        use rust_decimal::prelude::ToPrimitive;
4241        if self.window.is_empty() {
4242            return None;
4243        }
4244        let p = p.clamp(0.0, 0.499);
4245        let mut sorted: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
4246        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4247        let n = sorted.len();
4248        let trim = (n as f64 * p).floor() as usize;
4249        let trimmed = &sorted[trim..n - trim];
4250        if trimmed.is_empty() {
4251            return None;
4252        }
4253        Some(trimmed.iter().sum::<f64>() / trimmed.len() as f64)
4254    }
4255
4256    /// OLS linear trend slope of window values over their insertion index.
4257    ///
4258    /// A positive slope indicates an upward trend; negative indicates downward.
4259    /// Returns `None` if the window has fewer than 2 observations.
4260    pub fn linear_trend_slope(&self) -> Option<f64> {
4261        use rust_decimal::prelude::ToPrimitive;
4262        let n = self.window.len();
4263        if n < 2 {
4264            return None;
4265        }
4266        let n_f = n as f64;
4267        let x_mean = (n_f - 1.0) / 2.0;
4268        let y_vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
4269        if y_vals.len() < 2 {
4270            return None;
4271        }
4272        let y_mean = y_vals.iter().sum::<f64>() / y_vals.len() as f64;
4273        let numerator: f64 = y_vals
4274            .iter()
4275            .enumerate()
4276            .map(|(i, &y)| (i as f64 - x_mean) * (y - y_mean))
4277            .sum();
4278        let denominator: f64 = (0..n).map(|i| (i as f64 - x_mean).powi(2)).sum();
4279        if denominator == 0.0 {
4280            return None;
4281        }
4282        Some(numerator / denominator)
4283    }
4284
4285}
4286
4287#[cfg(test)]
4288mod zscore_tests {
4289    use super::*;
4290    use rust_decimal_macros::dec;
4291
4292    fn znorm(w: usize) -> ZScoreNormalizer {
4293        ZScoreNormalizer::new(w).unwrap()
4294    }
4295
4296    #[test]
4297    fn test_zscore_new_zero_window_returns_error() {
4298        assert!(matches!(
4299            ZScoreNormalizer::new(0),
4300            Err(StreamError::ConfigError { .. })
4301        ));
4302    }
4303
4304    #[test]
4305    fn test_zscore_is_full_false_before_capacity() {
4306        let mut n = znorm(3);
4307        assert!(!n.is_full());
4308        n.update(dec!(1));
4309        n.update(dec!(2));
4310        assert!(!n.is_full());
4311        n.update(dec!(3));
4312        assert!(n.is_full());
4313    }
4314
4315    #[test]
4316    fn test_zscore_is_full_stays_true_after_eviction() {
4317        let mut n = znorm(3);
4318        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
4319            n.update(v);
4320        }
4321        assert!(n.is_full());
4322    }
4323
4324    #[test]
4325    fn test_zscore_empty_window_returns_error() {
4326        let n = znorm(4);
4327        assert!(matches!(
4328            n.normalize(dec!(1)),
4329            Err(StreamError::NormalizationError { .. })
4330        ));
4331    }
4332
4333    #[test]
4334    fn test_zscore_single_value_returns_zero() {
4335        let mut n = znorm(4);
4336        n.update(dec!(50));
4337        assert_eq!(n.normalize(dec!(50)).unwrap(), 0.0);
4338    }
4339
4340    #[test]
4341    fn test_zscore_mean_is_zero() {
4342        let mut n = znorm(5);
4343        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] {
4344            n.update(v);
4345        }
4346        // mean = 30; z-score of 30 should be 0
4347        let z = n.normalize(dec!(30)).unwrap();
4348        assert!((z - 0.0).abs() < 1e-9, "z-score of mean should be 0, got {z}");
4349    }
4350
4351    #[test]
4352    fn test_zscore_symmetric_around_mean() {
4353        let mut n = znorm(4);
4354        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
4355            n.update(v);
4356        }
4357        // mean = 25; values equidistant above and below mean have equal |z|
4358        let z_low = n.normalize(dec!(15)).unwrap();
4359        let z_high = n.normalize(dec!(35)).unwrap();
4360        assert!((z_low.abs() - z_high.abs()).abs() < 1e-9);
4361        assert!(z_low < 0.0, "below-mean z-score should be negative");
4362        assert!(z_high > 0.0, "above-mean z-score should be positive");
4363    }
4364
4365    #[test]
4366    fn test_zscore_all_same_returns_zero() {
4367        let mut n = znorm(4);
4368        for _ in 0..4 {
4369            n.update(dec!(100));
4370        }
4371        assert_eq!(n.normalize(dec!(100)).unwrap(), 0.0);
4372    }
4373
4374    #[test]
4375    fn test_zscore_rolling_window_eviction() {
4376        let mut n = znorm(3);
4377        n.update(dec!(1));
4378        n.update(dec!(2));
4379        n.update(dec!(3));
4380        // Evict 1, add 100 — window is [2, 3, 100]
4381        n.update(dec!(100));
4382        // mean ≈ 35; value 100 should have positive z-score
4383        let z = n.normalize(dec!(100)).unwrap();
4384        assert!(z > 0.0);
4385    }
4386
4387    #[test]
4388    fn test_zscore_reset_clears_state() {
4389        let mut n = znorm(4);
4390        for v in [dec!(10), dec!(20), dec!(30)] {
4391            n.update(v);
4392        }
4393        n.reset();
4394        assert!(n.is_empty());
4395        assert!(n.mean().is_none());
4396        assert!(matches!(
4397            n.normalize(dec!(1)),
4398            Err(StreamError::NormalizationError { .. })
4399        ));
4400    }
4401
4402    #[test]
4403    fn test_zscore_len_and_window_size() {
4404        let mut n = znorm(5);
4405        assert_eq!(n.len(), 0);
4406        assert!(n.is_empty());
4407        n.update(dec!(1));
4408        n.update(dec!(2));
4409        assert_eq!(n.len(), 2);
4410        assert_eq!(n.window_size(), 5);
4411    }
4412
4413    // ── std_dev ───────────────────────────────────────────────────────────────
4414
4415    #[test]
4416    fn test_std_dev_none_when_empty() {
4417        let n = znorm(5);
4418        assert!(n.std_dev().is_none());
4419    }
4420
4421    #[test]
4422    fn test_std_dev_zero_with_one_observation() {
4423        let mut n = znorm(5);
4424        n.update(dec!(42));
4425        assert_eq!(n.std_dev(), Some(0.0));
4426    }
4427
4428    #[test]
4429    fn test_std_dev_zero_when_all_same() {
4430        let mut n = znorm(4);
4431        for _ in 0..4 {
4432            n.update(dec!(10));
4433        }
4434        let sd = n.std_dev().unwrap();
4435        assert!(sd < f64::EPSILON);
4436    }
4437
4438    #[test]
4439    fn test_std_dev_positive_for_varying_values() {
4440        let mut n = znorm(4);
4441        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
4442            n.update(v);
4443        }
4444        let sd = n.std_dev().unwrap();
4445        // Population std dev of [10,20,30,40]: mean=25, var=125, sd≈11.18
4446        assert!((sd - 11.18).abs() < 0.01);
4447    }
4448
4449    // ── ZScoreNormalizer::variance ────────────────────────────────────────────
4450
4451    #[test]
4452    fn test_variance_none_when_fewer_than_two_observations() {
4453        let mut n = znorm(5);
4454        assert!(n.variance().is_none());
4455        n.update(dec!(10));
4456        assert!(n.variance().is_none());
4457    }
4458
4459    #[test]
4460    fn test_variance_zero_for_identical_values() {
4461        let mut n = znorm(4);
4462        for _ in 0..4 {
4463            n.update(dec!(7));
4464        }
4465        assert_eq!(n.variance().unwrap(), dec!(0));
4466    }
4467
4468    #[test]
4469    fn test_variance_correct_for_known_values() {
4470        let mut n = znorm(4);
4471        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
4472            n.update(v);
4473        }
4474        // Population variance of [10,20,30,40]: mean=25, var=125
4475        let var = n.variance().unwrap();
4476        let var_f64 = f64::try_from(var).unwrap();
4477        assert!((var_f64 - 125.0).abs() < 0.01, "expected 125 got {var_f64}");
4478    }
4479
4480    // ── ZScoreNormalizer::normalize_batch ─────────────────────────────────────
4481
4482    #[test]
4483    fn test_normalize_batch_same_length_as_input() {
4484        let mut n = znorm(5);
4485        let vals = [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)];
4486        let out = n.normalize_batch(&vals).unwrap();
4487        assert_eq!(out.len(), vals.len());
4488    }
4489
4490    #[test]
4491    fn test_normalize_batch_last_value_matches_single_normalize() {
4492        let mut n1 = znorm(5);
4493        let vals = [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)];
4494        let batch = n1.normalize_batch(&vals).unwrap();
4495
4496        let mut n2 = znorm(5);
4497        for &v in &vals {
4498            n2.update(v);
4499        }
4500        let single = n2.normalize(dec!(50)).unwrap();
4501        assert!((batch[4] - single).abs() < 1e-9);
4502    }
4503
4504    #[test]
4505    fn test_sum_empty_returns_none() {
4506        let n = znorm(4);
4507        assert!(n.sum().is_none());
4508    }
4509
4510    #[test]
4511    fn test_sum_matches_manual() {
4512        let mut n = znorm(4);
4513        n.update(dec!(10));
4514        n.update(dec!(20));
4515        n.update(dec!(30));
4516        // window = [10, 20, 30], sum = 60
4517        assert_eq!(n.sum().unwrap(), dec!(60));
4518    }
4519
4520    #[test]
4521    fn test_sum_evicts_old_values() {
4522        let mut n = znorm(2);
4523        n.update(dec!(10));
4524        n.update(dec!(20));
4525        n.update(dec!(30)); // evicts 10
4526        // window = [20, 30], sum = 50
4527        assert_eq!(n.sum().unwrap(), dec!(50));
4528    }
4529
4530    #[test]
4531    fn test_std_dev_single_observation_returns_some_zero() {
4532        let mut n = znorm(5);
4533        n.update(dec!(10));
4534        // Single sample → variance undefined, std_dev should return None or 0
4535        // ZScoreNormalizer::std_dev returns None for n < 2
4536        assert!(n.std_dev().is_none() || n.std_dev().unwrap() == 0.0);
4537    }
4538
4539    #[test]
4540    fn test_std_dev_constant_window_is_zero() {
4541        let mut n = znorm(4);
4542        for _ in 0..4 {
4543            n.update(dec!(5));
4544        }
4545        let sd = n.std_dev().unwrap();
4546        assert!(sd.abs() < 1e-9, "expected 0.0 got {sd}");
4547    }
4548
4549    #[test]
4550    fn test_std_dev_known_population() {
4551        // values [2, 4, 4, 4, 5, 5, 7, 9] → σ = 2.0
4552        let mut n = znorm(8);
4553        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
4554            n.update(v);
4555        }
4556        let sd = n.std_dev().unwrap();
4557        assert!((sd - 2.0).abs() < 1e-6, "expected ~2.0 got {sd}");
4558    }
4559
4560    // --- window_range / coefficient_of_variation ---
4561
4562    #[test]
4563    fn test_window_range_none_when_empty() {
4564        let n = znorm(5);
4565        assert!(n.window_range().is_none());
4566    }
4567
4568    #[test]
4569    fn test_window_range_correct_value() {
4570        let mut n = znorm(5);
4571        n.update(dec!(10));
4572        n.update(dec!(20));
4573        n.update(dec!(15));
4574        // max=20, min=10 → range=10
4575        assert_eq!(n.window_range().unwrap(), dec!(10));
4576    }
4577
4578    #[test]
4579    fn test_coefficient_of_variation_none_when_empty() {
4580        let n = znorm(5);
4581        assert!(n.coefficient_of_variation().is_none());
4582    }
4583
4584    #[test]
4585    fn test_coefficient_of_variation_none_when_mean_zero() {
4586        let mut n = znorm(5);
4587        n.update(dec!(-5));
4588        n.update(dec!(5)); // mean = 0
4589        assert!(n.coefficient_of_variation().is_none());
4590    }
4591
4592    #[test]
4593    fn test_coefficient_of_variation_positive_for_nonzero_mean() {
4594        let mut n = znorm(8);
4595        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
4596            n.update(v);
4597        }
4598        // mean = 5, std_dev = 2, cv = 2/5 = 0.4
4599        let cv = n.coefficient_of_variation().unwrap();
4600        assert!((cv - 0.4).abs() < 1e-5, "expected ~0.4 got {cv}");
4601    }
4602
4603    // --- sample_variance ---
4604
4605    #[test]
4606    fn test_sample_variance_none_when_empty() {
4607        let n = znorm(5);
4608        assert!(n.sample_variance().is_none());
4609    }
4610
4611    #[test]
4612    fn test_sample_variance_zero_for_constant_window() {
4613        let mut n = znorm(3);
4614        n.update(dec!(7));
4615        n.update(dec!(7));
4616        n.update(dec!(7));
4617        assert!(n.sample_variance().unwrap().abs() < 1e-10);
4618    }
4619
4620    #[test]
4621    fn test_sample_variance_equals_std_dev_squared() {
4622        let mut n = znorm(8);
4623        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
4624            n.update(v);
4625        }
4626        // std_dev ≈ 2.0, variance ≈ 4.0
4627        let variance = n.sample_variance().unwrap();
4628        let sd = n.std_dev().unwrap();
4629        assert!((variance - sd * sd).abs() < 1e-10);
4630    }
4631
4632    // --- window_mean_f64 ---
4633
4634    #[test]
4635    fn test_window_mean_f64_none_when_empty() {
4636        let n = znorm(5);
4637        assert!(n.window_mean_f64().is_none());
4638    }
4639
4640    #[test]
4641    fn test_window_mean_f64_correct_value() {
4642        let mut n = znorm(4);
4643        n.update(dec!(10));
4644        n.update(dec!(20));
4645        // mean = 15.0
4646        let m = n.window_mean_f64().unwrap();
4647        assert!((m - 15.0).abs() < 1e-10);
4648    }
4649
4650    #[test]
4651    fn test_window_mean_f64_matches_decimal_mean() {
4652        let mut n = znorm(8);
4653        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
4654            n.update(v);
4655        }
4656        use rust_decimal::prelude::ToPrimitive;
4657        let expected = n.mean().unwrap().to_f64().unwrap();
4658        assert!((n.window_mean_f64().unwrap() - expected).abs() < 1e-10);
4659    }
4660
4661    // ── ZScoreNormalizer::kurtosis ────────────────────────────────────────────
4662
4663    #[test]
4664    fn test_kurtosis_none_when_fewer_than_4_observations() {
4665        let mut n = znorm(5);
4666        n.update(dec!(1));
4667        n.update(dec!(2));
4668        n.update(dec!(3));
4669        assert!(n.kurtosis().is_none());
4670    }
4671
4672    #[test]
4673    fn test_kurtosis_returns_some_with_4_observations() {
4674        let mut n = znorm(4);
4675        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
4676            n.update(v);
4677        }
4678        assert!(n.kurtosis().is_some());
4679    }
4680
4681    #[test]
4682    fn test_kurtosis_none_when_all_same_value() {
4683        let mut n = znorm(4);
4684        for _ in 0..4 {
4685            n.update(dec!(5));
4686        }
4687        // std_dev = 0 → kurtosis is None
4688        assert!(n.kurtosis().is_none());
4689    }
4690
4691    #[test]
4692    fn test_kurtosis_uniform_distribution_is_negative() {
4693        // Uniform distribution has excess kurtosis of -1.2
4694        let mut n = znorm(10);
4695        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5),
4696                  dec!(6), dec!(7), dec!(8), dec!(9), dec!(10)] {
4697            n.update(v);
4698        }
4699        let k = n.kurtosis().unwrap();
4700        // Excess kurtosis of uniform dist over integers is negative
4701        assert!(k < 0.0, "expected negative excess kurtosis for uniform dist, got {k}");
4702    }
4703
4704    // --- ZScoreNormalizer::is_near_mean ---
4705    #[test]
4706    fn test_is_near_mean_false_with_fewer_than_two_obs() {
4707        let mut n = znorm(5);
4708        n.update(dec!(10));
4709        assert!(!n.is_near_mean(dec!(10), 1.0));
4710    }
4711
4712    #[test]
4713    fn test_is_near_mean_true_within_one_sigma() {
4714        let mut n = znorm(10);
4715        // Feed 10, 10, 10, ..., 10, 20 → mean≈11, std_dev small-ish
4716        for _ in 0..9 {
4717            n.update(dec!(10));
4718        }
4719        n.update(dec!(20));
4720        // mean = (90 + 20) / 10 = 11
4721        assert!(n.is_near_mean(dec!(11), 1.0));
4722    }
4723
4724    #[test]
4725    fn test_is_near_mean_false_when_far_from_mean() {
4726        let mut n = znorm(5);
4727        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] {
4728            n.update(v);
4729        }
4730        // mean = 3, std_dev ≈ 1.41; 100 is many sigmas away
4731        assert!(!n.is_near_mean(dec!(100), 2.0));
4732    }
4733
4734    #[test]
4735    fn test_is_near_mean_true_when_all_identical_any_value() {
4736        let mut n = znorm(4);
4737        for _ in 0..4 {
4738            n.update(dec!(7));
4739        }
4740        // std_dev = 0 → any value returns true
4741        assert!(n.is_near_mean(dec!(999), 0.0));
4742    }
4743
4744    // --- ZScoreNormalizer::window_sum_f64 ---
4745    #[test]
4746    fn test_window_sum_f64_zero_on_empty() {
4747        let n = znorm(5);
4748        assert_eq!(n.window_sum_f64(), 0.0);
4749    }
4750
4751    #[test]
4752    fn test_window_sum_f64_correct_after_updates() {
4753        let mut n = znorm(5);
4754        n.update(dec!(10));
4755        n.update(dec!(20));
4756        n.update(dec!(30));
4757        assert!((n.window_sum_f64() - 60.0).abs() < 1e-10);
4758    }
4759
4760    #[test]
4761    fn test_window_sum_f64_rolls_out_old_values() {
4762        let mut n = znorm(2);
4763        n.update(dec!(100));
4764        n.update(dec!(200));
4765        n.update(dec!(300)); // 100 rolls out
4766        // window contains 200, 300 → sum = 500
4767        assert!((n.window_sum_f64() - 500.0).abs() < 1e-10);
4768    }
4769
4770    // ── ZScoreNormalizer::latest ────────────────────────────────────────────
4771
4772    #[test]
4773    fn test_zscore_latest_none_when_empty() {
4774        let n = znorm(5);
4775        assert!(n.latest().is_none());
4776    }
4777
4778    #[test]
4779    fn test_zscore_latest_returns_most_recent() {
4780        let mut n = znorm(5);
4781        n.update(dec!(10));
4782        n.update(dec!(20));
4783        assert_eq!(n.latest(), Some(dec!(20)));
4784    }
4785
4786    #[test]
4787    fn test_zscore_latest_updates_on_roll() {
4788        let mut n = znorm(2);
4789        n.update(dec!(1));
4790        n.update(dec!(2));
4791        n.update(dec!(3)); // rolls out 1
4792        assert_eq!(n.latest(), Some(dec!(3)));
4793    }
4794
4795    // --- ZScoreNormalizer::window_max_f64 / window_min_f64 ---
4796    #[test]
4797    fn test_window_max_f64_none_on_empty() {
4798        let n = znorm(5);
4799        assert!(n.window_max_f64().is_none());
4800    }
4801
4802    #[test]
4803    fn test_window_max_f64_correct_value() {
4804        let mut n = znorm(5);
4805        for v in [dec!(3), dec!(7), dec!(1), dec!(5)] {
4806            n.update(v);
4807        }
4808        assert!((n.window_max_f64().unwrap() - 7.0).abs() < 1e-10);
4809    }
4810
4811    #[test]
4812    fn test_window_min_f64_none_on_empty() {
4813        let n = znorm(5);
4814        assert!(n.window_min_f64().is_none());
4815    }
4816
4817    #[test]
4818    fn test_window_min_f64_correct_value() {
4819        let mut n = znorm(5);
4820        for v in [dec!(3), dec!(7), dec!(1), dec!(5)] {
4821            n.update(v);
4822        }
4823        assert!((n.window_min_f64().unwrap() - 1.0).abs() < 1e-10);
4824    }
4825
4826    // ── ZScoreNormalizer::percentile ────────────────────────────────────────
4827
4828    #[test]
4829    fn test_percentile_none_when_empty() {
4830        let n = znorm(5);
4831        assert!(n.percentile(dec!(10)).is_none());
4832    }
4833
4834    #[test]
4835    fn test_percentile_one_when_all_lte_value() {
4836        let mut n = znorm(4);
4837        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
4838            n.update(v);
4839        }
4840        assert!((n.percentile(dec!(4)).unwrap() - 1.0).abs() < 1e-9);
4841    }
4842
4843    #[test]
4844    fn test_percentile_zero_when_all_gt_value() {
4845        let mut n = znorm(4);
4846        for v in [dec!(5), dec!(6), dec!(7), dec!(8)] {
4847            n.update(v);
4848        }
4849        // 0 of 4 values are ≤ 4
4850        assert_eq!(n.percentile(dec!(4)).unwrap(), 0.0);
4851    }
4852
4853    #[test]
4854    fn test_percentile_half_at_median() {
4855        let mut n = znorm(4);
4856        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
4857            n.update(v);
4858        }
4859        // 2 of 4 values ≤ 2 → 0.5
4860        assert!((n.percentile(dec!(2)).unwrap() - 0.5).abs() < 1e-9);
4861    }
4862
4863    // ── ZScoreNormalizer::interquartile_range ────────────────────────────────
4864
4865    #[test]
4866    fn test_zscore_iqr_none_fewer_than_4_observations() {
4867        let mut n = znorm(5);
4868        for v in [dec!(1), dec!(2), dec!(3)] {
4869            n.update(v);
4870        }
4871        assert!(n.interquartile_range().is_none());
4872    }
4873
4874    #[test]
4875    fn test_zscore_iqr_some_with_4_observations() {
4876        let mut n = znorm(4);
4877        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
4878            n.update(v);
4879        }
4880        assert!(n.interquartile_range().is_some());
4881    }
4882
4883    #[test]
4884    fn test_zscore_iqr_zero_when_all_same() {
4885        let mut n = znorm(4);
4886        for _ in 0..4 {
4887            n.update(dec!(5));
4888        }
4889        assert_eq!(n.interquartile_range(), Some(dec!(0)));
4890    }
4891
4892    #[test]
4893    fn test_zscore_iqr_correct_for_sorted_data() {
4894        // [1,2,3,4,5,6,7,8]: q1_idx=2 → sorted[2]=3, q3_idx=6 → sorted[6]=7, IQR=4
4895        let mut n = znorm(8);
4896        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(6), dec!(7), dec!(8)] {
4897            n.update(v);
4898        }
4899        assert_eq!(n.interquartile_range(), Some(dec!(4)));
4900    }
4901
4902    // ── ZScoreNormalizer::z_score_of_latest / deviation_from_mean ───────────
4903
4904    #[test]
4905    fn test_z_score_of_latest_none_when_empty() {
4906        let n = znorm(5);
4907        assert!(n.z_score_of_latest().is_none());
4908    }
4909
4910    #[test]
4911    fn test_z_score_of_latest_zero_when_all_same() {
4912        let mut n = znorm(4);
4913        for _ in 0..4 {
4914            n.update(dec!(5));
4915        }
4916        // std_dev = 0 → normalize returns Ok(0.0) → z_score_of_latest returns Some(0.0)
4917        assert_eq!(n.z_score_of_latest(), Some(0.0));
4918    }
4919
4920    #[test]
4921    fn test_z_score_of_latest_returns_some_with_variance() {
4922        let mut n = znorm(4);
4923        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
4924            n.update(v);
4925        }
4926        // latest = 4; should produce Some
4927        assert!(n.z_score_of_latest().is_some());
4928    }
4929
4930    #[test]
4931    fn test_deviation_from_mean_none_when_empty() {
4932        let n = znorm(5);
4933        assert!(n.deviation_from_mean(dec!(10)).is_none());
4934    }
4935
4936    #[test]
4937    fn test_deviation_from_mean_correct() {
4938        let mut n = znorm(4);
4939        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
4940            n.update(v);
4941        }
4942        // mean = 2.5, value = 4 → deviation = 1.5
4943        let d = n.deviation_from_mean(dec!(4)).unwrap();
4944        assert!((d - 1.5).abs() < 1e-9);
4945    }
4946
4947    // ── ZScoreNormalizer::add_observation ─────────────────────────────────────
4948
4949    #[test]
4950    fn test_add_observation_same_as_update() {
4951        let mut n1 = znorm(4);
4952        let mut n2 = znorm(4);
4953        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
4954            n1.update(v);
4955            n2.add_observation(v);
4956        }
4957        assert_eq!(n1.mean(), n2.mean());
4958    }
4959
4960    #[test]
4961    fn test_add_observation_chainable() {
4962        let mut n = znorm(4);
4963        n.add_observation(dec!(1))
4964         .add_observation(dec!(2))
4965         .add_observation(dec!(3));
4966        assert_eq!(n.len(), 3);
4967    }
4968
4969    // ── ZScoreNormalizer::variance_f64 ────────────────────────────────────────
4970
4971    #[test]
4972    fn test_variance_f64_none_when_single_observation() {
4973        let mut n = znorm(4);
4974        n.update(dec!(5));
4975        assert!(n.variance_f64().is_none());
4976    }
4977
4978    #[test]
4979    fn test_variance_f64_zero_when_all_same() {
4980        let mut n = znorm(4);
4981        for _ in 0..4 { n.update(dec!(5)); }
4982        assert_eq!(n.variance_f64(), Some(0.0));
4983    }
4984
4985    #[test]
4986    fn test_variance_f64_positive_with_spread() {
4987        let mut n = znorm(4);
4988        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
4989        assert!(n.variance_f64().unwrap() > 0.0);
4990    }
4991
4992    // ── ZScoreNormalizer::ema_of_z_scores ────────────────────────────────────
4993
4994    #[test]
4995    fn test_ema_of_z_scores_none_when_single_value() {
4996        let mut n = znorm(4);
4997        n.update(dec!(5));
4998        assert!(n.ema_of_z_scores(0.5).is_none());
4999    }
5000
5001    #[test]
5002    fn test_ema_of_z_scores_returns_some_with_variance() {
5003        let mut n = znorm(4);
5004        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
5005            n.update(v);
5006        }
5007        let ema = n.ema_of_z_scores(0.3);
5008        assert!(ema.is_some());
5009    }
5010
5011    #[test]
5012    fn test_ema_of_z_scores_zero_when_all_same() {
5013        let mut n = znorm(4);
5014        for _ in 0..4 { n.update(dec!(5)); }
5015        // All z-scores are 0.0 → EMA = 0.0
5016        assert_eq!(n.ema_of_z_scores(0.5), Some(0.0));
5017    }
5018
5019    // ── ZScoreNormalizer::std_dev_f64 ─────────────────────────────────────────
5020
5021    #[test]
5022    fn test_std_dev_f64_none_when_single_observation() {
5023        let mut n = znorm(4);
5024        n.update(dec!(5));
5025        assert!(n.std_dev_f64().is_none());
5026    }
5027
5028    #[test]
5029    fn test_std_dev_f64_zero_when_all_same() {
5030        let mut n = znorm(4);
5031        for _ in 0..4 { n.update(dec!(5)); }
5032        assert_eq!(n.std_dev_f64(), Some(0.0));
5033    }
5034
5035    #[test]
5036    fn test_std_dev_f64_equals_sqrt_of_variance() {
5037        let mut n = znorm(4);
5038        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
5039        let var = n.variance_f64().unwrap();
5040        let std = n.std_dev_f64().unwrap();
5041        assert!((std - var.sqrt()).abs() < 1e-12);
5042    }
5043
5044    // ── rolling_mean_change ───────────────────────────────────────────────────
5045
5046    #[test]
5047    fn test_rolling_mean_change_none_when_one_observation() {
5048        let mut n = znorm(4);
5049        n.update(dec!(5));
5050        assert!(n.rolling_mean_change().is_none());
5051    }
5052
5053    #[test]
5054    fn test_rolling_mean_change_positive_when_rising() {
5055        let mut n = znorm(4);
5056        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
5057        // first half [1,2] mean=1.5, second half [3,4] mean=3.5 → change=2.0
5058        let change = n.rolling_mean_change().unwrap();
5059        assert!((change - 2.0).abs() < 1e-9);
5060    }
5061
5062    #[test]
5063    fn test_rolling_mean_change_negative_when_falling() {
5064        let mut n = znorm(4);
5065        for v in [dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
5066        let change = n.rolling_mean_change().unwrap();
5067        assert!(change < 0.0);
5068    }
5069
5070    #[test]
5071    fn test_rolling_mean_change_zero_when_flat() {
5072        let mut n = znorm(4);
5073        for _ in 0..4 { n.update(dec!(7)); }
5074        let change = n.rolling_mean_change().unwrap();
5075        assert!(change.abs() < 1e-9);
5076    }
5077
5078    // ── window_span_f64 ───────────────────────────────────────────────────────
5079
5080    #[test]
5081    fn test_window_span_f64_none_when_empty() {
5082        let n = znorm(4);
5083        assert!(n.window_span_f64().is_none());
5084    }
5085
5086    #[test]
5087    fn test_window_span_f64_zero_when_all_same() {
5088        let mut n = znorm(4);
5089        for _ in 0..4 { n.update(dec!(5)); }
5090        assert_eq!(n.window_span_f64(), Some(0.0));
5091    }
5092
5093    #[test]
5094    fn test_window_span_f64_correct_value() {
5095        let mut n = znorm(4);
5096        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
5097        // max=40, min=10, span=30
5098        assert!((n.window_span_f64().unwrap() - 30.0).abs() < 1e-9);
5099    }
5100
5101    // ── count_positive_z_scores ───────────────────────────────────────────────
5102
5103    #[test]
5104    fn test_count_positive_z_scores_zero_when_empty() {
5105        let n = znorm(4);
5106        assert_eq!(n.count_positive_z_scores(), 0);
5107    }
5108
5109    #[test]
5110    fn test_count_positive_z_scores_zero_when_all_same() {
5111        let mut n = znorm(4);
5112        for _ in 0..4 { n.update(dec!(5)); }
5113        assert_eq!(n.count_positive_z_scores(), 0);
5114    }
5115
5116    #[test]
5117    fn test_count_positive_z_scores_half_above_mean() {
5118        let mut n = znorm(4);
5119        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
5120        // mean=2.5, values above: 3 and 4
5121        assert_eq!(n.count_positive_z_scores(), 2);
5122    }
5123
5124    // ── above_threshold_count ─────────────────────────────────────────────────
5125
5126    #[test]
5127    fn test_above_threshold_count_zero_when_empty() {
5128        let n = znorm(4);
5129        assert_eq!(n.above_threshold_count(1.0), 0);
5130    }
5131
5132    #[test]
5133    fn test_above_threshold_count_zero_when_all_same() {
5134        let mut n = znorm(4);
5135        for _ in 0..4 { n.update(dec!(5)); }
5136        assert_eq!(n.above_threshold_count(0.5), 0);
5137    }
5138
5139    #[test]
5140    fn test_above_threshold_count_correct_with_extremes() {
5141        let mut n = znorm(6);
5142        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(100)] { n.update(v); }
5143        // 100 is many std devs from mean; threshold=1.0 should catch it
5144        assert!(n.above_threshold_count(1.0) >= 1);
5145    }
5146}
5147
5148#[cfg(test)]
5149mod minmax_extra_tests {
5150    use super::*;
5151    use rust_decimal_macros::dec;
5152
5153    fn norm(w: usize) -> MinMaxNormalizer {
5154        MinMaxNormalizer::new(w).unwrap()
5155    }
5156
5157    // ── fraction_above_mid ────────────────────────────────────────────────────
5158
5159    #[test]
5160    fn test_fraction_above_mid_none_when_empty() {
5161        let mut n = norm(4);
5162        assert!(n.fraction_above_mid().is_none());
5163    }
5164
5165    #[test]
5166    fn test_fraction_above_mid_zero_when_all_same() {
5167        let mut n = norm(4);
5168        for _ in 0..4 { n.update(dec!(5)); }
5169        assert_eq!(n.fraction_above_mid(), Some(0.0));
5170    }
5171
5172    #[test]
5173    fn test_fraction_above_mid_half_when_symmetric() {
5174        let mut n = norm(4);
5175        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
5176        // mid = (1+4)/2 = 2.5, above: 3 and 4 = 2/4 = 0.5
5177        let f = n.fraction_above_mid().unwrap();
5178        assert!((f - 0.5).abs() < 1e-10);
5179    }
5180}
5181
5182#[cfg(test)]
5183mod zscore_stability_tests {
5184    use super::*;
5185    use rust_decimal_macros::dec;
5186
5187    fn znorm(w: usize) -> ZScoreNormalizer {
5188        ZScoreNormalizer::new(w).unwrap()
5189    }
5190
5191    // ── is_mean_stable ────────────────────────────────────────────────────────
5192
5193    #[test]
5194    fn test_is_mean_stable_false_when_window_too_small() {
5195        let n = znorm(4);
5196        assert!(!n.is_mean_stable(1.0));
5197    }
5198
5199    #[test]
5200    fn test_is_mean_stable_true_when_flat() {
5201        let mut n = znorm(4);
5202        for _ in 0..4 { n.update(dec!(5)); }
5203        assert!(n.is_mean_stable(0.001));
5204    }
5205
5206    #[test]
5207    fn test_is_mean_stable_false_when_trending() {
5208        let mut n = znorm(4);
5209        for v in [dec!(1), dec!(2), dec!(10), dec!(20)] { n.update(v); }
5210        assert!(!n.is_mean_stable(0.5));
5211    }
5212
5213    // ── ZScoreNormalizer::count_above / count_below ───────────────────────────
5214
5215    #[test]
5216    fn test_zscore_count_above_zero_for_empty_window() {
5217        assert_eq!(znorm(4).count_above(dec!(10)), 0);
5218    }
5219
5220    #[test]
5221    fn test_zscore_count_above_correct() {
5222        let mut n = znorm(5);
5223        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5224        // strictly above 3: [4, 5] → count = 2
5225        assert_eq!(n.count_above(dec!(3)), 2);
5226    }
5227
5228    #[test]
5229    fn test_zscore_count_below_correct() {
5230        let mut n = znorm(5);
5231        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5232        // strictly below 3: [1, 2] → count = 2
5233        assert_eq!(n.count_below(dec!(3)), 2);
5234    }
5235
5236    #[test]
5237    fn test_zscore_count_above_excludes_at_threshold() {
5238        let mut n = znorm(3);
5239        for v in [dec!(5), dec!(5), dec!(5)] { n.update(v); }
5240        assert_eq!(n.count_above(dec!(5)), 0);
5241        assert_eq!(n.count_below(dec!(5)), 0);
5242    }
5243
5244    // ── ZScoreNormalizer::skewness ────────────────────────────────────────────
5245
5246    #[test]
5247    fn test_zscore_skewness_none_for_fewer_than_3_obs() {
5248        let mut n = znorm(5);
5249        n.update(dec!(10));
5250        n.update(dec!(20));
5251        assert!(n.skewness().is_none());
5252    }
5253
5254    #[test]
5255    fn test_zscore_skewness_none_for_all_identical() {
5256        let mut n = znorm(4);
5257        for _ in 0..4 { n.update(dec!(5)); }
5258        assert!(n.skewness().is_none());
5259    }
5260
5261    #[test]
5262    fn test_zscore_skewness_near_zero_for_symmetric_distribution() {
5263        let mut n = znorm(5);
5264        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5265        let skew = n.skewness().unwrap();
5266        assert!(skew.abs() < 0.01, "symmetric distribution should have ~0 skewness, got {skew}");
5267    }
5268
5269    // ── ZScoreNormalizer::percentile_value ────────────────────────────────────
5270
5271    #[test]
5272    fn test_zscore_percentile_value_none_for_empty_window() {
5273        assert!(znorm(4).percentile_value(0.5).is_none());
5274    }
5275
5276    #[test]
5277    fn test_zscore_percentile_value_min_at_zero() {
5278        let mut n = znorm(5);
5279        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
5280        assert_eq!(n.percentile_value(0.0), Some(dec!(10)));
5281    }
5282
5283    #[test]
5284    fn test_zscore_percentile_value_max_at_one() {
5285        let mut n = znorm(5);
5286        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
5287        assert_eq!(n.percentile_value(1.0), Some(dec!(50)));
5288    }
5289
5290    // ── ZScoreNormalizer::ewma ────────────────────────────────────────────────
5291
5292    #[test]
5293    fn test_zscore_ewma_none_for_empty_window() {
5294        assert!(znorm(4).ewma(0.5).is_none());
5295    }
5296
5297    #[test]
5298    fn test_zscore_ewma_equals_value_for_single_obs() {
5299        let mut n = znorm(4);
5300        n.update(dec!(42));
5301        assert!((n.ewma(0.5).unwrap() - 42.0).abs() < 1e-10);
5302    }
5303
5304    #[test]
5305    fn test_zscore_ewma_weights_recent_more_with_high_alpha() {
5306        // With alpha=1.0 EWMA = last value
5307        let mut n = znorm(4);
5308        for v in [dec!(10), dec!(20), dec!(30), dec!(100)] { n.update(v); }
5309        let ewma = n.ewma(1.0).unwrap();
5310        assert!((ewma - 100.0).abs() < 1e-10);
5311    }
5312
5313    #[test]
5314    fn test_zscore_fraction_above_mid_none_for_empty_window() {
5315        let n = znorm(3);
5316        assert!(n.fraction_above_mid().is_none());
5317    }
5318
5319    #[test]
5320    fn test_zscore_fraction_above_mid_none_when_all_equal() {
5321        let mut n = znorm(3);
5322        for _ in 0..3 { n.update(dec!(5)); }
5323        assert!(n.fraction_above_mid().is_none());
5324    }
5325
5326    #[test]
5327    fn test_zscore_fraction_above_mid_half_above() {
5328        let mut n = znorm(4);
5329        for v in [dec!(0), dec!(10), dec!(6), dec!(4)] { n.update(v); }
5330        // mid = (0+10)/2 = 5; above 5: 10 and 6 → 2/4 = 0.5
5331        let frac = n.fraction_above_mid().unwrap();
5332        assert!((frac - 0.5).abs() < 1e-9);
5333    }
5334
5335    #[test]
5336    fn test_zscore_normalized_range_none_for_empty_window() {
5337        let n = znorm(3);
5338        assert!(n.normalized_range().is_none());
5339    }
5340
5341    #[test]
5342    fn test_zscore_normalized_range_zero_for_uniform_window() {
5343        let mut n = znorm(3);
5344        for _ in 0..3 { n.update(dec!(10)); }
5345        assert_eq!(n.normalized_range(), Some(0.0));
5346    }
5347
5348    #[test]
5349    fn test_zscore_normalized_range_positive_for_varying_window() {
5350        let mut n = znorm(3);
5351        for v in [dec!(8), dec!(10), dec!(12)] { n.update(v); }
5352        // span = 12 - 8 = 4, mean = 10, ratio = 0.4
5353        let nr = n.normalized_range().unwrap();
5354        assert!((nr - 0.4).abs() < 1e-9);
5355    }
5356
5357    // ── ZScoreNormalizer::midpoint ────────────────────────────────────────────
5358
5359    #[test]
5360    fn test_zscore_midpoint_none_for_empty_window() {
5361        assert!(znorm(3).midpoint().is_none());
5362    }
5363
5364    #[test]
5365    fn test_zscore_midpoint_correct_for_known_range() {
5366        let mut n = znorm(4);
5367        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
5368        // min=10, max=40, midpoint=25
5369        assert_eq!(n.midpoint(), Some(dec!(25)));
5370    }
5371
5372    // ── ZScoreNormalizer::clamp_to_window ─────────────────────────────────────
5373
5374    #[test]
5375    fn test_zscore_clamp_returns_value_unchanged_on_empty_window() {
5376        let n = znorm(3);
5377        assert_eq!(n.clamp_to_window(dec!(50)), dec!(50));
5378    }
5379
5380    #[test]
5381    fn test_zscore_clamp_clamps_to_min() {
5382        let mut n = znorm(3);
5383        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
5384        assert_eq!(n.clamp_to_window(dec!(-5)), dec!(10));
5385    }
5386
5387    #[test]
5388    fn test_zscore_clamp_clamps_to_max() {
5389        let mut n = znorm(3);
5390        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
5391        assert_eq!(n.clamp_to_window(dec!(100)), dec!(30));
5392    }
5393
5394    #[test]
5395    fn test_zscore_clamp_passes_through_in_range_value() {
5396        let mut n = znorm(3);
5397        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
5398        assert_eq!(n.clamp_to_window(dec!(15)), dec!(15));
5399    }
5400
5401    // ── ZScoreNormalizer::min_max ─────────────────────────────────────────────
5402
5403    #[test]
5404    fn test_zscore_min_max_none_for_empty_window() {
5405        assert!(znorm(3).min_max().is_none());
5406    }
5407
5408    #[test]
5409    fn test_zscore_min_max_returns_correct_pair() {
5410        let mut n = znorm(4);
5411        for v in [dec!(5), dec!(15), dec!(10), dec!(20)] { n.update(v); }
5412        assert_eq!(n.min_max(), Some((dec!(5), dec!(20))));
5413    }
5414
5415    #[test]
5416    fn test_zscore_min_max_single_value() {
5417        let mut n = znorm(3);
5418        n.update(dec!(42));
5419        assert_eq!(n.min_max(), Some((dec!(42), dec!(42))));
5420    }
5421
5422    // ── ZScoreNormalizer::values ──────────────────────────────────────────────
5423
5424    #[test]
5425    fn test_zscore_values_empty_for_empty_window() {
5426        assert!(znorm(3).values().is_empty());
5427    }
5428
5429    #[test]
5430    fn test_zscore_values_preserves_insertion_order() {
5431        let mut n = znorm(4);
5432        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
5433        assert_eq!(n.values(), vec![dec!(10), dec!(20), dec!(30), dec!(40)]);
5434    }
5435
5436    // ── ZScoreNormalizer::above_zero_fraction ─────────────────────────────────
5437
5438    #[test]
5439    fn test_zscore_above_zero_fraction_none_for_empty_window() {
5440        assert!(znorm(3).above_zero_fraction().is_none());
5441    }
5442
5443    #[test]
5444    fn test_zscore_above_zero_fraction_zero_for_all_negative() {
5445        let mut n = znorm(3);
5446        for v in [dec!(-3), dec!(-2), dec!(-1)] { n.update(v); }
5447        assert_eq!(n.above_zero_fraction(), Some(0.0));
5448    }
5449
5450    #[test]
5451    fn test_zscore_above_zero_fraction_one_for_all_positive() {
5452        let mut n = znorm(3);
5453        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
5454        assert_eq!(n.above_zero_fraction(), Some(1.0));
5455    }
5456
5457    #[test]
5458    fn test_zscore_above_zero_fraction_half_for_mixed() {
5459        let mut n = znorm(4);
5460        for v in [dec!(-2), dec!(-1), dec!(1), dec!(2)] { n.update(v); }
5461        let frac = n.above_zero_fraction().unwrap();
5462        assert!((frac - 0.5).abs() < 1e-9);
5463    }
5464
5465    // ── ZScoreNormalizer::z_score_opt ─────────────────────────────────────────
5466
5467    #[test]
5468    fn test_zscore_opt_none_for_empty_window() {
5469        assert!(znorm(3).z_score_opt(dec!(10)).is_none());
5470    }
5471
5472    #[test]
5473    fn test_zscore_opt_matches_normalize_for_populated_window() {
5474        let mut n = znorm(4);
5475        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
5476        let z_opt = n.z_score_opt(dec!(25)).unwrap();
5477        let z_norm = n.normalize(dec!(25)).unwrap();
5478        assert!((z_opt - z_norm).abs() < 1e-12);
5479    }
5480
5481    // ── ZScoreNormalizer::is_stable ───────────────────────────────────────────
5482
5483    #[test]
5484    fn test_zscore_is_stable_false_for_empty_window() {
5485        assert!(!znorm(3).is_stable(2.0));
5486    }
5487
5488    #[test]
5489    fn test_zscore_is_stable_true_for_near_mean_value() {
5490        let mut n = znorm(5);
5491        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(30)] { n.update(v); }
5492        // latest is 30, near mean of 26; should be stable with threshold=2
5493        assert!(n.is_stable(2.0));
5494    }
5495
5496    #[test]
5497    fn test_zscore_is_stable_false_for_extreme_value() {
5498        let mut n = znorm(5);
5499        for v in [dec!(10), dec!(10), dec!(10), dec!(10), dec!(100)] { n.update(v); }
5500        // latest is 100, far from mean ~28; should be unstable
5501        assert!(!n.is_stable(1.0));
5502    }
5503
5504    // ── ZScoreNormalizer::window_values_above / window_values_below (moved here) ─
5505
5506    #[test]
5507    fn test_zscore_window_values_above_via_znorm_empty() {
5508        assert!(znorm(3).window_values_above(dec!(5)).is_empty());
5509    }
5510
5511    #[test]
5512    fn test_zscore_window_values_above_via_znorm_filters() {
5513        let mut n = znorm(5);
5514        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
5515        let above = n.window_values_above(dec!(5));
5516        assert_eq!(above.len(), 2);
5517        assert!(above.contains(&dec!(7)));
5518        assert!(above.contains(&dec!(9)));
5519    }
5520
5521    #[test]
5522    fn test_zscore_window_values_below_via_znorm_empty() {
5523        assert!(znorm(3).window_values_below(dec!(5)).is_empty());
5524    }
5525
5526    #[test]
5527    fn test_zscore_window_values_below_via_znorm_filters() {
5528        let mut n = znorm(5);
5529        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
5530        let below = n.window_values_below(dec!(5));
5531        assert_eq!(below.len(), 2);
5532        assert!(below.contains(&dec!(1)));
5533        assert!(below.contains(&dec!(3)));
5534    }
5535
5536    // ── ZScoreNormalizer::fraction_above / fraction_below ─────────────────────
5537
5538    #[test]
5539    fn test_zscore_fraction_above_none_for_empty_window() {
5540        assert!(znorm(3).fraction_above(dec!(5)).is_none());
5541    }
5542
5543    #[test]
5544    fn test_zscore_fraction_above_correct() {
5545        let mut n = znorm(5);
5546        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5547        // above 3: {4, 5} = 2/5 = 0.4
5548        let frac = n.fraction_above(dec!(3)).unwrap();
5549        assert!((frac - 0.4).abs() < 1e-9);
5550    }
5551
5552    #[test]
5553    fn test_zscore_fraction_below_none_for_empty_window() {
5554        assert!(znorm(3).fraction_below(dec!(5)).is_none());
5555    }
5556
5557    #[test]
5558    fn test_zscore_fraction_below_correct() {
5559        let mut n = znorm(5);
5560        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5561        // below 3: {1, 2} = 2/5 = 0.4
5562        let frac = n.fraction_below(dec!(3)).unwrap();
5563        assert!((frac - 0.4).abs() < 1e-9);
5564    }
5565
5566    // ── ZScoreNormalizer::window_values_above / window_values_below ──────────
5567
5568    #[test]
5569    fn test_zscore_window_values_above_empty_window() {
5570        assert!(znorm(3).window_values_above(dec!(0)).is_empty());
5571    }
5572
5573    #[test]
5574    fn test_zscore_window_values_above_filters_correctly() {
5575        let mut n = znorm(5);
5576        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
5577        let above = n.window_values_above(dec!(5));
5578        assert_eq!(above.len(), 2);
5579        assert!(above.contains(&dec!(7)));
5580        assert!(above.contains(&dec!(9)));
5581    }
5582
5583    #[test]
5584    fn test_zscore_window_values_below_empty_window() {
5585        assert!(znorm(3).window_values_below(dec!(0)).is_empty());
5586    }
5587
5588    #[test]
5589    fn test_zscore_window_values_below_filters_correctly() {
5590        let mut n = znorm(5);
5591        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
5592        let below = n.window_values_below(dec!(5));
5593        assert_eq!(below.len(), 2);
5594        assert!(below.contains(&dec!(1)));
5595        assert!(below.contains(&dec!(3)));
5596    }
5597
5598    // ── ZScoreNormalizer::percentile_rank ─────────────────────────────────────
5599
5600    #[test]
5601    fn test_zscore_percentile_rank_none_for_empty_window() {
5602        assert!(znorm(3).percentile_rank(dec!(5)).is_none());
5603    }
5604
5605    #[test]
5606    fn test_zscore_percentile_rank_correct() {
5607        let mut n = znorm(5);
5608        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5609        // <= 3: {1, 2, 3} = 3/5 = 0.6
5610        let rank = n.percentile_rank(dec!(3)).unwrap();
5611        assert!((rank - 0.6).abs() < 1e-9);
5612    }
5613
5614    // ── ZScoreNormalizer::count_equal ─────────────────────────────────────────
5615
5616    #[test]
5617    fn test_zscore_count_equal_zero_for_no_match() {
5618        let mut n = znorm(3);
5619        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
5620        assert_eq!(n.count_equal(dec!(99)), 0);
5621    }
5622
5623    #[test]
5624    fn test_zscore_count_equal_counts_duplicates() {
5625        let mut n = znorm(5);
5626        for v in [dec!(5), dec!(5), dec!(3), dec!(5), dec!(2)] { n.update(v); }
5627        assert_eq!(n.count_equal(dec!(5)), 3);
5628    }
5629
5630    // ── ZScoreNormalizer::median ──────────────────────────────────────────────
5631
5632    #[test]
5633    fn test_zscore_median_none_for_empty_window() {
5634        assert!(znorm(3).median().is_none());
5635    }
5636
5637    #[test]
5638    fn test_zscore_median_correct_for_odd_count() {
5639        let mut n = znorm(5);
5640        for v in [dec!(3), dec!(1), dec!(5), dec!(4), dec!(2)] { n.update(v); }
5641        // sorted: 1,2,3,4,5 → middle (idx 2) = 3
5642        assert_eq!(n.median(), Some(dec!(3)));
5643    }
5644
5645    // ── ZScoreNormalizer::rolling_range ───────────────────────────────────────
5646
5647    #[test]
5648    fn test_zscore_rolling_range_none_for_empty() {
5649        assert!(znorm(3).rolling_range().is_none());
5650    }
5651
5652    #[test]
5653    fn test_zscore_rolling_range_correct() {
5654        let mut n = znorm(5);
5655        for v in [dec!(10), dec!(50), dec!(30), dec!(20), dec!(40)] { n.update(v); }
5656        assert_eq!(n.rolling_range(), Some(dec!(40)));
5657    }
5658
5659    // ── ZScoreNormalizer::skewness ─────────────────────────────────────────────
5660
5661    #[test]
5662    fn test_zscore_skewness_none_for_fewer_than_3() {
5663        let mut n = znorm(5);
5664        n.update(dec!(1)); n.update(dec!(2));
5665        assert!(n.skewness().is_none());
5666    }
5667
5668    #[test]
5669    fn test_zscore_skewness_near_zero_for_symmetric_data() {
5670        let mut n = znorm(5);
5671        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5672        let s = n.skewness().unwrap();
5673        assert!(s.abs() < 0.5);
5674    }
5675
5676    // ── ZScoreNormalizer::kurtosis ─────────────────────────────────────
5677
5678    #[test]
5679    fn test_zscore_kurtosis_none_for_fewer_than_4() {
5680        let mut n = znorm(5);
5681        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
5682        assert!(n.kurtosis().is_none());
5683    }
5684
5685    #[test]
5686    fn test_zscore_kurtosis_returns_f64_for_populated_window() {
5687        let mut n = znorm(5);
5688        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5689        assert!(n.kurtosis().is_some());
5690    }
5691
5692    // ── ZScoreNormalizer::autocorrelation_lag1 ────────────────────────────────
5693
5694    #[test]
5695    fn test_zscore_autocorrelation_none_for_single_value() {
5696        let mut n = znorm(3);
5697        n.update(dec!(1));
5698        assert!(n.autocorrelation_lag1().is_none());
5699    }
5700
5701    #[test]
5702    fn test_zscore_autocorrelation_positive_for_trending_data() {
5703        let mut n = znorm(5);
5704        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5705        let ac = n.autocorrelation_lag1().unwrap();
5706        assert!(ac > 0.0);
5707    }
5708
5709    // ── ZScoreNormalizer::trend_consistency ───────────────────────────────────
5710
5711    #[test]
5712    fn test_zscore_trend_consistency_none_for_single_value() {
5713        let mut n = znorm(3);
5714        n.update(dec!(1));
5715        assert!(n.trend_consistency().is_none());
5716    }
5717
5718    #[test]
5719    fn test_zscore_trend_consistency_one_for_strictly_rising() {
5720        let mut n = znorm(5);
5721        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5722        let tc = n.trend_consistency().unwrap();
5723        assert!((tc - 1.0).abs() < 1e-9);
5724    }
5725
5726    #[test]
5727    fn test_zscore_trend_consistency_zero_for_strictly_falling() {
5728        let mut n = znorm(5);
5729        for v in [dec!(5), dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
5730        let tc = n.trend_consistency().unwrap();
5731        assert!((tc - 0.0).abs() < 1e-9);
5732    }
5733
5734    // ── ZScoreNormalizer::coefficient_of_variation ────────────────────────────
5735
5736    #[test]
5737    fn test_zscore_cov_none_for_empty_window() {
5738        assert!(znorm(3).coefficient_of_variation().is_none());
5739    }
5740
5741    #[test]
5742    fn test_zscore_cov_positive_for_varied_data() {
5743        let mut n = znorm(5);
5744        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
5745        let cov = n.coefficient_of_variation().unwrap();
5746        assert!(cov > 0.0);
5747    }
5748
5749    // ── ZScoreNormalizer::mean_absolute_deviation ─────────────────────────────
5750
5751    #[test]
5752    fn test_zscore_mad_none_for_empty() {
5753        assert!(znorm(3).mean_absolute_deviation().is_none());
5754    }
5755
5756    #[test]
5757    fn test_zscore_mad_zero_for_identical_values() {
5758        let mut n = znorm(3);
5759        for v in [dec!(5), dec!(5), dec!(5)] { n.update(v); }
5760        let mad = n.mean_absolute_deviation().unwrap();
5761        assert!((mad - 0.0).abs() < 1e-9);
5762    }
5763
5764    #[test]
5765    fn test_zscore_mad_positive_for_varied_data() {
5766        let mut n = znorm(4);
5767        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
5768        let mad = n.mean_absolute_deviation().unwrap();
5769        assert!(mad > 0.0);
5770    }
5771
5772    // ── ZScoreNormalizer::percentile_of_latest ────────────────────────────────
5773
5774    #[test]
5775    fn test_zscore_percentile_of_latest_none_for_empty() {
5776        assert!(znorm(3).percentile_of_latest().is_none());
5777    }
5778
5779    #[test]
5780    fn test_zscore_percentile_of_latest_returns_some_after_update() {
5781        let mut n = znorm(4);
5782        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
5783        assert!(n.percentile_of_latest().is_some());
5784    }
5785
5786    #[test]
5787    fn test_zscore_percentile_of_latest_max_has_high_rank() {
5788        let mut n = znorm(5);
5789        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5790        let rank = n.percentile_of_latest().unwrap();
5791        assert!(rank >= 0.9, "max value should have rank near 1.0, got {}", rank);
5792    }
5793
5794    // ── ZScoreNormalizer::tail_ratio ──────────────────────────────────────────
5795
5796    #[test]
5797    fn test_zscore_tail_ratio_none_for_empty() {
5798        assert!(znorm(4).tail_ratio().is_none());
5799    }
5800
5801    #[test]
5802    fn test_zscore_tail_ratio_one_for_identical_values() {
5803        let mut n = znorm(4);
5804        for _ in 0..4 { n.update(dec!(7)); }
5805        let r = n.tail_ratio().unwrap();
5806        assert!((r - 1.0).abs() < 1e-9, "expected 1.0, got {}", r);
5807    }
5808
5809    #[test]
5810    fn test_zscore_tail_ratio_above_one_with_outlier() {
5811        let mut n = znorm(5);
5812        for v in [dec!(1), dec!(1), dec!(1), dec!(1), dec!(10)] { n.update(v); }
5813        let r = n.tail_ratio().unwrap();
5814        assert!(r > 1.0, "outlier should push ratio above 1.0, got {}", r);
5815    }
5816
5817    // ── ZScoreNormalizer::z_score_of_min / z_score_of_max ────────────────────
5818
5819    #[test]
5820    fn test_zscore_z_score_of_min_none_for_empty() {
5821        assert!(znorm(4).z_score_of_min().is_none());
5822    }
5823
5824    #[test]
5825    fn test_zscore_z_score_of_max_none_for_empty() {
5826        assert!(znorm(4).z_score_of_max().is_none());
5827    }
5828
5829    #[test]
5830    fn test_zscore_z_score_of_min_negative_for_varied_window() {
5831        let mut n = znorm(5);
5832        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5833        let z = n.z_score_of_min().unwrap();
5834        assert!(z < 0.0, "z-score of min should be negative, got {}", z);
5835    }
5836
5837    #[test]
5838    fn test_zscore_z_score_of_max_positive_for_varied_window() {
5839        let mut n = znorm(5);
5840        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5841        let z = n.z_score_of_max().unwrap();
5842        assert!(z > 0.0, "z-score of max should be positive, got {}", z);
5843    }
5844
5845    // ── ZScoreNormalizer::window_entropy ──────────────────────────────────────
5846
5847    #[test]
5848    fn test_zscore_window_entropy_none_for_empty() {
5849        assert!(znorm(4).window_entropy().is_none());
5850    }
5851
5852    #[test]
5853    fn test_zscore_window_entropy_zero_for_identical_values() {
5854        let mut n = znorm(3);
5855        for _ in 0..3 { n.update(dec!(5)); }
5856        let e = n.window_entropy().unwrap();
5857        assert!((e - 0.0).abs() < 1e-9, "identical values should have zero entropy, got {}", e);
5858    }
5859
5860    #[test]
5861    fn test_zscore_window_entropy_positive_for_varied_values() {
5862        let mut n = znorm(4);
5863        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
5864        let e = n.window_entropy().unwrap();
5865        assert!(e > 0.0, "varied values should have positive entropy, got {}", e);
5866    }
5867
5868    // ── ZScoreNormalizer::normalized_std_dev ──────────────────────────────────
5869
5870    #[test]
5871    fn test_zscore_normalized_std_dev_none_for_empty() {
5872        assert!(znorm(4).normalized_std_dev().is_none());
5873    }
5874
5875    #[test]
5876    fn test_zscore_normalized_std_dev_positive_for_varied_values() {
5877        let mut n = znorm(4);
5878        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
5879        let r = n.normalized_std_dev().unwrap();
5880        assert!(r > 0.0, "expected positive normalized std dev, got {}", r);
5881    }
5882
5883    // ── ZScoreNormalizer::value_above_mean_count ──────────────────────────────
5884
5885    #[test]
5886    fn test_zscore_value_above_mean_count_none_for_empty() {
5887        assert!(znorm(4).value_above_mean_count().is_none());
5888    }
5889
5890    #[test]
5891    fn test_zscore_value_above_mean_count_correct() {
5892        let mut n = znorm(4);
5893        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
5894        // mean=2.5, above: 3 and 4 → 2
5895        assert_eq!(n.value_above_mean_count().unwrap(), 2);
5896    }
5897
5898    // ── ZScoreNormalizer::consecutive_above_mean ──────────────────────────────
5899
5900    #[test]
5901    fn test_zscore_consecutive_above_mean_none_for_empty() {
5902        assert!(znorm(4).consecutive_above_mean().is_none());
5903    }
5904
5905    #[test]
5906    fn test_zscore_consecutive_above_mean_correct() {
5907        let mut n = znorm(4);
5908        for v in [dec!(1), dec!(5), dec!(6), dec!(7)] { n.update(v); }
5909        // mean=4.75, above: 5,6,7 → run=3
5910        assert_eq!(n.consecutive_above_mean().unwrap(), 3);
5911    }
5912
5913    // ── ZScoreNormalizer::above_threshold_fraction / below_threshold_fraction ─
5914
5915    #[test]
5916    fn test_zscore_above_threshold_fraction_none_for_empty() {
5917        assert!(znorm(4).above_threshold_fraction(dec!(5)).is_none());
5918    }
5919
5920    #[test]
5921    fn test_zscore_above_threshold_fraction_correct() {
5922        let mut n = znorm(4);
5923        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
5924        let f = n.above_threshold_fraction(dec!(2)).unwrap();
5925        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
5926    }
5927
5928    #[test]
5929    fn test_zscore_below_threshold_fraction_none_for_empty() {
5930        assert!(znorm(4).below_threshold_fraction(dec!(5)).is_none());
5931    }
5932
5933    #[test]
5934    fn test_zscore_below_threshold_fraction_correct() {
5935        let mut n = znorm(4);
5936        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
5937        let f = n.below_threshold_fraction(dec!(3)).unwrap();
5938        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
5939    }
5940
5941    // ── ZScoreNormalizer::lag_k_autocorrelation ───────────────────────────────
5942
5943    #[test]
5944    fn test_zscore_lag_k_autocorrelation_none_for_zero_k() {
5945        let mut n = znorm(5);
5946        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
5947        assert!(n.lag_k_autocorrelation(0).is_none());
5948    }
5949
5950    #[test]
5951    fn test_zscore_lag_k_autocorrelation_none_when_k_gte_len() {
5952        let mut n = znorm(3);
5953        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
5954        assert!(n.lag_k_autocorrelation(3).is_none());
5955    }
5956
5957    #[test]
5958    fn test_zscore_lag_k_autocorrelation_positive_for_trend() {
5959        let mut n = znorm(6);
5960        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(6)] { n.update(v); }
5961        let ac = n.lag_k_autocorrelation(1).unwrap();
5962        assert!(ac > 0.0, "trending series should have positive AC, got {}", ac);
5963    }
5964
5965    // ── ZScoreNormalizer::half_life_estimate ──────────────────────────────────
5966
5967    #[test]
5968    fn test_zscore_half_life_estimate_none_for_fewer_than_3() {
5969        let mut n = znorm(3);
5970        n.update(dec!(1)); n.update(dec!(2));
5971        assert!(n.half_life_estimate().is_none());
5972    }
5973
5974    #[test]
5975    fn test_zscore_half_life_no_panic_for_alternating() {
5976        let mut n = znorm(6);
5977        for v in [dec!(10), dec!(5), dec!(10), dec!(5), dec!(10), dec!(5)] { n.update(v); }
5978        let _ = n.half_life_estimate();
5979    }
5980
5981    // ── ZScoreNormalizer::geometric_mean ──────────────────────────────────────
5982
5983    #[test]
5984    fn test_zscore_geometric_mean_none_for_empty() {
5985        assert!(znorm(4).geometric_mean().is_none());
5986    }
5987
5988    #[test]
5989    fn test_zscore_geometric_mean_correct_for_powers_of_2() {
5990        let mut n = znorm(4);
5991        for v in [dec!(1), dec!(2), dec!(4), dec!(8)] { n.update(v); }
5992        let gm = n.geometric_mean().unwrap();
5993        assert!((gm - 64.0f64.powf(0.25)).abs() < 1e-6, "got {}", gm);
5994    }
5995
5996    // ── ZScoreNormalizer::harmonic_mean ───────────────────────────────────────
5997
5998    #[test]
5999    fn test_zscore_harmonic_mean_none_for_empty() {
6000        assert!(znorm(4).harmonic_mean().is_none());
6001    }
6002
6003    #[test]
6004    fn test_zscore_harmonic_mean_positive_for_positive_values() {
6005        let mut n = znorm(4);
6006        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
6007        let hm = n.harmonic_mean().unwrap();
6008        assert!(hm > 0.0 && hm < 4.0, "HM should be in (0, max), got {}", hm);
6009    }
6010
6011    // ── ZScoreNormalizer::range_normalized_value ──────────────────────────────
6012
6013    #[test]
6014    fn test_zscore_range_normalized_value_none_for_empty() {
6015        assert!(znorm(4).range_normalized_value(dec!(5)).is_none());
6016    }
6017
6018    #[test]
6019    fn test_zscore_range_normalized_value_in_range() {
6020        let mut n = znorm(4);
6021        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
6022        let r = n.range_normalized_value(dec!(2)).unwrap();
6023        assert!(r >= 0.0 && r <= 1.0, "expected [0,1], got {}", r);
6024    }
6025
6026    // ── ZScoreNormalizer::distance_from_median ────────────────────────────────
6027
6028    #[test]
6029    fn test_zscore_distance_from_median_none_for_empty() {
6030        assert!(znorm(4).distance_from_median(dec!(5)).is_none());
6031    }
6032
6033    #[test]
6034    fn test_zscore_distance_from_median_zero_at_median() {
6035        let mut n = znorm(5);
6036        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
6037        let d = n.distance_from_median(dec!(3)).unwrap();
6038        assert!((d - 0.0).abs() < 1e-9, "distance from median=3 should be 0, got {}", d);
6039    }
6040
6041    #[test]
6042    fn test_zscore_momentum_none_for_single_value() {
6043        let mut n = znorm(5);
6044        n.update(dec!(10));
6045        assert!(n.momentum().is_none());
6046    }
6047
6048    #[test]
6049    fn test_zscore_momentum_positive_for_rising_window() {
6050        let mut n = znorm(3);
6051        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
6052        let m = n.momentum().unwrap();
6053        assert!(m > 0.0, "rising window → positive momentum, got {}", m);
6054    }
6055
6056    #[test]
6057    fn test_zscore_value_rank_none_for_empty() {
6058        assert!(znorm(4).value_rank(dec!(5)).is_none());
6059    }
6060
6061    #[test]
6062    fn test_zscore_value_rank_extremes() {
6063        let mut n = znorm(4);
6064        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
6065        let low = n.value_rank(dec!(0)).unwrap();
6066        assert!((low - 0.0).abs() < 1e-9, "got {}", low);
6067        let high = n.value_rank(dec!(5)).unwrap();
6068        assert!((high - 1.0).abs() < 1e-9, "got {}", high);
6069    }
6070
6071    #[test]
6072    fn test_zscore_coeff_of_variation_none_for_single_value() {
6073        let mut n = znorm(5);
6074        n.update(dec!(10));
6075        assert!(n.coeff_of_variation().is_none());
6076    }
6077
6078    #[test]
6079    fn test_zscore_coeff_of_variation_positive_for_spread() {
6080        let mut n = znorm(4);
6081        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
6082        let cv = n.coeff_of_variation().unwrap();
6083        assert!(cv > 0.0, "expected positive CV, got {}", cv);
6084    }
6085
6086    #[test]
6087    fn test_zscore_quantile_range_none_for_empty() {
6088        assert!(znorm(4).quantile_range().is_none());
6089    }
6090
6091    #[test]
6092    fn test_zscore_quantile_range_non_negative() {
6093        let mut n = znorm(5);
6094        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
6095        let iqr = n.quantile_range().unwrap();
6096        assert!(iqr >= 0.0, "IQR should be non-negative, got {}", iqr);
6097    }
6098
6099    // ── ZScoreNormalizer::upper_quartile / lower_quartile ─────────────────────
6100
6101    #[test]
6102    fn test_zscore_upper_quartile_none_for_empty() {
6103        assert!(znorm(4).upper_quartile().is_none());
6104    }
6105
6106    #[test]
6107    fn test_zscore_lower_quartile_none_for_empty() {
6108        assert!(znorm(4).lower_quartile().is_none());
6109    }
6110
6111    #[test]
6112    fn test_zscore_upper_ge_lower_quartile() {
6113        let mut n = znorm(8);
6114        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50), dec!(60), dec!(70), dec!(80)] {
6115            n.update(v);
6116        }
6117        let q3 = n.upper_quartile().unwrap();
6118        let q1 = n.lower_quartile().unwrap();
6119        assert!(q3 >= q1, "Q3 ({}) should be >= Q1 ({})", q3, q1);
6120    }
6121
6122    // ── ZScoreNormalizer::sign_change_rate ────────────────────────────────────
6123
6124    #[test]
6125    fn test_zscore_sign_change_rate_none_for_fewer_than_3() {
6126        let mut n = znorm(4);
6127        n.update(dec!(1));
6128        n.update(dec!(2));
6129        assert!(n.sign_change_rate().is_none());
6130    }
6131
6132    #[test]
6133    fn test_zscore_sign_change_rate_one_for_zigzag() {
6134        let mut n = znorm(5);
6135        for v in [dec!(1), dec!(3), dec!(1), dec!(3), dec!(1)] { n.update(v); }
6136        let r = n.sign_change_rate().unwrap();
6137        assert!((r - 1.0).abs() < 1e-9, "zigzag should give 1.0, got {}", r);
6138    }
6139
6140    #[test]
6141    fn test_zscore_sign_change_rate_zero_for_monotone() {
6142        let mut n = znorm(5);
6143        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
6144        let r = n.sign_change_rate().unwrap();
6145        assert!((r - 0.0).abs() < 1e-9, "monotone should give 0.0, got {}", r);
6146    }
6147}