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    // ── round-81 ─────────────────────────────────────────────────────────────
1300
1301    /// Ratio of the first-half window variance to the second-half window variance.
1302    ///
1303    /// Values above 1.0 indicate decreasing volatility; below 1.0 indicate
1304    /// increasing volatility. Returns `None` if the window has fewer than 4
1305    /// observations or if the second-half variance is zero.
1306    pub fn variance_ratio(&self) -> Option<f64> {
1307        use rust_decimal::prelude::ToPrimitive;
1308        let n = self.window.len();
1309        if n < 4 {
1310            return None;
1311        }
1312        let mid = n / 2;
1313        let first: Vec<f64> = self.window.iter().take(mid).filter_map(|v| v.to_f64()).collect();
1314        let second: Vec<f64> = self.window.iter().skip(mid).filter_map(|v| v.to_f64()).collect();
1315        let var = |vals: &[f64]| -> Option<f64> {
1316            let n_f = vals.len() as f64;
1317            if n_f < 2.0 { return None; }
1318            let mean = vals.iter().sum::<f64>() / n_f;
1319            Some(vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n_f - 1.0))
1320        };
1321        let v1 = var(&first)?;
1322        let v2 = var(&second)?;
1323        if v2 == 0.0 {
1324            return None;
1325        }
1326        Some(v1 / v2)
1327    }
1328
1329    /// Linear trend slope of z-scores over the window.
1330    ///
1331    /// First computes the z-score of each window value relative to the full
1332    /// window mean and std-dev, then returns the OLS slope over the resulting
1333    /// z-score sequence. Positive means z-scores are trending upward.
1334    /// Returns `None` if fewer than 2 observations or std-dev is zero.
1335    pub fn z_score_trend_slope(&self) -> Option<f64> {
1336        use rust_decimal::prelude::ToPrimitive;
1337        let n = self.window.len();
1338        if n < 2 {
1339            return None;
1340        }
1341        let std_dev = self.std_dev()?;
1342        if std_dev == 0.0 {
1343            return None;
1344        }
1345        let mean = self.mean()?.to_f64()?;
1346        let z_vals: Vec<f64> = self
1347            .window
1348            .iter()
1349            .filter_map(|v| v.to_f64())
1350            .map(|v| (v - mean) / std_dev)
1351            .collect();
1352        if z_vals.len() < 2 {
1353            return None;
1354        }
1355        let n_f = z_vals.len() as f64;
1356        let x_mean = (n_f - 1.0) / 2.0;
1357        let z_mean = z_vals.iter().sum::<f64>() / n_f;
1358        let num: f64 = z_vals.iter().enumerate().map(|(i, &z)| (i as f64 - x_mean) * (z - z_mean)).sum();
1359        let den: f64 = (0..z_vals.len()).map(|i| (i as f64 - x_mean).powi(2)).sum();
1360        if den == 0.0 { return None; }
1361        Some(num / den)
1362    }
1363
1364    // ── round-82 ─────────────────────────────────────────────────────────────
1365
1366    /// Mean of `|x_i − x_{i-1}|` across consecutive window values; average absolute change.
1367    pub fn mean_absolute_change(&self) -> Option<f64> {
1368        use rust_decimal::prelude::ToPrimitive;
1369        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
1370        if vals.len() < 2 {
1371            return None;
1372        }
1373        let mac = vals.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f64>() / (vals.len() - 1) as f64;
1374        Some(mac)
1375    }
1376
1377    // ── round-83 ─────────────────────────────────────────────────────────────
1378
1379    /// Fraction of consecutive pairs that are monotonically increasing.
1380    ///
1381    /// Computed as `count(x_{i+1} > x_i) / (n − 1)`.
1382    /// Returns `None` for windows with fewer than 2 values.
1383    pub fn monotone_increase_fraction(&self) -> Option<f64> {
1384        let n = self.window.len();
1385        if n < 2 {
1386            return None;
1387        }
1388        let increasing = self.window
1389            .iter()
1390            .collect::<Vec<_>>()
1391            .windows(2)
1392            .filter(|w| w[1] > w[0])
1393            .count();
1394        Some(increasing as f64 / (n - 1) as f64)
1395    }
1396
1397    /// Maximum absolute value in the window.
1398    ///
1399    /// Returns `None` if the window is empty.
1400    pub fn abs_max(&self) -> Option<Decimal> {
1401        self.window.iter().map(|v| v.abs()).reduce(|a, b| a.max(b))
1402    }
1403
1404    /// Minimum absolute value in the window.
1405    ///
1406    /// Returns `None` if the window is empty.
1407    pub fn abs_min(&self) -> Option<Decimal> {
1408        self.window.iter().map(|v| v.abs()).reduce(|a, b| a.min(b))
1409    }
1410
1411    /// Count of values in the window that equal the window maximum.
1412    ///
1413    /// Returns `None` if the window is empty.
1414    pub fn max_count(&self) -> Option<usize> {
1415        let mut tmp = MinMaxNormalizer::new(self.window_size).ok()?;
1416        for &v in &self.window {
1417            tmp.update(v);
1418        }
1419        let (_, max) = tmp.min_max()?;
1420        Some(self.window.iter().filter(|&&v| v == max).count())
1421    }
1422
1423    /// Count of values in the window that equal the window minimum.
1424    ///
1425    /// Returns `None` if the window is empty.
1426    pub fn min_count(&self) -> Option<usize> {
1427        let mut tmp = MinMaxNormalizer::new(self.window_size).ok()?;
1428        for &v in &self.window {
1429            tmp.update(v);
1430        }
1431        let (min, _) = tmp.min_max()?;
1432        Some(self.window.iter().filter(|&&v| v == min).count())
1433    }
1434
1435    /// Ratio of the current window mean to the mean computed at window creation
1436    /// time (first `window_size / 2` values).
1437    ///
1438    /// Returns `None` if fewer than 2 observations or the early mean is zero.
1439    pub fn mean_ratio(&self) -> Option<f64> {
1440        use rust_decimal::prelude::ToPrimitive;
1441        let n = self.window.len();
1442        if n < 2 {
1443            return None;
1444        }
1445        let current_mean = self.mean()?;
1446        let half = (n / 2).max(1);
1447        let early_sum: Decimal = self.window.iter().take(half).copied().sum();
1448        let early_mean = early_sum / Decimal::from(half as i64);
1449        if early_mean.is_zero() {
1450            return None;
1451        }
1452        (current_mean / early_mean).to_f64()
1453    }
1454
1455    // ── round-84 ─────────────────────────────────────────────────────────────
1456
1457    /// Exponentially-weighted mean with decay factor `alpha` ∈ (0, 1]; most-recent value has highest weight.
1458    pub fn exponential_weighted_mean(&self, alpha: f64) -> Option<f64> {
1459        use rust_decimal::prelude::ToPrimitive;
1460        if self.window.is_empty() {
1461            return None;
1462        }
1463        let alpha = alpha.clamp(1e-6, 1.0);
1464        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
1465        if vals.is_empty() {
1466            return None;
1467        }
1468        let mut ewm = vals[0];
1469        for &v in &vals[1..] {
1470            ewm = alpha * v + (1.0 - alpha) * ewm;
1471        }
1472        Some(ewm)
1473    }
1474
1475    /// Mean of squared values in the window (second raw moment).
1476    pub fn second_moment(&self) -> Option<f64> {
1477        use rust_decimal::prelude::ToPrimitive;
1478        if self.window.is_empty() {
1479            return None;
1480        }
1481        let sum: f64 = self.window.iter().filter_map(|v| v.to_f64()).map(|v| v * v).sum();
1482        Some(sum / self.window.len() as f64)
1483    }
1484
1485    /// Range / mean — coefficient of dispersion; `None` if mean is zero.
1486    pub fn range_over_mean(&self) -> Option<f64> {
1487        use rust_decimal::prelude::ToPrimitive;
1488        let max = self.window.iter().copied().max()?;
1489        let min = self.window.iter().copied().min()?;
1490        let mean = self.mean()?;
1491        if mean.is_zero() {
1492            return None;
1493        }
1494        ((max - min) / mean).to_f64()
1495    }
1496
1497    /// Fraction of window values strictly above the window median.
1498    pub fn above_median_fraction(&self) -> Option<f64> {
1499        if self.window.is_empty() {
1500            return None;
1501        }
1502        let median = self.median()?;
1503        let count = self.window.iter().filter(|&&v| v > median).count();
1504        Some(count as f64 / self.window.len() as f64)
1505    }
1506
1507    // ── round-85 ─────────────────────────────────────────────────────────────
1508
1509    /// Mean of values strictly between Q1 and Q3 (the interquartile mean).
1510    pub fn interquartile_mean(&self) -> Option<f64> {
1511        use rust_decimal::prelude::ToPrimitive;
1512        if self.window.is_empty() {
1513            return None;
1514        }
1515        let q1 = self.percentile_value(0.25)?;
1516        let q3 = self.percentile_value(0.75)?;
1517        let iqr_vals: Vec<f64> = self.window
1518            .iter()
1519            .filter(|&&v| v > q1 && v < q3)
1520            .filter_map(|v| v.to_f64())
1521            .collect();
1522        if iqr_vals.is_empty() {
1523            return None;
1524        }
1525        Some(iqr_vals.iter().sum::<f64>() / iqr_vals.len() as f64)
1526    }
1527
1528    /// Fraction of window values beyond `threshold` standard deviations from the mean.
1529    pub fn outlier_fraction(&self, threshold: f64) -> Option<f64> {
1530        use rust_decimal::prelude::ToPrimitive;
1531        if self.window.is_empty() {
1532            return None;
1533        }
1534        let std_dev = self.std_dev()?;
1535        let mean = self.mean()?.to_f64()?;
1536        if std_dev == 0.0 {
1537            return Some(0.0);
1538        }
1539        let count = self.window
1540            .iter()
1541            .filter_map(|v| v.to_f64())
1542            .filter(|&v| ((v - mean) / std_dev).abs() > threshold)
1543            .count();
1544        Some(count as f64 / self.window.len() as f64)
1545    }
1546
1547    /// Count of sign changes (transitions across zero) in the window.
1548    pub fn sign_flip_count(&self) -> Option<usize> {
1549        if self.window.len() < 2 {
1550            return None;
1551        }
1552        let count = self.window
1553            .iter()
1554            .collect::<Vec<_>>()
1555            .windows(2)
1556            .filter(|w| w[0].is_sign_negative() != w[1].is_sign_negative())
1557            .count();
1558        Some(count)
1559    }
1560
1561    /// Root mean square of window values.
1562    pub fn rms(&self) -> Option<f64> {
1563        use rust_decimal::prelude::ToPrimitive;
1564        if self.window.is_empty() {
1565            return None;
1566        }
1567        let sum_sq: f64 = self.window.iter().filter_map(|v| v.to_f64()).map(|v| v * v).sum();
1568        Some((sum_sq / self.window.len() as f64).sqrt())
1569    }
1570
1571    // ── round-86 ─────────────────────────────────────────────────────────────
1572
1573    /// Number of distinct values in the window.
1574    pub fn distinct_count(&self) -> usize {
1575        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
1576        sorted.sort();
1577        sorted.dedup();
1578        sorted.len()
1579    }
1580
1581    /// Fraction of window values that equal the window maximum.
1582    pub fn max_fraction(&self) -> Option<f64> {
1583        if self.window.is_empty() {
1584            return None;
1585        }
1586        let max = self.window.iter().copied().max()?;
1587        let count = self.window.iter().filter(|&&v| v == max).count();
1588        Some(count as f64 / self.window.len() as f64)
1589    }
1590
1591    /// Fraction of window values that equal the window minimum.
1592    pub fn min_fraction(&self) -> Option<f64> {
1593        if self.window.is_empty() {
1594            return None;
1595        }
1596        let min = self.window.iter().copied().min()?;
1597        let count = self.window.iter().filter(|&&v| v == min).count();
1598        Some(count as f64 / self.window.len() as f64)
1599    }
1600
1601    /// Difference between the latest value and the window mean (signed).
1602    pub fn latest_minus_mean(&self) -> Option<f64> {
1603        use rust_decimal::prelude::ToPrimitive;
1604        let latest = self.latest()?;
1605        let mean = self.mean()?;
1606        (latest - mean).to_f64()
1607    }
1608
1609    /// Ratio of the latest value to the window mean; `None` if mean is zero.
1610    pub fn latest_to_mean_ratio(&self) -> Option<f64> {
1611        use rust_decimal::prelude::ToPrimitive;
1612        let latest = self.latest()?;
1613        let mean = self.mean()?;
1614        if mean.is_zero() {
1615            return None;
1616        }
1617        (latest / mean).to_f64()
1618    }
1619
1620    // ── round-87 ─────────────────────────────────────────────────────────────
1621
1622    /// Fraction of window values strictly below the mean.
1623    pub fn below_mean_fraction(&self) -> Option<f64> {
1624        if self.window.is_empty() {
1625            return None;
1626        }
1627        let mean = self.mean()?;
1628        let count = self.window.iter().filter(|&&v| v < mean).count();
1629        Some(count as f64 / self.window.len() as f64)
1630    }
1631
1632    /// Variance of values lying outside the interquartile range.
1633    /// Returns `None` if fewer than 4 values or no tail values.
1634    pub fn tail_variance(&self) -> Option<f64> {
1635        use rust_decimal::prelude::ToPrimitive;
1636        if self.window.len() < 4 {
1637            return None;
1638        }
1639        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
1640        sorted.sort();
1641        let n = sorted.len();
1642        let q1 = sorted[n / 4];
1643        let q3 = sorted[(3 * n) / 4];
1644        let tails: Vec<f64> = sorted
1645            .iter()
1646            .filter(|&&v| v < q1 || v > q3)
1647            .filter_map(|v| v.to_f64())
1648            .collect();
1649        if tails.len() < 2 {
1650            return None;
1651        }
1652        let nt = tails.len() as f64;
1653        let mean = tails.iter().sum::<f64>() / nt;
1654        let var = tails.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (nt - 1.0);
1655        Some(var)
1656    }
1657
1658    // ── round-88 ─────────────────────────────────────────────────────────────
1659
1660    /// Number of times the window reaches a new running maximum (from index 0).
1661    pub fn new_max_count(&self) -> usize {
1662        if self.window.is_empty() {
1663            return 0;
1664        }
1665        let vals: Vec<Decimal> = self.window.iter().copied().collect();
1666        let mut running = vals[0];
1667        let mut count = 1usize;
1668        for &v in vals.iter().skip(1) {
1669            if v > running {
1670                running = v;
1671                count += 1;
1672            }
1673        }
1674        count
1675    }
1676
1677    /// Number of times the window reaches a new running minimum (from index 0).
1678    pub fn new_min_count(&self) -> usize {
1679        if self.window.is_empty() {
1680            return 0;
1681        }
1682        let vals: Vec<Decimal> = self.window.iter().copied().collect();
1683        let mut running = vals[0];
1684        let mut count = 1usize;
1685        for &v in vals.iter().skip(1) {
1686            if v < running {
1687                running = v;
1688                count += 1;
1689            }
1690        }
1691        count
1692    }
1693
1694    /// Fraction of window values strictly equal to zero.
1695    pub fn zero_fraction(&self) -> Option<f64> {
1696        if self.window.is_empty() {
1697            return None;
1698        }
1699        let count = self.window.iter().filter(|&&v| v == Decimal::ZERO).count();
1700        Some(count as f64 / self.window.len() as f64)
1701    }
1702
1703    // ── round-89 ─────────────────────────────────────────────────────────────
1704
1705    /// Cumulative sum of all window values (running total).
1706    pub fn cumulative_sum(&self) -> Decimal {
1707        self.window.iter().copied().sum()
1708    }
1709
1710    /// Ratio of the window maximum to the window minimum.
1711    /// Returns `None` if the window is empty or minimum is zero.
1712    pub fn max_to_min_ratio(&self) -> Option<f64> {
1713        use rust_decimal::prelude::ToPrimitive;
1714        let max = self.window.iter().copied().max()?;
1715        let min = self.window.iter().copied().min()?;
1716        if min.is_zero() {
1717            return None;
1718        }
1719        (max / min).to_f64()
1720    }
1721
1722    // ── round-90 ─────────────────────────────────────────────────────────────
1723
1724    /// Fraction of window values strictly above the window midpoint `(min + max) / 2`.
1725    ///
1726    /// Returns `None` for an empty window.
1727    pub fn above_midpoint_fraction(&self) -> Option<f64> {
1728        if self.window.is_empty() {
1729            return None;
1730        }
1731        let min = self.window.iter().copied().min()?;
1732        let max = self.window.iter().copied().max()?;
1733        let mid = (min + max) / Decimal::TWO;
1734        let count = self.window.iter().filter(|&&v| v > mid).count();
1735        Some(count as f64 / self.window.len() as f64)
1736    }
1737
1738    /// Position of the latest window value in the range: `(latest − min) / (max − min)`.
1739    ///
1740    /// Returns `None` when the window is empty or has zero range.
1741    pub fn span_utilization(&self) -> Option<f64> {
1742        use rust_decimal::prelude::ToPrimitive;
1743        if self.window.is_empty() {
1744            return None;
1745        }
1746        let min = self.window.iter().copied().min()?;
1747        let max = self.window.iter().copied().max()?;
1748        let range = max - min;
1749        if range.is_zero() {
1750            return None;
1751        }
1752        let latest = *self.window.back()?;
1753        ((latest - min) / range).to_f64()
1754    }
1755
1756    /// Fraction of window values strictly greater than zero.
1757    ///
1758    /// Returns `None` for an empty window.
1759    pub fn positive_fraction(&self) -> Option<f64> {
1760        if self.window.is_empty() {
1761            return None;
1762        }
1763        let count = self.window.iter().filter(|&&v| v > Decimal::ZERO).count();
1764        Some(count as f64 / self.window.len() as f64)
1765    }
1766
1767    // ── round-91 ─────────────────────────────────────────────────────────────
1768
1769    /// Interquartile range of the window: `Q3 − Q1`.
1770    ///
1771    /// Returns `None` for an empty window.
1772    pub fn window_iqr(&self) -> Option<Decimal> {
1773        if self.window.is_empty() {
1774            return None;
1775        }
1776        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
1777        sorted.sort();
1778        let n = sorted.len();
1779        let q1 = sorted[n / 4];
1780        let q3 = sorted[(3 * n) / 4];
1781        Some(q3 - q1)
1782    }
1783
1784    /// Mean run length of monotone non-decreasing segments.
1785    ///
1786    /// Returns `None` for fewer than 2 values in the window.
1787    pub fn run_length_mean(&self) -> Option<f64> {
1788        if self.window.len() < 2 {
1789            return None;
1790        }
1791        let vals: Vec<Decimal> = self.window.iter().copied().collect();
1792        let mut runs: Vec<usize> = Vec::new();
1793        let mut run_len = 1usize;
1794        for w in vals.windows(2) {
1795            if w[1] >= w[0] {
1796                run_len += 1;
1797            } else {
1798                runs.push(run_len);
1799                run_len = 1;
1800            }
1801        }
1802        runs.push(run_len);
1803        Some(runs.iter().sum::<usize>() as f64 / runs.len() as f64)
1804    }
1805
1806}
1807
1808#[cfg(test)]
1809mod tests {
1810    use super::*;
1811    use rust_decimal_macros::dec;
1812
1813    fn norm(w: usize) -> MinMaxNormalizer {
1814        MinMaxNormalizer::new(w).unwrap()
1815    }
1816
1817    // ── Construction ─────────────────────────────────────────────────────────
1818
1819    #[test]
1820    fn test_new_normalizer_is_empty() {
1821        let n = norm(4);
1822        assert!(n.is_empty());
1823        assert_eq!(n.len(), 0);
1824    }
1825
1826    #[test]
1827    fn test_minmax_is_full_false_before_capacity() {
1828        let mut n = norm(3);
1829        assert!(!n.is_full());
1830        n.update(dec!(1));
1831        n.update(dec!(2));
1832        assert!(!n.is_full());
1833        n.update(dec!(3));
1834        assert!(n.is_full());
1835    }
1836
1837    #[test]
1838    fn test_minmax_is_full_stays_true_after_eviction() {
1839        let mut n = norm(3);
1840        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
1841            n.update(v);
1842        }
1843        assert!(n.is_full()); // window stays at capacity after eviction
1844    }
1845
1846    #[test]
1847    fn test_new_zero_window_returns_error() {
1848        let result = MinMaxNormalizer::new(0);
1849        assert!(matches!(result, Err(StreamError::ConfigError { .. })));
1850    }
1851
1852    // ── Normalization range [0, 1] ────────────────────────────────────────────
1853
1854    #[test]
1855    fn test_normalize_min_is_zero() {
1856        let mut n = norm(4);
1857        n.update(dec!(10));
1858        n.update(dec!(20));
1859        n.update(dec!(30));
1860        n.update(dec!(40));
1861        let v = n.normalize(dec!(10)).unwrap();
1862        assert!(
1863            (v - 0.0).abs() < 1e-10,
1864            "min should normalize to 0.0, got {v}"
1865        );
1866    }
1867
1868    #[test]
1869    fn test_normalize_max_is_one() {
1870        let mut n = norm(4);
1871        n.update(dec!(10));
1872        n.update(dec!(20));
1873        n.update(dec!(30));
1874        n.update(dec!(40));
1875        let v = n.normalize(dec!(40)).unwrap();
1876        assert!(
1877            (v - 1.0).abs() < 1e-10,
1878            "max should normalize to 1.0, got {v}"
1879        );
1880    }
1881
1882    #[test]
1883    fn test_normalize_midpoint_is_half() {
1884        let mut n = norm(4);
1885        n.update(dec!(0));
1886        n.update(dec!(100));
1887        let v = n.normalize(dec!(50)).unwrap();
1888        assert!((v - 0.5).abs() < 1e-10);
1889    }
1890
1891    #[test]
1892    fn test_normalize_result_clamped_below_zero() {
1893        let mut n = norm(4);
1894        n.update(dec!(50));
1895        n.update(dec!(100));
1896        // 10 is below the window min of 50
1897        let v = n.normalize(dec!(10)).unwrap();
1898        assert!(v >= 0.0);
1899        assert_eq!(v, 0.0);
1900    }
1901
1902    #[test]
1903    fn test_normalize_result_clamped_above_one() {
1904        let mut n = norm(4);
1905        n.update(dec!(50));
1906        n.update(dec!(100));
1907        // 200 is above the window max of 100
1908        let v = n.normalize(dec!(200)).unwrap();
1909        assert!(v <= 1.0);
1910        assert_eq!(v, 1.0);
1911    }
1912
1913    #[test]
1914    fn test_normalize_all_same_values_returns_zero() {
1915        let mut n = norm(4);
1916        n.update(dec!(5));
1917        n.update(dec!(5));
1918        n.update(dec!(5));
1919        let v = n.normalize(dec!(5)).unwrap();
1920        assert_eq!(v, 0.0);
1921    }
1922
1923    // ── Empty window error ────────────────────────────────────────────────────
1924
1925    #[test]
1926    fn test_normalize_empty_window_returns_error() {
1927        let mut n = norm(4);
1928        let err = n.normalize(dec!(1)).unwrap_err();
1929        assert!(matches!(err, StreamError::NormalizationError { .. }));
1930    }
1931
1932    #[test]
1933    fn test_min_max_empty_returns_none() {
1934        let mut n = norm(4);
1935        assert!(n.min_max().is_none());
1936    }
1937
1938    // ── Rolling window eviction ───────────────────────────────────────────────
1939
1940    /// After the window fills and the minimum is evicted, the new min must
1941    /// reflect the remaining values.
1942    #[test]
1943    fn test_rolling_window_evicts_oldest() {
1944        let mut n = norm(3);
1945        n.update(dec!(1)); // will be evicted
1946        n.update(dec!(5));
1947        n.update(dec!(10));
1948        n.update(dec!(20)); // evicts 1
1949        let (min, max) = n.min_max().unwrap();
1950        assert_eq!(min, dec!(5));
1951        assert_eq!(max, dec!(20));
1952    }
1953
1954    #[test]
1955    fn test_rolling_window_len_does_not_exceed_capacity() {
1956        let mut n = norm(3);
1957        for i in 0..10 {
1958            n.update(Decimal::from(i));
1959        }
1960        assert_eq!(n.len(), 3);
1961    }
1962
1963    // ── Reset behavior ────────────────────────────────────────────────────────
1964
1965    #[test]
1966    fn test_reset_clears_window() {
1967        let mut n = norm(4);
1968        n.update(dec!(10));
1969        n.update(dec!(20));
1970        n.reset();
1971        assert!(n.is_empty());
1972        assert!(n.min_max().is_none());
1973    }
1974
1975    #[test]
1976    fn test_normalize_works_after_reset() {
1977        let mut n = norm(4);
1978        n.update(dec!(10));
1979        n.reset();
1980        n.update(dec!(0));
1981        n.update(dec!(100));
1982        let v = n.normalize(dec!(100)).unwrap();
1983        assert!((v - 1.0).abs() < 1e-10);
1984    }
1985
1986    // ── Streaming update ──────────────────────────────────────────────────────
1987
1988    #[test]
1989    fn test_streaming_updates_monotone_sequence() {
1990        let mut n = norm(5);
1991        let prices = [dec!(100), dec!(101), dec!(102), dec!(103), dec!(104), dec!(105)];
1992        for &p in &prices {
1993            n.update(p);
1994        }
1995        // Window now holds [101, 102, 103, 104, 105]; min=101, max=105
1996        let v_min = n.normalize(dec!(101)).unwrap();
1997        let v_max = n.normalize(dec!(105)).unwrap();
1998        assert!((v_min - 0.0).abs() < 1e-10);
1999        assert!((v_max - 1.0).abs() < 1e-10);
2000    }
2001
2002    #[test]
2003    fn test_normalization_monotonicity_in_window() {
2004        let mut n = norm(10);
2005        for i in 0..10 {
2006            n.update(Decimal::from(i * 10));
2007        }
2008        // Values 0, 10, 20, ..., 90 in window; min=0, max=90
2009        let v0 = n.normalize(dec!(0)).unwrap();
2010        let v50 = n.normalize(dec!(50)).unwrap();
2011        let v90 = n.normalize(dec!(90)).unwrap();
2012        assert!(v0 < v50, "normalized values should be monotone");
2013        assert!(v50 < v90, "normalized values should be monotone");
2014    }
2015
2016    #[test]
2017    fn test_high_precision_input_preserved() {
2018        // Verify that a value like 50000.12345678 is handled without f64 loss.
2019        let mut n = norm(2);
2020        n.update(dec!(50000.00000000));
2021        n.update(dec!(50000.12345678));
2022        let (min, max) = n.min_max().unwrap();
2023        assert_eq!(min, dec!(50000.00000000));
2024        assert_eq!(max, dec!(50000.12345678));
2025    }
2026
2027    // ── denormalize ───────────────────────────────────────────────────────────
2028
2029    #[test]
2030    fn test_denormalize_empty_window_returns_error() {
2031        let mut n = norm(4);
2032        assert!(matches!(n.denormalize(0.5), Err(StreamError::NormalizationError { .. })));
2033    }
2034
2035    #[test]
2036    fn test_denormalize_roundtrip_min() {
2037        let mut n = norm(4);
2038        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
2039            n.update(v);
2040        }
2041        let normalized = n.normalize(dec!(10)).unwrap(); // should be ~0.0
2042        let back = n.denormalize(normalized).unwrap();
2043        assert!((back - dec!(10)).abs() < dec!(0.0001));
2044    }
2045
2046    #[test]
2047    fn test_denormalize_roundtrip_max() {
2048        let mut n = norm(4);
2049        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
2050            n.update(v);
2051        }
2052        let normalized = n.normalize(dec!(40)).unwrap(); // should be ~1.0
2053        let back = n.denormalize(normalized).unwrap();
2054        assert!((back - dec!(40)).abs() < dec!(0.0001));
2055    }
2056
2057    // ── range ─────────────────────────────────────────────────────────────────
2058
2059    #[test]
2060    fn test_range_none_when_empty() {
2061        let mut n = norm(4);
2062        assert!(n.range().is_none());
2063    }
2064
2065    #[test]
2066    fn test_range_zero_when_all_same() {
2067        let mut n = norm(3);
2068        n.update(dec!(5));
2069        n.update(dec!(5));
2070        n.update(dec!(5));
2071        assert_eq!(n.range(), Some(dec!(0)));
2072    }
2073
2074    #[test]
2075    fn test_range_correct() {
2076        let mut n = norm(4);
2077        for v in [dec!(10), dec!(40), dec!(20), dec!(30)] {
2078            n.update(v);
2079        }
2080        assert_eq!(n.range(), Some(dec!(30))); // 40 - 10
2081    }
2082
2083    // ── MinMaxNormalizer::midpoint ────────────────────────────────────────────
2084
2085    #[test]
2086    fn test_midpoint_none_when_empty() {
2087        let mut n = norm(4);
2088        assert!(n.midpoint().is_none());
2089    }
2090
2091    #[test]
2092    fn test_midpoint_correct() {
2093        let mut n = norm(4);
2094        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
2095            n.update(v);
2096        }
2097        // min=10, max=40 → midpoint = 25
2098        assert_eq!(n.midpoint(), Some(dec!(25)));
2099    }
2100
2101    #[test]
2102    fn test_midpoint_single_value() {
2103        let mut n = norm(4);
2104        n.update(dec!(42));
2105        assert_eq!(n.midpoint(), Some(dec!(42)));
2106    }
2107
2108    // ── MinMaxNormalizer::clamp_to_window ─────────────────────────────────────
2109
2110    #[test]
2111    fn test_clamp_to_window_returns_value_unchanged_when_empty() {
2112        let mut n = norm(4);
2113        assert_eq!(n.clamp_to_window(dec!(50)), dec!(50));
2114    }
2115
2116    #[test]
2117    fn test_clamp_to_window_clamps_above_max() {
2118        let mut n = norm(4);
2119        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
2120        assert_eq!(n.clamp_to_window(dec!(100)), dec!(30));
2121    }
2122
2123    #[test]
2124    fn test_clamp_to_window_clamps_below_min() {
2125        let mut n = norm(4);
2126        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
2127        assert_eq!(n.clamp_to_window(dec!(5)), dec!(10));
2128    }
2129
2130    #[test]
2131    fn test_clamp_to_window_passthrough_when_in_range() {
2132        let mut n = norm(4);
2133        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
2134        assert_eq!(n.clamp_to_window(dec!(15)), dec!(15));
2135    }
2136
2137    // ── MinMaxNormalizer::count_above ─────────────────────────────────────────
2138
2139    #[test]
2140    fn test_count_above_zero_when_empty() {
2141        let n = norm(4);
2142        assert_eq!(n.count_above(dec!(5)), 0);
2143    }
2144
2145    #[test]
2146    fn test_count_above_counts_strictly_above() {
2147        let mut n = norm(8);
2148        for v in [dec!(1), dec!(5), dec!(10), dec!(15)] { n.update(v); }
2149        assert_eq!(n.count_above(dec!(5)), 2); // 10 and 15
2150    }
2151
2152    #[test]
2153    fn test_count_above_all_when_threshold_below_all() {
2154        let mut n = norm(4);
2155        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
2156        assert_eq!(n.count_above(dec!(5)), 3);
2157    }
2158
2159    #[test]
2160    fn test_count_above_zero_when_threshold_above_all() {
2161        let mut n = norm(4);
2162        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
2163        assert_eq!(n.count_above(dec!(100)), 0);
2164    }
2165
2166    // ── MinMaxNormalizer::count_below ─────────────────────────────────────────
2167
2168    #[test]
2169    fn test_count_below_zero_when_empty() {
2170        let n = norm(4);
2171        assert_eq!(n.count_below(dec!(5)), 0);
2172    }
2173
2174    #[test]
2175    fn test_count_below_counts_strictly_below() {
2176        let mut n = norm(8);
2177        for v in [dec!(1), dec!(5), dec!(10), dec!(15)] { n.update(v); }
2178        assert_eq!(n.count_below(dec!(10)), 2); // 1 and 5
2179    }
2180
2181    #[test]
2182    fn test_count_below_all_when_threshold_above_all() {
2183        let mut n = norm(4);
2184        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
2185        assert_eq!(n.count_below(dec!(100)), 3);
2186    }
2187
2188    #[test]
2189    fn test_count_below_zero_when_threshold_below_all() {
2190        let mut n = norm(4);
2191        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
2192        assert_eq!(n.count_below(dec!(5)), 0);
2193    }
2194
2195    #[test]
2196    fn test_count_above_plus_count_below_leq_len() {
2197        let mut n = norm(5);
2198        for v in [dec!(1), dec!(5), dec!(5), dec!(10), dec!(20)] { n.update(v); }
2199        // threshold = 5: above = {10,20} = 2, below = {1} = 1, equal = {5,5} = 2
2200        // above + below = 3 <= 5 = len
2201        assert_eq!(n.count_above(dec!(5)) + n.count_below(dec!(5)), 3);
2202    }
2203
2204    // ── MinMaxNormalizer::normalized_range ────────────────────────────────────
2205
2206    #[test]
2207    fn test_normalized_range_none_when_empty() {
2208        let mut n = norm(4);
2209        assert!(n.normalized_range().is_none());
2210    }
2211
2212    #[test]
2213    fn test_normalized_range_zero_when_all_same() {
2214        let mut n = norm(4);
2215        for _ in 0..4 { n.update(dec!(5)); }
2216        assert_eq!(n.normalized_range(), Some(0.0));
2217    }
2218
2219    #[test]
2220    fn test_normalized_range_correct_value() {
2221        let mut n = norm(4);
2222        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
2223        // (40-10)/40 = 0.75
2224        let nr = n.normalized_range().unwrap();
2225        assert!((nr - 0.75).abs() < 1e-10);
2226    }
2227
2228    // ── MinMaxNormalizer::normalize_clamp ─────────────────────────────────────
2229
2230    #[test]
2231    fn test_normalize_clamp_in_range_equals_normalize() {
2232        let mut n = norm(4);
2233        for v in [dec!(0), dec!(25), dec!(75), dec!(100)] {
2234            n.update(v);
2235        }
2236        let clamped = n.normalize_clamp(dec!(50)).unwrap();
2237        let normal = n.normalize(dec!(50)).unwrap();
2238        assert!((clamped - normal).abs() < 1e-9);
2239    }
2240
2241    #[test]
2242    fn test_normalize_clamp_above_max_clamped_to_one() {
2243        let mut n = norm(3);
2244        for v in [dec!(0), dec!(50), dec!(100)] {
2245            n.update(v);
2246        }
2247        // 200 is above the window max of 100; normalize would return > 1.0
2248        let clamped = n.normalize_clamp(dec!(200)).unwrap();
2249        assert!((clamped - 1.0).abs() < 1e-9, "expected 1.0 got {clamped}");
2250    }
2251
2252    #[test]
2253    fn test_normalize_clamp_below_min_clamped_to_zero() {
2254        let mut n = norm(3);
2255        for v in [dec!(10), dec!(50), dec!(100)] {
2256            n.update(v);
2257        }
2258        // -50 is below the window min of 10; normalize would return < 0.0
2259        let clamped = n.normalize_clamp(dec!(-50)).unwrap();
2260        assert!((clamped - 0.0).abs() < 1e-9, "expected 0.0 got {clamped}");
2261    }
2262
2263    #[test]
2264    fn test_normalize_clamp_empty_window_returns_error() {
2265        let mut n = norm(4);
2266        assert!(n.normalize_clamp(dec!(5)).is_err());
2267    }
2268
2269    // ── MinMaxNormalizer::latest ──────────────────────────────────────────────
2270
2271    #[test]
2272    fn test_latest_none_when_empty() {
2273        let n = norm(5);
2274        assert_eq!(n.latest(), None);
2275    }
2276
2277    #[test]
2278    fn test_latest_returns_most_recent_value() {
2279        let mut n = norm(5);
2280        n.update(dec!(10));
2281        n.update(dec!(20));
2282        n.update(dec!(30));
2283        assert_eq!(n.latest(), Some(dec!(30)));
2284    }
2285
2286    #[test]
2287    fn test_latest_updates_on_each_push() {
2288        let mut n = norm(3);
2289        n.update(dec!(1));
2290        assert_eq!(n.latest(), Some(dec!(1)));
2291        n.update(dec!(5));
2292        assert_eq!(n.latest(), Some(dec!(5)));
2293    }
2294
2295    #[test]
2296    fn test_latest_returns_last_after_window_overflow() {
2297        let mut n = norm(2); // window_size = 2
2298        n.update(dec!(100));
2299        n.update(dec!(200));
2300        n.update(dec!(300)); // oldest (100) evicted
2301        assert_eq!(n.latest(), Some(dec!(300)));
2302    }
2303
2304    // ── MinMaxNormalizer::coefficient_of_variation ────────────────────────────
2305
2306    #[test]
2307    fn test_minmax_cv_none_fewer_than_2_obs() {
2308        let mut n = norm(4);
2309        n.update(dec!(10));
2310        assert!(n.coefficient_of_variation().is_none());
2311    }
2312
2313    #[test]
2314    fn test_minmax_cv_none_when_mean_zero() {
2315        let mut n = norm(4);
2316        for v in [dec!(-5), dec!(5)] { n.update(v); }
2317        assert!(n.coefficient_of_variation().is_none());
2318    }
2319
2320    #[test]
2321    fn test_minmax_cv_positive_for_positive_mean() {
2322        let mut n = norm(4);
2323        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
2324        let cv = n.coefficient_of_variation().unwrap();
2325        assert!(cv > 0.0, "CV should be positive");
2326    }
2327
2328    // ── MinMaxNormalizer::variance / std_dev ─────────────────────────────────
2329
2330    #[test]
2331    fn test_minmax_variance_none_fewer_than_2_obs() {
2332        let mut n = norm(5);
2333        n.update(dec!(10));
2334        assert!(n.variance().is_none());
2335    }
2336
2337    #[test]
2338    fn test_minmax_variance_zero_all_same() {
2339        let mut n = norm(4);
2340        for _ in 0..4 { n.update(dec!(5)); }
2341        assert_eq!(n.variance(), Some(dec!(0)));
2342    }
2343
2344    #[test]
2345    fn test_minmax_variance_correct_value() {
2346        let mut n = norm(4);
2347        // values [2, 4, 4, 4, 5, 5, 7, 9] — classic example, pop variance = 4
2348        // use window=4, push last 4: [5, 5, 7, 9], mean=6.5, var=2.75
2349        for v in [dec!(5), dec!(5), dec!(7), dec!(9)] { n.update(v); }
2350        let var = n.variance().unwrap();
2351        // 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
2352        assert!((var.to_f64().unwrap() - 2.75).abs() < 1e-9);
2353    }
2354
2355    #[test]
2356    fn test_minmax_std_dev_none_fewer_than_2_obs() {
2357        let n = norm(4);
2358        assert!(n.std_dev().is_none());
2359    }
2360
2361    #[test]
2362    fn test_minmax_std_dev_zero_all_same() {
2363        let mut n = norm(3);
2364        for _ in 0..3 { n.update(dec!(7)); }
2365        assert_eq!(n.std_dev(), Some(0.0));
2366    }
2367
2368    #[test]
2369    fn test_minmax_std_dev_sqrt_of_variance() {
2370        let mut n = norm(4);
2371        for v in [dec!(5), dec!(5), dec!(7), dec!(9)] { n.update(v); }
2372        let sd = n.std_dev().unwrap();
2373        let var = n.variance().unwrap().to_f64().unwrap();
2374        assert!((sd - var.sqrt()).abs() < 1e-9);
2375    }
2376
2377    // ── MinMaxNormalizer::kurtosis ────────────────────────────────────────────
2378
2379    #[test]
2380    fn test_minmax_kurtosis_none_fewer_than_4_observations() {
2381        let mut n = norm(5);
2382        n.update(dec!(1));
2383        n.update(dec!(2));
2384        n.update(dec!(3));
2385        assert!(n.kurtosis().is_none());
2386    }
2387
2388    #[test]
2389    fn test_minmax_kurtosis_some_with_4_observations() {
2390        let mut n = norm(4);
2391        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
2392            n.update(v);
2393        }
2394        assert!(n.kurtosis().is_some());
2395    }
2396
2397    #[test]
2398    fn test_minmax_kurtosis_none_all_same_value() {
2399        let mut n = norm(4);
2400        for _ in 0..4 {
2401            n.update(dec!(5));
2402        }
2403        // std_dev = 0 → kurtosis undefined
2404        assert!(n.kurtosis().is_none());
2405    }
2406
2407    #[test]
2408    fn test_minmax_kurtosis_uniform_distribution_is_negative() {
2409        // Uniform distribution has excess kurtosis ≈ -1.2
2410        let mut n = norm(10);
2411        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5),
2412                  dec!(6), dec!(7), dec!(8), dec!(9), dec!(10)] {
2413            n.update(v);
2414        }
2415        let k = n.kurtosis().unwrap();
2416        assert!(k < 0.0, "uniform distribution should have negative excess kurtosis, got {k}");
2417    }
2418
2419    // ── MinMaxNormalizer::median ──────────────────────────────────────────────
2420
2421    #[test]
2422    fn test_minmax_median_none_for_empty_window() {
2423        assert!(norm(4).median().is_none());
2424    }
2425
2426    #[test]
2427    fn test_minmax_median_odd_window() {
2428        let mut n = norm(5);
2429        for v in [dec!(3), dec!(1), dec!(5), dec!(2), dec!(4)] { n.update(v); }
2430        // sorted: [1, 2, 3, 4, 5] → median = 3
2431        assert_eq!(n.median(), Some(dec!(3)));
2432    }
2433
2434    #[test]
2435    fn test_minmax_median_even_window() {
2436        let mut n = norm(4);
2437        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2438        // sorted: [1, 2, 3, 4] → median = (2+3)/2 = 2.5
2439        assert_eq!(n.median(), Some(dec!(2.5)));
2440    }
2441
2442    // ── MinMaxNormalizer::sample_variance ─────────────────────────────────────
2443
2444    #[test]
2445    fn test_minmax_sample_variance_none_for_single_obs() {
2446        let mut n = norm(4);
2447        n.update(dec!(10));
2448        assert!(n.sample_variance().is_none());
2449    }
2450
2451    #[test]
2452    fn test_minmax_sample_variance_larger_than_population_variance() {
2453        let mut n = norm(4);
2454        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
2455        use rust_decimal::prelude::ToPrimitive;
2456        let pop_var = n.variance().unwrap().to_f64().unwrap();
2457        let sample_var = n.sample_variance().unwrap();
2458        assert!(sample_var > pop_var, "sample variance should exceed population variance");
2459    }
2460
2461    // ── MinMaxNormalizer::mad ─────────────────────────────────────────────────
2462
2463    #[test]
2464    fn test_minmax_mad_none_for_empty_window() {
2465        assert!(norm(4).mad().is_none());
2466    }
2467
2468    #[test]
2469    fn test_minmax_mad_zero_for_identical_values() {
2470        let mut n = norm(4);
2471        for _ in 0..4 { n.update(dec!(5)); }
2472        assert_eq!(n.mad(), Some(dec!(0)));
2473    }
2474
2475    #[test]
2476    fn test_minmax_mad_correct_for_known_distribution() {
2477        let mut n = norm(5);
2478        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2479        // median = 3; deviations = [2,1,0,1,2] sorted → [0,1,1,2,2]; MAD = 1
2480        assert_eq!(n.mad(), Some(dec!(1)));
2481    }
2482
2483    // ── MinMaxNormalizer::robust_z_score ──────────────────────────────────────
2484
2485    #[test]
2486    fn test_minmax_robust_z_none_for_empty_window() {
2487        assert!(norm(4).robust_z_score(dec!(10)).is_none());
2488    }
2489
2490    #[test]
2491    fn test_minmax_robust_z_none_when_mad_is_zero() {
2492        let mut n = norm(4);
2493        for _ in 0..4 { n.update(dec!(5)); }
2494        assert!(n.robust_z_score(dec!(5)).is_none());
2495    }
2496
2497    #[test]
2498    fn test_minmax_robust_z_positive_above_median() {
2499        let mut n = norm(5);
2500        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2501        let rz = n.robust_z_score(dec!(5)).unwrap();
2502        assert!(rz > 0.0, "robust z-score should be positive for value above median");
2503    }
2504
2505    #[test]
2506    fn test_minmax_robust_z_negative_below_median() {
2507        let mut n = norm(5);
2508        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2509        let rz = n.robust_z_score(dec!(1)).unwrap();
2510        assert!(rz < 0.0, "robust z-score should be negative for value below median");
2511    }
2512
2513    // ── MinMaxNormalizer::percentile_value ────────────────────────────────────
2514
2515    #[test]
2516    fn test_percentile_value_none_for_empty_window() {
2517        assert!(norm(4).percentile_value(0.5).is_none());
2518    }
2519
2520    #[test]
2521    fn test_percentile_value_min_at_zero() {
2522        let mut n = norm(5);
2523        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
2524        assert_eq!(n.percentile_value(0.0), Some(dec!(10)));
2525    }
2526
2527    #[test]
2528    fn test_percentile_value_max_at_one() {
2529        let mut n = norm(5);
2530        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
2531        assert_eq!(n.percentile_value(1.0), Some(dec!(50)));
2532    }
2533
2534    #[test]
2535    fn test_percentile_value_median_at_half() {
2536        let mut n = norm(5);
2537        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
2538        // p=0.5 → idx=2.0 → exact middle = 30
2539        assert_eq!(n.percentile_value(0.5), Some(dec!(30)));
2540    }
2541
2542    // ── MinMaxNormalizer::sum ─────────────────────────────────────────────────
2543
2544    #[test]
2545    fn test_minmax_sum_none_for_empty_window() {
2546        assert!(norm(3).sum().is_none());
2547    }
2548
2549    #[test]
2550    fn test_minmax_sum_single_value() {
2551        let mut n = norm(3);
2552        n.update(dec!(7));
2553        assert_eq!(n.sum(), Some(dec!(7)));
2554    }
2555
2556    #[test]
2557    fn test_minmax_sum_multiple_values() {
2558        let mut n = norm(4);
2559        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2560        assert_eq!(n.sum(), Some(dec!(10)));
2561    }
2562
2563    // ── MinMaxNormalizer::is_outlier ──────────────────────────────────────────
2564
2565    #[test]
2566    fn test_minmax_is_outlier_false_for_empty_window() {
2567        assert!(!norm(3).is_outlier(dec!(100), 2.0));
2568    }
2569
2570    #[test]
2571    fn test_minmax_is_outlier_false_for_in_range_value() {
2572        let mut n = norm(5);
2573        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2574        assert!(!n.is_outlier(dec!(3), 2.0));
2575    }
2576
2577    #[test]
2578    fn test_minmax_is_outlier_true_for_extreme_value() {
2579        let mut n = norm(5);
2580        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2581        assert!(n.is_outlier(dec!(100), 2.0));
2582    }
2583
2584    // ── MinMaxNormalizer::trim_outliers ───────────────────────────────────────
2585
2586    #[test]
2587    fn test_minmax_trim_outliers_returns_all_when_no_outliers() {
2588        let mut n = norm(5);
2589        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2590        let trimmed = n.trim_outliers(10.0);
2591        assert_eq!(trimmed.len(), 5);
2592    }
2593
2594    #[test]
2595    fn test_minmax_trim_outliers_removes_extreme_values() {
2596        let mut n = norm(5);
2597        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2598        // sigma=0 means everything is an outlier
2599        let trimmed = n.trim_outliers(0.0);
2600        assert_eq!(trimmed.len(), 1); // only the mean value (z=0) passes
2601    }
2602
2603    // ── MinMaxNormalizer::z_score_of_latest ───────────────────────────────────
2604
2605    #[test]
2606    fn test_minmax_z_score_of_latest_none_for_empty_window() {
2607        assert!(norm(3).z_score_of_latest().is_none());
2608    }
2609
2610    #[test]
2611    fn test_minmax_z_score_of_latest_zero_for_single_value() {
2612        let mut n = norm(5);
2613        n.update(dec!(10));
2614        // std_dev is zero for single value → None
2615        assert!(n.z_score_of_latest().is_none());
2616    }
2617
2618    #[test]
2619    fn test_minmax_z_score_of_latest_positive_for_above_mean() {
2620        let mut n = norm(5);
2621        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(10)] { n.update(v); }
2622        let z = n.z_score_of_latest().unwrap();
2623        assert!(z > 0.0, "latest value is above mean → positive z-score");
2624    }
2625
2626    // ── MinMaxNormalizer::deviation_from_mean ─────────────────────────────────
2627
2628    #[test]
2629    fn test_minmax_deviation_from_mean_none_for_empty_window() {
2630        assert!(norm(3).deviation_from_mean(dec!(5)).is_none());
2631    }
2632
2633    #[test]
2634    fn test_minmax_deviation_from_mean_zero_at_mean() {
2635        let mut n = norm(4);
2636        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2637        // mean = 2.5
2638        let dev = n.deviation_from_mean(dec!(2.5)).unwrap();
2639        assert!(dev.abs() < 1e-9);
2640    }
2641
2642    #[test]
2643    fn test_minmax_deviation_from_mean_positive_above_mean() {
2644        let mut n = norm(4);
2645        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2646        let dev = n.deviation_from_mean(dec!(5)).unwrap();
2647        assert!(dev > 0.0);
2648    }
2649
2650    // ── MinMaxNormalizer::range_f64 / sum_f64 ─────────────────────────────────
2651
2652    #[test]
2653    fn test_minmax_range_f64_none_for_empty_window() {
2654        assert!(norm(3).range_f64().is_none());
2655    }
2656
2657    #[test]
2658    fn test_minmax_range_f64_correct() {
2659        let mut n = norm(4);
2660        for v in [dec!(5), dec!(15), dec!(10), dec!(20)] { n.update(v); }
2661        // range = 20 - 5 = 15
2662        let r = n.range_f64().unwrap();
2663        assert!((r - 15.0).abs() < 1e-9);
2664    }
2665
2666    #[test]
2667    fn test_minmax_sum_f64_none_for_empty_window() {
2668        assert!(norm(3).sum_f64().is_none());
2669    }
2670
2671    #[test]
2672    fn test_minmax_sum_f64_correct() {
2673        let mut n = norm(4);
2674        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2675        let s = n.sum_f64().unwrap();
2676        assert!((s - 10.0).abs() < 1e-9);
2677    }
2678
2679    // ── MinMaxNormalizer::values ──────────────────────────────────────────────
2680
2681    #[test]
2682    fn test_minmax_values_empty_for_empty_window() {
2683        assert!(norm(3).values().is_empty());
2684    }
2685
2686    #[test]
2687    fn test_minmax_values_preserves_insertion_order() {
2688        let mut n = norm(5);
2689        for v in [dec!(3), dec!(1), dec!(4), dec!(1), dec!(5)] { n.update(v); }
2690        assert_eq!(n.values(), vec![dec!(3), dec!(1), dec!(4), dec!(1), dec!(5)]);
2691    }
2692
2693    // ── MinMaxNormalizer::normalized_midpoint ─────────────────────────────────
2694
2695    #[test]
2696    fn test_minmax_normalized_midpoint_none_for_empty_window() {
2697        assert!(norm(3).normalized_midpoint().is_none());
2698    }
2699
2700    #[test]
2701    fn test_minmax_normalized_midpoint_half_for_uniform_range() {
2702        let mut n = norm(4);
2703        for v in [dec!(0), dec!(10), dec!(20), dec!(30)] { n.update(v); }
2704        // midpoint = (0+30)/2 = 15; normalize(15) with min=0, max=30 = 0.5
2705        let mid = n.normalized_midpoint().unwrap();
2706        assert!((mid - 0.5).abs() < 1e-9);
2707    }
2708
2709    // ── MinMaxNormalizer::is_at_min / is_at_max ───────────────────────────────
2710
2711    #[test]
2712    fn test_minmax_is_at_min_false_for_empty_window() {
2713        assert!(!norm(3).is_at_min(dec!(5)));
2714    }
2715
2716    #[test]
2717    fn test_minmax_is_at_min_true_for_minimum_value() {
2718        let mut n = norm(4);
2719        for v in [dec!(10), dec!(20), dec!(5), dec!(30)] { n.update(v); }
2720        assert!(n.is_at_min(dec!(5)));
2721    }
2722
2723    #[test]
2724    fn test_minmax_is_at_min_false_for_non_minimum() {
2725        let mut n = norm(4);
2726        for v in [dec!(10), dec!(20), dec!(5), dec!(30)] { n.update(v); }
2727        assert!(!n.is_at_min(dec!(10)));
2728    }
2729
2730    #[test]
2731    fn test_minmax_is_at_max_false_for_empty_window() {
2732        assert!(!norm(3).is_at_max(dec!(5)));
2733    }
2734
2735    #[test]
2736    fn test_minmax_is_at_max_true_for_maximum_value() {
2737        let mut n = norm(4);
2738        for v in [dec!(10), dec!(20), dec!(5), dec!(30)] { n.update(v); }
2739        assert!(n.is_at_max(dec!(30)));
2740    }
2741
2742    #[test]
2743    fn test_minmax_is_at_max_false_for_non_maximum() {
2744        let mut n = norm(4);
2745        for v in [dec!(10), dec!(20), dec!(5), dec!(30)] { n.update(v); }
2746        assert!(!n.is_at_max(dec!(20)));
2747    }
2748
2749    // ── MinMaxNormalizer::fraction_above / fraction_below ─────────────────────
2750
2751    #[test]
2752    fn test_minmax_fraction_above_none_for_empty_window() {
2753        assert!(norm(3).fraction_above(dec!(5)).is_none());
2754    }
2755
2756    #[test]
2757    fn test_minmax_fraction_above_correct() {
2758        let mut n = norm(5);
2759        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2760        // above 3: {4, 5} = 2/5 = 0.4
2761        let frac = n.fraction_above(dec!(3)).unwrap();
2762        assert!((frac - 0.4).abs() < 1e-9);
2763    }
2764
2765    #[test]
2766    fn test_minmax_fraction_below_none_for_empty_window() {
2767        assert!(norm(3).fraction_below(dec!(5)).is_none());
2768    }
2769
2770    #[test]
2771    fn test_minmax_fraction_below_correct() {
2772        let mut n = norm(5);
2773        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2774        // below 3: {1, 2} = 2/5 = 0.4
2775        let frac = n.fraction_below(dec!(3)).unwrap();
2776        assert!((frac - 0.4).abs() < 1e-9);
2777    }
2778
2779    // ── MinMaxNormalizer::window_values_above / window_values_below ───────────
2780
2781    #[test]
2782    fn test_minmax_window_values_above_empty_window() {
2783        assert!(norm(3).window_values_above(dec!(5)).is_empty());
2784    }
2785
2786    #[test]
2787    fn test_minmax_window_values_above_filters_correctly() {
2788        let mut n = norm(5);
2789        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
2790        let above = n.window_values_above(dec!(5));
2791        assert_eq!(above.len(), 2);
2792        assert!(above.contains(&dec!(7)));
2793        assert!(above.contains(&dec!(9)));
2794    }
2795
2796    #[test]
2797    fn test_minmax_window_values_below_empty_window() {
2798        assert!(norm(3).window_values_below(dec!(5)).is_empty());
2799    }
2800
2801    #[test]
2802    fn test_minmax_window_values_below_filters_correctly() {
2803        let mut n = norm(5);
2804        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
2805        let below = n.window_values_below(dec!(5));
2806        assert_eq!(below.len(), 2);
2807        assert!(below.contains(&dec!(1)));
2808        assert!(below.contains(&dec!(3)));
2809    }
2810
2811    // ── MinMaxNormalizer::percentile_rank ─────────────────────────────────────
2812
2813    #[test]
2814    fn test_minmax_percentile_rank_none_for_empty_window() {
2815        assert!(norm(3).percentile_rank(dec!(5)).is_none());
2816    }
2817
2818    #[test]
2819    fn test_minmax_percentile_rank_correct() {
2820        let mut n = norm(5);
2821        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2822        // <= 3: {1, 2, 3} = 3/5 = 0.6
2823        let rank = n.percentile_rank(dec!(3)).unwrap();
2824        assert!((rank - 0.6).abs() < 1e-9);
2825    }
2826
2827    // ── MinMaxNormalizer::count_equal ─────────────────────────────────────────
2828
2829    #[test]
2830    fn test_minmax_count_equal_zero_for_no_match() {
2831        let mut n = norm(3);
2832        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
2833        assert_eq!(n.count_equal(dec!(99)), 0);
2834    }
2835
2836    #[test]
2837    fn test_minmax_count_equal_counts_duplicates() {
2838        let mut n = norm(5);
2839        for v in [dec!(5), dec!(5), dec!(3), dec!(5), dec!(2)] { n.update(v); }
2840        assert_eq!(n.count_equal(dec!(5)), 3);
2841    }
2842
2843    // ── MinMaxNormalizer::rolling_range ───────────────────────────────────────
2844
2845    #[test]
2846    fn test_minmax_rolling_range_none_for_empty() {
2847        assert!(norm(3).rolling_range().is_none());
2848    }
2849
2850    #[test]
2851    fn test_minmax_rolling_range_correct() {
2852        let mut n = norm(5);
2853        for v in [dec!(10), dec!(50), dec!(30), dec!(20), dec!(40)] { n.update(v); }
2854        assert_eq!(n.rolling_range(), Some(dec!(40)));
2855    }
2856
2857    // ── MinMaxNormalizer::skewness ─────────────────────────────────────────────
2858
2859    #[test]
2860    fn test_minmax_skewness_none_for_fewer_than_3() {
2861        let mut n = norm(5);
2862        n.update(dec!(1)); n.update(dec!(2));
2863        assert!(n.skewness().is_none());
2864    }
2865
2866    #[test]
2867    fn test_minmax_skewness_near_zero_for_symmetric_data() {
2868        let mut n = norm(5);
2869        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2870        let s = n.skewness().unwrap();
2871        assert!(s.abs() < 0.5);
2872    }
2873
2874    // ── MinMaxNormalizer::kurtosis ─────────────────────────────────────
2875
2876    #[test]
2877    fn test_minmax_kurtosis_none_for_fewer_than_4() {
2878        let mut n = norm(5);
2879        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
2880        assert!(n.kurtosis().is_none());
2881    }
2882
2883    #[test]
2884    fn test_minmax_kurtosis_returns_f64_for_populated_window() {
2885        let mut n = norm(5);
2886        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2887        assert!(n.kurtosis().is_some());
2888    }
2889
2890    // ── MinMaxNormalizer::autocorrelation_lag1 ────────────────────────────────
2891
2892    #[test]
2893    fn test_minmax_autocorrelation_none_for_single_value() {
2894        let mut n = norm(3);
2895        n.update(dec!(1));
2896        assert!(n.autocorrelation_lag1().is_none());
2897    }
2898
2899    #[test]
2900    fn test_minmax_autocorrelation_positive_for_trending_data() {
2901        let mut n = norm(5);
2902        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2903        let ac = n.autocorrelation_lag1().unwrap();
2904        assert!(ac > 0.0);
2905    }
2906
2907    // ── MinMaxNormalizer::trend_consistency ───────────────────────────────────
2908
2909    #[test]
2910    fn test_minmax_trend_consistency_none_for_single_value() {
2911        let mut n = norm(3);
2912        n.update(dec!(1));
2913        assert!(n.trend_consistency().is_none());
2914    }
2915
2916    #[test]
2917    fn test_minmax_trend_consistency_one_for_strictly_rising() {
2918        let mut n = norm(5);
2919        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2920        let tc = n.trend_consistency().unwrap();
2921        assert!((tc - 1.0).abs() < 1e-9);
2922    }
2923
2924    #[test]
2925    fn test_minmax_trend_consistency_zero_for_strictly_falling() {
2926        let mut n = norm(5);
2927        for v in [dec!(5), dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
2928        let tc = n.trend_consistency().unwrap();
2929        assert!((tc - 0.0).abs() < 1e-9);
2930    }
2931
2932    // ── MinMaxNormalizer::coefficient_of_variation ────────────────────────────
2933
2934    #[test]
2935    fn test_minmax_cov_none_for_single_value() {
2936        let mut n = norm(3);
2937        n.update(dec!(10));
2938        assert!(n.coefficient_of_variation().is_none());
2939    }
2940
2941    #[test]
2942    fn test_minmax_cov_positive_for_varied_data() {
2943        let mut n = norm(5);
2944        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
2945        let cov = n.coefficient_of_variation().unwrap();
2946        assert!(cov > 0.0);
2947    }
2948
2949    // ── MinMaxNormalizer::mean_absolute_deviation ─────────────────────────────
2950
2951    #[test]
2952    fn test_minmax_mean_absolute_deviation_none_for_empty() {
2953        assert!(norm(3).mean_absolute_deviation().is_none());
2954    }
2955
2956    #[test]
2957    fn test_minmax_mean_absolute_deviation_zero_for_identical_values() {
2958        let mut n = norm(3);
2959        for v in [dec!(5), dec!(5), dec!(5)] { n.update(v); }
2960        let mad = n.mean_absolute_deviation().unwrap();
2961        assert!((mad - 0.0).abs() < 1e-9);
2962    }
2963
2964    #[test]
2965    fn test_minmax_mean_absolute_deviation_positive_for_varied_data() {
2966        let mut n = norm(4);
2967        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2968        let mad = n.mean_absolute_deviation().unwrap();
2969        assert!(mad > 0.0);
2970    }
2971
2972    // ── MinMaxNormalizer::percentile_of_latest ────────────────────────────────
2973
2974    #[test]
2975    fn test_minmax_percentile_of_latest_none_for_empty() {
2976        assert!(norm(3).percentile_of_latest().is_none());
2977    }
2978
2979    #[test]
2980    fn test_minmax_percentile_of_latest_returns_some_after_update() {
2981        let mut n = norm(4);
2982        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
2983        assert!(n.percentile_of_latest().is_some());
2984    }
2985
2986    #[test]
2987    fn test_minmax_percentile_of_latest_max_has_high_rank() {
2988        let mut n = norm(5);
2989        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
2990        let rank = n.percentile_of_latest().unwrap();
2991        assert!(rank >= 0.9, "max value should have rank near 1.0, got {}", rank);
2992    }
2993
2994    // ── MinMaxNormalizer::tail_ratio ──────────────────────────────────────────
2995
2996    #[test]
2997    fn test_minmax_tail_ratio_none_for_empty() {
2998        assert!(norm(4).tail_ratio().is_none());
2999    }
3000
3001    #[test]
3002    fn test_minmax_tail_ratio_one_for_identical_values() {
3003        let mut n = norm(4);
3004        for _ in 0..4 { n.update(dec!(7)); }
3005        // p75 == max == 7 → ratio = 1.0
3006        let r = n.tail_ratio().unwrap();
3007        assert!((r - 1.0).abs() < 1e-9, "expected 1.0, got {}", r);
3008    }
3009
3010    #[test]
3011    fn test_minmax_tail_ratio_above_one_with_outlier() {
3012        let mut n = norm(5);
3013        for v in [dec!(1), dec!(1), dec!(1), dec!(1), dec!(10)] { n.update(v); }
3014        let r = n.tail_ratio().unwrap();
3015        assert!(r > 1.0, "outlier should push ratio above 1.0, got {}", r);
3016    }
3017
3018    // ── MinMaxNormalizer::z_score_of_min / z_score_of_max ─────────────────────
3019
3020    #[test]
3021    fn test_minmax_z_score_of_min_none_for_empty() {
3022        assert!(norm(4).z_score_of_min().is_none());
3023    }
3024
3025    #[test]
3026    fn test_minmax_z_score_of_max_none_for_empty() {
3027        assert!(norm(4).z_score_of_max().is_none());
3028    }
3029
3030    #[test]
3031    fn test_minmax_z_score_of_min_negative_for_varied_window() {
3032        let mut n = norm(5);
3033        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
3034        // min=1, mean=3, so z-score should be negative
3035        let z = n.z_score_of_min().unwrap();
3036        assert!(z < 0.0, "z-score of min should be negative, got {}", z);
3037    }
3038
3039    #[test]
3040    fn test_minmax_z_score_of_max_positive_for_varied_window() {
3041        let mut n = norm(5);
3042        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
3043        // max=5, mean=3, so z-score should be positive
3044        let z = n.z_score_of_max().unwrap();
3045        assert!(z > 0.0, "z-score of max should be positive, got {}", z);
3046    }
3047
3048    // ── MinMaxNormalizer::window_entropy ──────────────────────────────────────
3049
3050    #[test]
3051    fn test_minmax_window_entropy_none_for_empty() {
3052        assert!(norm(4).window_entropy().is_none());
3053    }
3054
3055    #[test]
3056    fn test_minmax_window_entropy_zero_for_identical_values() {
3057        let mut n = norm(3);
3058        for _ in 0..3 { n.update(dec!(5)); }
3059        let e = n.window_entropy().unwrap();
3060        assert!((e - 0.0).abs() < 1e-9, "identical values should have zero entropy, got {}", e);
3061    }
3062
3063    #[test]
3064    fn test_minmax_window_entropy_positive_for_varied_values() {
3065        let mut n = norm(4);
3066        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3067        let e = n.window_entropy().unwrap();
3068        assert!(e > 0.0, "varied values should have positive entropy, got {}", e);
3069    }
3070
3071    // ── MinMaxNormalizer::normalized_std_dev ──────────────────────────────────
3072
3073    #[test]
3074    fn test_minmax_normalized_std_dev_none_for_single_value() {
3075        let mut n = norm(4);
3076        n.update(dec!(5));
3077        assert!(n.normalized_std_dev().is_none());
3078    }
3079
3080    #[test]
3081    fn test_minmax_normalized_std_dev_positive_for_varied_values() {
3082        let mut n = norm(4);
3083        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3084        let r = n.normalized_std_dev().unwrap();
3085        assert!(r > 0.0, "expected positive normalized std dev, got {}", r);
3086    }
3087
3088    // ── MinMaxNormalizer::value_above_mean_count ──────────────────────────────
3089
3090    #[test]
3091    fn test_minmax_value_above_mean_count_none_for_empty() {
3092        assert!(norm(4).value_above_mean_count().is_none());
3093    }
3094
3095    #[test]
3096    fn test_minmax_value_above_mean_count_correct() {
3097        // values: 1,2,3,4 → mean=2.5 → above: 3,4 → count=2
3098        let mut n = norm(4);
3099        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3100        assert_eq!(n.value_above_mean_count().unwrap(), 2);
3101    }
3102
3103    // ── MinMaxNormalizer::consecutive_above_mean ──────────────────────────────
3104
3105    #[test]
3106    fn test_minmax_consecutive_above_mean_none_for_empty() {
3107        assert!(norm(4).consecutive_above_mean().is_none());
3108    }
3109
3110    #[test]
3111    fn test_minmax_consecutive_above_mean_correct() {
3112        // values: 1,5,6,7 → mean=4.75 → above: 5,6,7 → run=3
3113        let mut n = norm(4);
3114        for v in [dec!(1), dec!(5), dec!(6), dec!(7)] { n.update(v); }
3115        assert_eq!(n.consecutive_above_mean().unwrap(), 3);
3116    }
3117
3118    // ── MinMaxNormalizer::above_threshold_fraction / below_threshold_fraction ─
3119
3120    #[test]
3121    fn test_minmax_above_threshold_fraction_none_for_empty() {
3122        assert!(norm(4).above_threshold_fraction(dec!(5)).is_none());
3123    }
3124
3125    #[test]
3126    fn test_minmax_above_threshold_fraction_correct() {
3127        let mut n = norm(4);
3128        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3129        // above 2: values 3,4 → fraction = 0.5
3130        let f = n.above_threshold_fraction(dec!(2)).unwrap();
3131        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
3132    }
3133
3134    #[test]
3135    fn test_minmax_below_threshold_fraction_none_for_empty() {
3136        assert!(norm(4).below_threshold_fraction(dec!(5)).is_none());
3137    }
3138
3139    #[test]
3140    fn test_minmax_below_threshold_fraction_correct() {
3141        let mut n = norm(4);
3142        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3143        // below 3: values 1,2 → fraction = 0.5
3144        let f = n.below_threshold_fraction(dec!(3)).unwrap();
3145        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
3146    }
3147
3148    // ── MinMaxNormalizer::lag_k_autocorrelation ───────────────────────────────
3149
3150    #[test]
3151    fn test_minmax_lag_k_autocorrelation_none_for_zero_k() {
3152        let mut n = norm(5);
3153        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
3154        assert!(n.lag_k_autocorrelation(0).is_none());
3155    }
3156
3157    #[test]
3158    fn test_minmax_lag_k_autocorrelation_none_when_k_gte_len() {
3159        let mut n = norm(3);
3160        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
3161        assert!(n.lag_k_autocorrelation(3).is_none());
3162    }
3163
3164    #[test]
3165    fn test_minmax_lag_k_autocorrelation_positive_for_trend() {
3166        // Monotone increasing → strong positive autocorrelation
3167        let mut n = norm(6);
3168        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(6)] { n.update(v); }
3169        let ac = n.lag_k_autocorrelation(1).unwrap();
3170        assert!(ac > 0.0, "trending series should have positive AC, got {}", ac);
3171    }
3172
3173    // ── MinMaxNormalizer::half_life_estimate ──────────────────────────────────
3174
3175    #[test]
3176    fn test_minmax_half_life_estimate_none_for_fewer_than_3() {
3177        let mut n = norm(3);
3178        n.update(dec!(1)); n.update(dec!(2));
3179        assert!(n.half_life_estimate().is_none());
3180    }
3181
3182    #[test]
3183    fn test_minmax_half_life_estimate_some_for_mean_reverting() {
3184        // Alternating series: strong mean-reversion signal
3185        let mut n = norm(6);
3186        for v in [dec!(10), dec!(5), dec!(10), dec!(5), dec!(10), dec!(5)] { n.update(v); }
3187        // May or may not return Some depending on AR coefficient sign; just ensure no panic
3188        let _ = n.half_life_estimate();
3189    }
3190
3191    // ── MinMaxNormalizer::geometric_mean ──────────────────────────────────────
3192
3193    #[test]
3194    fn test_minmax_geometric_mean_none_for_empty() {
3195        assert!(norm(4).geometric_mean().is_none());
3196    }
3197
3198    #[test]
3199    fn test_minmax_geometric_mean_correct_for_powers_of_2() {
3200        // geomean(1,2,4,8) = (1*2*4*8)^(1/4) = 64^0.25 = 2.828...
3201        let mut n = norm(4);
3202        for v in [dec!(1), dec!(2), dec!(4), dec!(8)] { n.update(v); }
3203        let gm = n.geometric_mean().unwrap();
3204        assert!((gm - 64.0f64.powf(0.25)).abs() < 1e-6, "got {}", gm);
3205    }
3206
3207    // ── MinMaxNormalizer::harmonic_mean ───────────────────────────────────────
3208
3209    #[test]
3210    fn test_minmax_harmonic_mean_none_for_empty() {
3211        assert!(norm(4).harmonic_mean().is_none());
3212    }
3213
3214    #[test]
3215    fn test_minmax_harmonic_mean_none_when_any_zero() {
3216        let mut n = norm(2);
3217        n.update(dec!(0)); n.update(dec!(5));
3218        assert!(n.harmonic_mean().is_none());
3219    }
3220
3221    #[test]
3222    fn test_minmax_harmonic_mean_positive_for_positive_values() {
3223        let mut n = norm(4);
3224        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3225        let hm = n.harmonic_mean().unwrap();
3226        assert!(hm > 0.0 && hm < 4.0, "HM should be in (0, max), got {}", hm);
3227    }
3228
3229    // ── MinMaxNormalizer::range_normalized_value ──────────────────────────────
3230
3231    #[test]
3232    fn test_minmax_range_normalized_value_none_for_empty() {
3233        assert!(norm(4).range_normalized_value(dec!(5)).is_none());
3234    }
3235
3236    #[test]
3237    fn test_minmax_range_normalized_value_zero_for_min() {
3238        let mut n = norm(4);
3239        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3240        let r = n.range_normalized_value(dec!(1)).unwrap();
3241        assert!((r - 0.0).abs() < 1e-9, "min value should normalize to 0, got {}", r);
3242    }
3243
3244    #[test]
3245    fn test_minmax_range_normalized_value_one_for_max() {
3246        let mut n = norm(4);
3247        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3248        let r = n.range_normalized_value(dec!(4)).unwrap();
3249        assert!((r - 1.0).abs() < 1e-9, "max value should normalize to 1, got {}", r);
3250    }
3251
3252    // ── MinMaxNormalizer::distance_from_median ────────────────────────────────
3253
3254    #[test]
3255    fn test_minmax_distance_from_median_none_for_empty() {
3256        assert!(norm(4).distance_from_median(dec!(5)).is_none());
3257    }
3258
3259    #[test]
3260    fn test_minmax_distance_from_median_zero_at_median() {
3261        let mut n = norm(5);
3262        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
3263        // median=3
3264        let d = n.distance_from_median(dec!(3)).unwrap();
3265        assert!((d - 0.0).abs() < 1e-9, "distance from median should be 0, got {}", d);
3266    }
3267
3268    #[test]
3269    fn test_minmax_distance_from_median_positive_above() {
3270        let mut n = norm(5);
3271        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
3272        let d = n.distance_from_median(dec!(5)).unwrap();
3273        assert!(d > 0.0, "value above median should give positive distance, got {}", d);
3274    }
3275
3276    #[test]
3277    fn test_minmax_momentum_none_for_single_value() {
3278        let mut n = norm(5);
3279        n.update(dec!(10));
3280        assert!(n.momentum().is_none());
3281    }
3282
3283    #[test]
3284    fn test_minmax_momentum_positive_for_rising_window() {
3285        let mut n = norm(3);
3286        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
3287        let m = n.momentum().unwrap();
3288        assert!(m > 0.0, "rising window → positive momentum, got {}", m);
3289    }
3290
3291    #[test]
3292    fn test_minmax_value_rank_none_for_empty() {
3293        assert!(norm(4).value_rank(dec!(5)).is_none());
3294    }
3295
3296    #[test]
3297    fn test_minmax_value_rank_extremes() {
3298        let mut n = norm(4);
3299        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3300        // value below all: rank = 0/4 = 0.0
3301        let low = n.value_rank(dec!(0)).unwrap();
3302        assert!((low - 0.0).abs() < 1e-9, "got {}", low);
3303        // value above all: rank = 4/4 = 1.0
3304        let high = n.value_rank(dec!(5)).unwrap();
3305        assert!((high - 1.0).abs() < 1e-9, "got {}", high);
3306    }
3307
3308    #[test]
3309    fn test_minmax_coeff_of_variation_none_for_single_value() {
3310        let mut n = norm(5);
3311        n.update(dec!(10));
3312        assert!(n.coeff_of_variation().is_none());
3313    }
3314
3315    #[test]
3316    fn test_minmax_coeff_of_variation_positive_for_spread() {
3317        let mut n = norm(4);
3318        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
3319        let cv = n.coeff_of_variation().unwrap();
3320        assert!(cv > 0.0, "expected positive CV, got {}", cv);
3321    }
3322
3323    #[test]
3324    fn test_minmax_quantile_range_none_for_empty() {
3325        assert!(norm(4).quantile_range().is_none());
3326    }
3327
3328    #[test]
3329    fn test_minmax_quantile_range_non_negative() {
3330        let mut n = norm(5);
3331        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
3332        let iqr = n.quantile_range().unwrap();
3333        assert!(iqr >= 0.0, "IQR should be non-negative, got {}", iqr);
3334    }
3335
3336    // ── MinMaxNormalizer::upper_quartile / lower_quartile ─────────────────────
3337
3338    #[test]
3339    fn test_minmax_upper_quartile_none_for_empty() {
3340        assert!(norm(4).upper_quartile().is_none());
3341    }
3342
3343    #[test]
3344    fn test_minmax_lower_quartile_none_for_empty() {
3345        assert!(norm(4).lower_quartile().is_none());
3346    }
3347
3348    #[test]
3349    fn test_minmax_upper_ge_lower_quartile() {
3350        let mut n = norm(8);
3351        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(6), dec!(7), dec!(8)] {
3352            n.update(v);
3353        }
3354        let q3 = n.upper_quartile().unwrap();
3355        let q1 = n.lower_quartile().unwrap();
3356        assert!(q3 >= q1, "Q3 ({}) should be >= Q1 ({})", q3, q1);
3357    }
3358
3359    // ── MinMaxNormalizer::sign_change_rate ────────────────────────────────────
3360
3361    #[test]
3362    fn test_minmax_sign_change_rate_none_for_fewer_than_3() {
3363        let mut n = norm(4);
3364        n.update(dec!(1));
3365        n.update(dec!(2));
3366        assert!(n.sign_change_rate().is_none());
3367    }
3368
3369    #[test]
3370    fn test_minmax_sign_change_rate_one_for_zigzag() {
3371        let mut n = norm(5);
3372        // 1, 3, 1, 3, 1 → diffs: +,−,+,− → every adjacent pair flips → 1.0
3373        for v in [dec!(1), dec!(3), dec!(1), dec!(3), dec!(1)] { n.update(v); }
3374        let r = n.sign_change_rate().unwrap();
3375        assert!((r - 1.0).abs() < 1e-9, "zigzag should give 1.0, got {}", r);
3376    }
3377
3378    #[test]
3379    fn test_minmax_sign_change_rate_zero_for_monotone() {
3380        let mut n = norm(5);
3381        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
3382        let r = n.sign_change_rate().unwrap();
3383        assert!((r - 0.0).abs() < 1e-9, "monotone should give 0.0, got {}", r);
3384    }
3385
3386    // ── round-79 ─────────────────────────────────────────────────────────────
3387
3388    // ── MinMaxNormalizer::consecutive_below_mean ──────────────────────────────
3389
3390    #[test]
3391    fn test_consecutive_below_mean_none_for_single_value() {
3392        let mut n = norm(5);
3393        n.update(dec!(10));
3394        assert!(n.consecutive_below_mean().is_none());
3395    }
3396
3397    #[test]
3398    fn test_consecutive_below_mean_zero_when_latest_above_mean() {
3399        let mut n = norm(5);
3400        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(100)] { n.update(v); }
3401        let c = n.consecutive_below_mean().unwrap();
3402        assert_eq!(c, 0, "latest above mean → streak=0, got {}", c);
3403    }
3404
3405    #[test]
3406    fn test_consecutive_below_mean_counts_trailing_below() {
3407        let mut n = norm(5);
3408        for v in [dec!(100), dec!(100), dec!(1), dec!(1), dec!(1)] { n.update(v); }
3409        let c = n.consecutive_below_mean().unwrap();
3410        assert!(c >= 3, "last 3 below mean → streak>=3, got {}", c);
3411    }
3412
3413    // ── MinMaxNormalizer::drift_rate ──────────────────────────────────────────
3414
3415    #[test]
3416    fn test_drift_rate_none_for_single_value() {
3417        let mut n = norm(5);
3418        n.update(dec!(10));
3419        assert!(n.drift_rate().is_none());
3420    }
3421
3422    #[test]
3423    fn test_drift_rate_positive_for_rising_series() {
3424        let mut n = norm(6);
3425        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(6)] { n.update(v); }
3426        let d = n.drift_rate().unwrap();
3427        assert!(d > 0.0, "rising series → positive drift, got {}", d);
3428    }
3429
3430    #[test]
3431    fn test_drift_rate_negative_for_falling_series() {
3432        let mut n = norm(6);
3433        for v in [dec!(6), dec!(5), dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
3434        let d = n.drift_rate().unwrap();
3435        assert!(d < 0.0, "falling series → negative drift, got {}", d);
3436    }
3437
3438    // ── MinMaxNormalizer::peak_to_trough_ratio ────────────────────────────────
3439
3440    #[test]
3441    fn test_peak_to_trough_ratio_none_for_empty() {
3442        assert!(norm(4).peak_to_trough_ratio().is_none());
3443    }
3444
3445    #[test]
3446    fn test_peak_to_trough_ratio_one_for_constant() {
3447        let mut n = norm(4);
3448        for v in [dec!(10), dec!(10), dec!(10), dec!(10)] { n.update(v); }
3449        let r = n.peak_to_trough_ratio().unwrap();
3450        assert!((r - 1.0).abs() < 1e-9, "constant → ratio=1, got {}", r);
3451    }
3452
3453    #[test]
3454    fn test_peak_to_trough_ratio_above_one_for_spread() {
3455        let mut n = norm(4);
3456        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
3457        let r = n.peak_to_trough_ratio().unwrap();
3458        assert!(r > 1.0, "spread → ratio>1, got {}", r);
3459    }
3460
3461    // ── MinMaxNormalizer::normalized_deviation ────────────────────────────────
3462
3463    #[test]
3464    fn test_normalized_deviation_none_for_empty() {
3465        assert!(norm(4).normalized_deviation().is_none());
3466    }
3467
3468    #[test]
3469    fn test_normalized_deviation_none_for_constant() {
3470        let mut n = norm(4);
3471        for v in [dec!(5), dec!(5), dec!(5), dec!(5)] { n.update(v); }
3472        assert!(n.normalized_deviation().is_none());
3473    }
3474
3475    #[test]
3476    fn test_normalized_deviation_positive_for_latest_above_mean() {
3477        let mut n = norm(5);
3478        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(10)] { n.update(v); }
3479        let d = n.normalized_deviation().unwrap();
3480        assert!(d > 0.0, "latest above mean → positive deviation, got {}", d);
3481    }
3482
3483    // ── MinMaxNormalizer::window_cv_pct ───────────────────────────────────────
3484
3485    #[test]
3486    fn test_window_cv_pct_none_for_single_value() {
3487        let mut n = norm(5);
3488        n.update(dec!(10));
3489        assert!(n.window_cv_pct().is_none());
3490    }
3491
3492    #[test]
3493    fn test_window_cv_pct_positive_for_varied_values() {
3494        let mut n = norm(4);
3495        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
3496        let cv = n.window_cv_pct().unwrap();
3497        assert!(cv > 0.0, "expected positive CV%, got {}", cv);
3498    }
3499
3500    // ── MinMaxNormalizer::latest_rank_pct ─────────────────────────────────────
3501
3502    #[test]
3503    fn test_latest_rank_pct_none_for_single_value() {
3504        let mut n = norm(5);
3505        n.update(dec!(10));
3506        assert!(n.latest_rank_pct().is_none());
3507    }
3508
3509    #[test]
3510    fn test_latest_rank_pct_one_for_max_value() {
3511        let mut n = norm(4);
3512        for v in [dec!(1), dec!(2), dec!(3), dec!(100)] { n.update(v); }
3513        let r = n.latest_rank_pct().unwrap();
3514        assert!((r - 1.0).abs() < 1e-9, "latest is max → rank=1, got {}", r);
3515    }
3516
3517    #[test]
3518    fn test_latest_rank_pct_zero_for_min_value() {
3519        let mut n = norm(4);
3520        for v in [dec!(10), dec!(20), dec!(30), dec!(1)] { n.update(v); }
3521        let r = n.latest_rank_pct().unwrap();
3522        assert!(r.abs() < 1e-9, "latest is min → rank=0, got {}", r);
3523    }
3524
3525    // ── MinMaxNormalizer::trimmed_mean ────────────────────────────────────────
3526
3527    #[test]
3528    fn test_minmax_trimmed_mean_none_for_empty() {
3529        assert!(norm(4).trimmed_mean(0.1).is_none());
3530    }
3531
3532    #[test]
3533    fn test_minmax_trimmed_mean_equals_mean_at_zero_trim() {
3534        let mut n = norm(4);
3535        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
3536        let tm = n.trimmed_mean(0.0).unwrap();
3537        let m = n.mean().unwrap().to_f64().unwrap();
3538        assert!((tm - m).abs() < 1e-9, "0% trim should equal mean, got tm={} m={}", tm, m);
3539    }
3540
3541    #[test]
3542    fn test_minmax_trimmed_mean_reduces_effect_of_outlier() {
3543        let mut n = norm(5);
3544        for v in [dec!(10), dec!(10), dec!(10), dec!(10), dec!(1000)] { n.update(v); }
3545        // p=0.2 → trim = floor(5*0.2) = 1 element removed from each end
3546        let tm = n.trimmed_mean(0.2).unwrap();
3547        let m = n.mean().unwrap().to_f64().unwrap();
3548        assert!(tm < m, "trimmed mean should be less than mean when outlier is trimmed, tm={} m={}", tm, m);
3549    }
3550
3551    // ── MinMaxNormalizer::linear_trend_slope ─────────────────────────────────
3552
3553    #[test]
3554    fn test_minmax_linear_trend_slope_none_for_single_value() {
3555        let mut n = norm(4);
3556        n.update(dec!(10));
3557        assert!(n.linear_trend_slope().is_none());
3558    }
3559
3560    #[test]
3561    fn test_minmax_linear_trend_slope_positive_for_rising() {
3562        let mut n = norm(4);
3563        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3564        let slope = n.linear_trend_slope().unwrap();
3565        assert!(slope > 0.0, "rising window → positive slope, got {}", slope);
3566    }
3567
3568    #[test]
3569    fn test_minmax_linear_trend_slope_negative_for_falling() {
3570        let mut n = norm(4);
3571        for v in [dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
3572        let slope = n.linear_trend_slope().unwrap();
3573        assert!(slope < 0.0, "falling window → negative slope, got {}", slope);
3574    }
3575
3576    #[test]
3577    fn test_minmax_linear_trend_slope_zero_for_flat() {
3578        let mut n = norm(4);
3579        for v in [dec!(5), dec!(5), dec!(5), dec!(5)] { n.update(v); }
3580        let slope = n.linear_trend_slope().unwrap();
3581        assert!(slope.abs() < 1e-9, "flat window → slope=0, got {}", slope);
3582    }
3583
3584    // ── MinMaxNormalizer::variance_ratio ─────────────────────────────────────
3585
3586    #[test]
3587    fn test_minmax_variance_ratio_none_for_few_values() {
3588        let mut n = norm(3);
3589        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
3590        assert!(n.variance_ratio().is_none());
3591    }
3592
3593    #[test]
3594    fn test_minmax_variance_ratio_gt_one_for_decreasing_vol() {
3595        let mut n = norm(6);
3596        // first half: high variance [1, 10, 1]; second half: low variance [5, 6, 5]
3597        for v in [dec!(1), dec!(10), dec!(1), dec!(5), dec!(6), dec!(5)] { n.update(v); }
3598        let r = n.variance_ratio().unwrap();
3599        assert!(r > 1.0, "first half more volatile → ratio > 1, got {}", r);
3600    }
3601
3602    // ── MinMaxNormalizer::z_score_trend_slope ────────────────────────────────
3603
3604    #[test]
3605    fn test_minmax_z_score_trend_slope_none_for_single_value() {
3606        let mut n = norm(4);
3607        n.update(dec!(10));
3608        assert!(n.z_score_trend_slope().is_none());
3609    }
3610
3611    #[test]
3612    fn test_minmax_z_score_trend_slope_positive_for_rising() {
3613        let mut n = norm(5);
3614        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
3615        let slope = n.z_score_trend_slope().unwrap();
3616        assert!(slope > 0.0, "rising window → positive z-score slope, got {}", slope);
3617    }
3618
3619    // ── MinMaxNormalizer::mean_absolute_change ────────────────────────────────
3620
3621    #[test]
3622    fn test_minmax_mean_absolute_change_none_for_single_value() {
3623        let mut n = norm(4);
3624        n.update(dec!(10));
3625        assert!(n.mean_absolute_change().is_none());
3626    }
3627
3628    #[test]
3629    fn test_minmax_mean_absolute_change_zero_for_constant() {
3630        let mut n = norm(4);
3631        for _ in 0..4 { n.update(dec!(5)); }
3632        let mac = n.mean_absolute_change().unwrap();
3633        assert!(mac.abs() < 1e-9, "constant window → MAC=0, got {}", mac);
3634    }
3635
3636    #[test]
3637    fn test_minmax_mean_absolute_change_positive_for_varying() {
3638        let mut n = norm(4);
3639        for v in [dec!(1), dec!(3), dec!(2), dec!(5)] { n.update(v); }
3640        let mac = n.mean_absolute_change().unwrap();
3641        assert!(mac > 0.0, "varying window → MAC > 0, got {}", mac);
3642    }
3643
3644    // ── round-83 tests ─────────────────────────────────────────────────────
3645
3646    #[test]
3647    fn test_minmax_monotone_increase_fraction_none_for_single() {
3648        let mut n = norm(4);
3649        n.update(dec!(5));
3650        assert!(n.monotone_increase_fraction().is_none());
3651    }
3652
3653    #[test]
3654    fn test_minmax_monotone_increase_fraction_one_for_rising() {
3655        let mut n = norm(4);
3656        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3657        let f = n.monotone_increase_fraction().unwrap();
3658        assert!((f - 1.0).abs() < 1e-9, "all rising → fraction=1, got {}", f);
3659    }
3660
3661    #[test]
3662    fn test_minmax_abs_max_none_for_empty() {
3663        let n = norm(4);
3664        assert!(n.abs_max().is_none());
3665    }
3666
3667    #[test]
3668    fn test_minmax_abs_max_returns_max_absolute() {
3669        let mut n = norm(4);
3670        for v in [dec!(1), dec!(3), dec!(2)] { n.update(v); }
3671        assert_eq!(n.abs_max().unwrap(), dec!(3));
3672    }
3673
3674    #[test]
3675    fn test_minmax_max_count_none_for_empty() {
3676        let n = norm(4);
3677        assert!(n.max_count().is_none());
3678    }
3679
3680    #[test]
3681    fn test_minmax_max_count_correct() {
3682        let mut n = norm(4);
3683        for v in [dec!(1), dec!(5), dec!(3), dec!(5)] { n.update(v); }
3684        assert_eq!(n.max_count().unwrap(), 2);
3685    }
3686
3687    #[test]
3688    fn test_minmax_mean_ratio_none_for_single() {
3689        let mut n = norm(4);
3690        n.update(dec!(10));
3691        assert!(n.mean_ratio().is_none());
3692    }
3693
3694    // ── round-84 tests ─────────────────────────────────────────────────────
3695
3696    #[test]
3697    fn test_minmax_exponential_weighted_mean_none_for_empty() {
3698        let n = norm(4);
3699        assert!(n.exponential_weighted_mean(0.5).is_none());
3700    }
3701
3702    #[test]
3703    fn test_minmax_exponential_weighted_mean_returns_value() {
3704        let mut n = norm(4);
3705        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3706        let ewm = n.exponential_weighted_mean(0.5).unwrap();
3707        assert!(ewm > 0.0, "EWM should be positive, got {}", ewm);
3708    }
3709
3710    #[test]
3711    fn test_minmax_peak_to_trough_none_for_empty() {
3712        let n = norm(4);
3713        assert!(n.peak_to_trough_ratio().is_none());
3714    }
3715
3716    #[test]
3717    fn test_minmax_peak_to_trough_correct() {
3718        let mut n = norm(4);
3719        for v in [dec!(2), dec!(4), dec!(1), dec!(8)] { n.update(v); }
3720        let r = n.peak_to_trough_ratio().unwrap();
3721        assert!((r - 8.0).abs() < 1e-9, "max=8, min=1 → ratio=8, got {}", r);
3722    }
3723
3724    #[test]
3725    fn test_minmax_second_moment_none_for_empty() {
3726        let n = norm(4);
3727        assert!(n.second_moment().is_none());
3728    }
3729
3730    #[test]
3731    fn test_minmax_second_moment_correct() {
3732        let mut n = norm(4);
3733        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
3734        // (1 + 4 + 9) / 3 = 14/3 ≈ 4.667
3735        let m = n.second_moment().unwrap();
3736        assert!((m - 14.0 / 3.0).abs() < 1e-9, "second moment ≈ 4.667, got {}", m);
3737    }
3738
3739    #[test]
3740    fn test_minmax_range_over_mean_none_for_empty() {
3741        let n = norm(4);
3742        assert!(n.range_over_mean().is_none());
3743    }
3744
3745    #[test]
3746    fn test_minmax_range_over_mean_positive() {
3747        let mut n = norm(4);
3748        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3749        let r = n.range_over_mean().unwrap();
3750        assert!(r > 0.0, "range/mean should be positive, got {}", r);
3751    }
3752
3753    #[test]
3754    fn test_minmax_above_median_fraction_none_for_empty() {
3755        let n = norm(4);
3756        assert!(n.above_median_fraction().is_none());
3757    }
3758
3759    #[test]
3760    fn test_minmax_above_median_fraction_in_range() {
3761        let mut n = norm(4);
3762        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3763        let f = n.above_median_fraction().unwrap();
3764        assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
3765    }
3766
3767    // ── round-85 tests ─────────────────────────────────────────────────────
3768
3769    #[test]
3770    fn test_minmax_interquartile_mean_none_for_empty() {
3771        let n = norm(4);
3772        assert!(n.interquartile_mean().is_none());
3773    }
3774
3775    #[test]
3776    fn test_minmax_outlier_fraction_none_for_empty() {
3777        let n = norm(4);
3778        assert!(n.outlier_fraction(2.0).is_none());
3779    }
3780
3781    #[test]
3782    fn test_minmax_outlier_fraction_zero_for_constant() {
3783        let mut n = norm(4);
3784        for _ in 0..4 { n.update(dec!(5)); }
3785        let f = n.outlier_fraction(1.0).unwrap();
3786        assert!(f.abs() < 1e-9, "constant window → no outliers, got {}", f);
3787    }
3788
3789    #[test]
3790    fn test_minmax_sign_flip_count_none_for_single() {
3791        let mut n = norm(4);
3792        n.update(dec!(1));
3793        assert!(n.sign_flip_count().is_none());
3794    }
3795
3796    #[test]
3797    fn test_minmax_sign_flip_count_correct() {
3798        let mut n = norm(6);
3799        for v in [dec!(1), dec!(-1), dec!(1), dec!(-1)] { n.update(v); }
3800        let c = n.sign_flip_count().unwrap();
3801        assert_eq!(c, 3, "3 sign flips expected, got {}", c);
3802    }
3803
3804    #[test]
3805    fn test_minmax_rms_none_for_empty() {
3806        let n = norm(4);
3807        assert!(n.rms().is_none());
3808    }
3809
3810    #[test]
3811    fn test_minmax_rms_correct_for_unit_value() {
3812        let mut n = norm(4);
3813        for _ in 0..4 { n.update(dec!(1)); }
3814        let r = n.rms().unwrap();
3815        assert!((r - 1.0).abs() < 1e-9, "RMS of all-ones = 1.0, got {}", r);
3816    }
3817
3818    // ── round-86 tests ─────────────────────────────────────────────────────
3819
3820    #[test]
3821    fn test_minmax_distinct_count_zero_for_empty() {
3822        let n = norm(4);
3823        assert_eq!(n.distinct_count(), 0);
3824    }
3825
3826    #[test]
3827    fn test_minmax_distinct_count_correct() {
3828        let mut n = norm(4);
3829        for v in [dec!(1), dec!(1), dec!(2), dec!(3)] { n.update(v); }
3830        assert_eq!(n.distinct_count(), 3);
3831    }
3832
3833    #[test]
3834    fn test_minmax_max_fraction_none_for_empty() {
3835        let n = norm(4);
3836        assert!(n.max_fraction().is_none());
3837    }
3838
3839    #[test]
3840    fn test_minmax_max_fraction_correct() {
3841        let mut n = norm(4);
3842        for v in [dec!(1), dec!(2), dec!(3), dec!(3)] { n.update(v); }
3843        let f = n.max_fraction().unwrap();
3844        // 2 out of 4 values are the max (3)
3845        assert!((f - 0.5).abs() < 1e-9, "2/4 are max → 0.5, got {}", f);
3846    }
3847
3848    #[test]
3849    fn test_minmax_latest_minus_mean_none_for_empty() {
3850        let n = norm(4);
3851        assert!(n.latest_minus_mean().is_none());
3852    }
3853
3854    #[test]
3855    fn test_minmax_latest_to_mean_ratio_none_for_empty() {
3856        let n = norm(4);
3857        assert!(n.latest_to_mean_ratio().is_none());
3858    }
3859
3860    #[test]
3861    fn test_minmax_latest_to_mean_ratio_one_for_constant() {
3862        let mut n = norm(4);
3863        for _ in 0..4 { n.update(dec!(5)); }
3864        let r = n.latest_to_mean_ratio().unwrap();
3865        assert!((r - 1.0).abs() < 1e-9, "latest=mean → ratio=1, got {}", r);
3866    }
3867
3868    // ── round-87 tests ────────────────────────────────────────────────────────
3869
3870    #[test]
3871    fn test_minmax_below_mean_fraction_none_for_empty() {
3872        assert!(norm(4).below_mean_fraction().is_none());
3873    }
3874
3875    #[test]
3876    fn test_minmax_below_mean_fraction_symmetric_data() {
3877        let mut n = norm(4);
3878        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3879        // mean=2.5; values strictly below: 1, 2 → 2/4 = 0.5
3880        let f = n.below_mean_fraction().unwrap();
3881        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
3882    }
3883
3884    #[test]
3885    fn test_minmax_tail_variance_none_for_small_window() {
3886        let mut n = norm(3);
3887        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
3888        assert!(n.tail_variance().is_none());
3889    }
3890
3891    #[test]
3892    fn test_minmax_tail_variance_nonneg_for_varied_data() {
3893        let mut n = norm(6);
3894        for v in [dec!(1), dec!(2), dec!(5), dec!(6), dec!(9), dec!(10)] { n.update(v); }
3895        let tv = n.tail_variance().unwrap();
3896        assert!(tv >= 0.0, "tail variance should be non-negative, got {}", tv);
3897    }
3898
3899    // ── round-88 tests ─────────────────────────────────────────────────────
3900
3901    #[test]
3902    fn test_minmax_new_max_count_zero_for_empty() {
3903        let n = norm(4);
3904        assert_eq!(n.new_max_count(), 0);
3905    }
3906
3907    #[test]
3908    fn test_minmax_new_max_count_all_rising() {
3909        let mut n = norm(4);
3910        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3911        assert_eq!(n.new_max_count(), 4, "each value is a new high");
3912    }
3913
3914    #[test]
3915    fn test_minmax_new_min_count_zero_for_empty() {
3916        let n = norm(4);
3917        assert_eq!(n.new_min_count(), 0);
3918    }
3919
3920    #[test]
3921    fn test_minmax_new_min_count_all_falling() {
3922        let mut n = norm(4);
3923        for v in [dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
3924        assert_eq!(n.new_min_count(), 4, "each value is a new low");
3925    }
3926
3927    #[test]
3928    fn test_minmax_zero_fraction_none_for_empty() {
3929        let n = norm(4);
3930        assert!(n.zero_fraction().is_none());
3931    }
3932
3933    #[test]
3934    fn test_minmax_zero_fraction_zero_when_no_zeros() {
3935        let mut n = norm(4);
3936        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
3937        let f = n.zero_fraction().unwrap();
3938        assert!(f.abs() < 1e-9, "no zeros → fraction=0, got {}", f);
3939    }
3940
3941    // ── round-89 tests ────────────────────────────────────────────────────────
3942
3943    #[test]
3944    fn test_minmax_cumulative_sum_zero_for_empty() {
3945        let n = norm(4);
3946        assert_eq!(n.cumulative_sum(), rust_decimal::Decimal::ZERO);
3947    }
3948
3949    #[test]
3950    fn test_minmax_cumulative_sum_correct() {
3951        let mut n = norm(4);
3952        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
3953        assert_eq!(n.cumulative_sum(), dec!(6));
3954    }
3955
3956    #[test]
3957    fn test_minmax_max_to_min_ratio_none_for_empty() {
3958        assert!(norm(4).max_to_min_ratio().is_none());
3959    }
3960
3961    #[test]
3962    fn test_minmax_max_to_min_ratio_one_for_constant() {
3963        let mut n = norm(4);
3964        for _ in 0..4 { n.update(dec!(5)); }
3965        let r = n.max_to_min_ratio().unwrap();
3966        assert!((r - 1.0).abs() < 1e-9, "constant window → ratio=1, got {}", r);
3967    }
3968
3969    // ── round-90 tests ────────────────────────────────────────────────────────
3970
3971    #[test]
3972    fn test_minmax_above_midpoint_fraction_none_for_empty() {
3973        assert!(norm(4).above_midpoint_fraction().is_none());
3974    }
3975
3976    #[test]
3977    fn test_minmax_above_midpoint_fraction_half_for_symmetric() {
3978        let mut n = norm(4);
3979        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
3980        // midpoint = (1+4)/2 = 2.5; values above: 3 and 4 → 2/4 = 0.5
3981        let f = n.above_midpoint_fraction().unwrap();
3982        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
3983    }
3984
3985    #[test]
3986    fn test_minmax_span_utilization_none_for_empty() {
3987        assert!(norm(4).span_utilization().is_none());
3988    }
3989
3990    #[test]
3991    fn test_minmax_span_utilization_one_for_latest_at_max() {
3992        let mut n = norm(4);
3993        for v in [dec!(1), dec!(5), dec!(3), dec!(10)] { n.update(v); }
3994        // range [1,10], latest=10 → utilization = 1.0
3995        let u = n.span_utilization().unwrap();
3996        assert!((u - 1.0).abs() < 1e-9, "latest=max → 1.0, got {}", u);
3997    }
3998
3999    #[test]
4000    fn test_minmax_positive_fraction_none_for_empty() {
4001        assert!(norm(4).positive_fraction().is_none());
4002    }
4003
4004    #[test]
4005    fn test_minmax_positive_fraction_half() {
4006        let mut n = norm(4);
4007        for v in [dec!(-1), dec!(0), dec!(1), dec!(2)] { n.update(v); }
4008        // strictly > 0: 1 and 2 → 2/4 = 0.5
4009        let f = n.positive_fraction().unwrap();
4010        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
4011    }
4012
4013    // ── round-91 tests ────────────────────────────────────────────────────────
4014
4015    #[test]
4016    fn test_minmax_window_iqr_none_for_empty() {
4017        assert!(norm(4).window_iqr().is_none());
4018    }
4019
4020    #[test]
4021    fn test_minmax_window_iqr_zero_for_constant() {
4022        let mut n = norm(4);
4023        for _ in 0..4 { n.update(dec!(5)); }
4024        assert_eq!(n.window_iqr().unwrap(), dec!(0));
4025    }
4026
4027    #[test]
4028    fn test_minmax_mean_absolute_deviation_none_for_empty() {
4029        assert!(norm(4).mean_absolute_deviation().is_none());
4030    }
4031
4032    #[test]
4033    fn test_minmax_mean_absolute_deviation_zero_for_constant() {
4034        let mut n = norm(4);
4035        for _ in 0..4 { n.update(dec!(7)); }
4036        let mad = n.mean_absolute_deviation().unwrap();
4037        assert!(mad.abs() < 1e-9, "constant window → MAD=0, got {}", mad);
4038    }
4039
4040    #[test]
4041    fn test_minmax_run_length_mean_none_for_single_value() {
4042        let mut n = norm(4);
4043        n.update(dec!(1));
4044        assert!(n.run_length_mean().is_none());
4045    }
4046
4047    #[test]
4048    fn test_minmax_run_length_mean_all_increasing() {
4049        let mut n = norm(4);
4050        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
4051        // one monotone run of length 4 → mean = 4.0
4052        let r = n.run_length_mean().unwrap();
4053        assert!((r - 4.0).abs() < 1e-9, "monotone up → run_len=4, got {}", r);
4054    }
4055}
4056
4057/// Rolling z-score normalizer over a sliding window of [`Decimal`] observations.
4058///
4059/// Maps each new sample to its z-score: `(x - mean) / std_dev`. The rolling
4060/// window maintains an O(1) mean and variance via incremental sum/sum-of-squares
4061/// tracking, with O(W) recompute only when a value is evicted.
4062///
4063/// Returns 0.0 when the window has fewer than 2 observations (variance is 0).
4064///
4065/// # Example
4066///
4067/// ```rust
4068/// use fin_stream::norm::ZScoreNormalizer;
4069/// use rust_decimal_macros::dec;
4070///
4071/// let mut norm = ZScoreNormalizer::new(5).unwrap();
4072/// for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] {
4073///     norm.update(v);
4074/// }
4075/// // 30 is the mean; normalize returns 0.0
4076/// let z = norm.normalize(dec!(30)).unwrap();
4077/// assert!((z - 0.0).abs() < 1e-9);
4078/// ```
4079pub struct ZScoreNormalizer {
4080    window_size: usize,
4081    window: VecDeque<Decimal>,
4082    sum: Decimal,
4083    sum_sq: Decimal,
4084}
4085
4086impl ZScoreNormalizer {
4087    /// Create a new z-score normalizer with the given rolling window size.
4088    ///
4089    /// # Errors
4090    ///
4091    /// Returns [`StreamError::ConfigError`] if `window_size == 0`.
4092    pub fn new(window_size: usize) -> Result<Self, StreamError> {
4093        if window_size == 0 {
4094            return Err(StreamError::ConfigError {
4095                reason: "ZScoreNormalizer window_size must be > 0".into(),
4096            });
4097        }
4098        Ok(Self {
4099            window_size,
4100            window: VecDeque::with_capacity(window_size),
4101            sum: Decimal::ZERO,
4102            sum_sq: Decimal::ZERO,
4103        })
4104    }
4105
4106    /// Add a new observation to the rolling window.
4107    ///
4108    /// Evicts the oldest value when the window is full, adjusting running sums
4109    /// in O(1). No full recompute is needed unless eviction causes sum drift;
4110    /// the implementation recomputes exactly when necessary via `recompute`.
4111    pub fn update(&mut self, value: Decimal) {
4112        if self.window.len() == self.window_size {
4113            let evicted = self.window.pop_front().unwrap_or(Decimal::ZERO);
4114            self.sum -= evicted;
4115            self.sum_sq -= evicted * evicted;
4116        }
4117        self.window.push_back(value);
4118        self.sum += value;
4119        self.sum_sq += value * value;
4120    }
4121
4122    /// Normalize `value` to a z-score using the current window's mean and std dev.
4123    ///
4124    /// Returns 0.0 if:
4125    /// - The window has fewer than 2 observations (std dev undefined).
4126    /// - The standard deviation is effectively zero (all window values identical).
4127    ///
4128    /// # Errors
4129    ///
4130    /// Returns [`StreamError::NormalizationError`] if the window is empty.
4131    ///
4132    /// # Complexity: O(1)
4133    #[must_use = "z-score is returned; ignoring it loses the normalized value"]
4134    pub fn normalize(&self, value: Decimal) -> Result<f64, StreamError> {
4135        let n = self.window.len();
4136        if n == 0 {
4137            return Err(StreamError::NormalizationError {
4138                reason: "window is empty; call update() before normalize()".into(),
4139            });
4140        }
4141        if n < 2 {
4142            return Ok(0.0);
4143        }
4144        let std_dev = self.std_dev().unwrap_or(0.0);
4145        if std_dev < f64::EPSILON {
4146            return Ok(0.0);
4147        }
4148        let mean = self.mean().ok_or_else(|| StreamError::NormalizationError {
4149            reason: "mean unavailable".into(),
4150        })?;
4151        let diff = value - mean;
4152        let diff_f64 = diff.to_f64().ok_or_else(|| StreamError::NormalizationError {
4153            reason: "Decimal-to-f64 conversion failed for diff".into(),
4154        })?;
4155        Ok(diff_f64 / std_dev)
4156    }
4157
4158    /// Current rolling mean of the window, or `None` if the window is empty.
4159    pub fn mean(&self) -> Option<Decimal> {
4160        if self.window.is_empty() {
4161            return None;
4162        }
4163        let n = Decimal::from(self.window.len() as u64);
4164        Some(self.sum / n)
4165    }
4166
4167    /// Current population standard deviation of the window.
4168    ///
4169    /// Returns `None` if the window is empty. Returns `Some(0.0)` if fewer
4170    /// than 2 observations are present (undefined variance, treated as zero)
4171    /// or if all values are identical.
4172    pub fn std_dev(&self) -> Option<f64> {
4173        let n = self.window.len();
4174        if n == 0 {
4175            return None;
4176        }
4177        if n < 2 {
4178            return Some(0.0);
4179        }
4180        self.variance_f64().map(f64::sqrt)
4181    }
4182
4183    /// Reset the normalizer, clearing all observations and sums.
4184    pub fn reset(&mut self) {
4185        self.window.clear();
4186        self.sum = Decimal::ZERO;
4187        self.sum_sq = Decimal::ZERO;
4188    }
4189
4190    /// Number of observations currently in the window.
4191    pub fn len(&self) -> usize {
4192        self.window.len()
4193    }
4194
4195    /// Returns `true` if no observations have been added since construction or reset.
4196    pub fn is_empty(&self) -> bool {
4197        self.window.is_empty()
4198    }
4199
4200    /// The configured window size.
4201    pub fn window_size(&self) -> usize {
4202        self.window_size
4203    }
4204
4205    /// Returns `true` when the window holds exactly `window_size` observations.
4206    ///
4207    /// At full capacity the z-score calculation is stable; before this point
4208    /// the window may not be representative of the underlying distribution.
4209    pub fn is_full(&self) -> bool {
4210        self.window.len() == self.window_size
4211    }
4212
4213    /// Running sum of all values currently in the window.
4214    ///
4215    /// Returns `None` if the window is empty. Useful for deriving a rolling
4216    /// mean without calling [`normalize`](Self::normalize).
4217    pub fn sum(&self) -> Option<Decimal> {
4218        if self.window.is_empty() {
4219            return None;
4220        }
4221        Some(self.sum)
4222    }
4223
4224    /// Current population variance of the window.
4225    ///
4226    /// Computed as `E[X²] − (E[X])²` from running sums in O(1). Returns
4227    /// `None` if fewer than 2 observations are present (variance undefined).
4228    pub fn variance(&self) -> Option<Decimal> {
4229        let n = self.window.len();
4230        if n < 2 {
4231            return None;
4232        }
4233        let n_dec = Decimal::from(n as u64);
4234        let mean = self.sum / n_dec;
4235        let v = (self.sum_sq / n_dec) - mean * mean;
4236        Some(if v < Decimal::ZERO { Decimal::ZERO } else { v })
4237    }
4238
4239    /// Standard deviation of the current window as `f64`.
4240    ///
4241    /// Returns `None` if the window has fewer than 2 observations.
4242    pub fn std_dev_f64(&self) -> Option<f64> {
4243        self.variance_f64().map(|v| v.sqrt())
4244    }
4245
4246    /// Current window variance as `f64` (convenience wrapper around [`variance`](Self::variance)).
4247    ///
4248    /// Returns `None` if the window has fewer than 2 observations.
4249    pub fn variance_f64(&self) -> Option<f64> {
4250        use rust_decimal::prelude::ToPrimitive;
4251        self.variance()?.to_f64()
4252    }
4253
4254    /// Feed a slice of values into the window and return z-scores for each.
4255    ///
4256    /// Each value is first passed through [`update`](Self::update) to advance
4257    /// the rolling window, then normalized. The output has the same length as
4258    /// `values`.
4259    ///
4260    /// # Errors
4261    ///
4262    /// Propagates the first [`StreamError`] returned by [`normalize`](Self::normalize).
4263    pub fn normalize_batch(
4264        &mut self,
4265        values: &[Decimal],
4266    ) -> Result<Vec<f64>, StreamError> {
4267        values
4268            .iter()
4269            .map(|&v| {
4270                self.update(v);
4271                self.normalize(v)
4272            })
4273            .collect()
4274    }
4275
4276    /// Returns `true` if `value` is an outlier: its z-score exceeds `z_threshold` in magnitude.
4277    ///
4278    /// Returns `false` when the window has fewer than 2 observations (z-score undefined).
4279    /// A typical threshold is `2.0` (95th percentile) or `3.0` (99.7th percentile).
4280    pub fn is_outlier(&self, value: Decimal, z_threshold: f64) -> bool {
4281        use rust_decimal::prelude::ToPrimitive;
4282        if self.window.len() < 2 {
4283            return false;
4284        }
4285        let sd = self.std_dev().unwrap_or(0.0);
4286        if sd == 0.0 {
4287            return false;
4288        }
4289        let Some(mean_f64) = self.mean().and_then(|m| m.to_f64()) else { return false; };
4290        let val_f64 = value.to_f64().unwrap_or(mean_f64);
4291        ((val_f64 - mean_f64) / sd).abs() > z_threshold
4292    }
4293
4294    /// Percentile rank: fraction of window observations that are ≤ `value`.
4295    ///
4296    /// Returns `None` if the window is empty. Range: `[0.0, 1.0]`.
4297    pub fn percentile_rank(&self, value: Decimal) -> Option<f64> {
4298        if self.window.is_empty() {
4299            return None;
4300        }
4301        let count = self.window.iter().filter(|&&v| v <= value).count();
4302        Some(count as f64 / self.window.len() as f64)
4303    }
4304
4305    /// Minimum value seen in the current window.
4306    ///
4307    /// Returns `None` when the window is empty.
4308    pub fn running_min(&self) -> Option<Decimal> {
4309        self.window.iter().copied().reduce(Decimal::min)
4310    }
4311
4312    /// Maximum value seen in the current window.
4313    ///
4314    /// Returns `None` when the window is empty.
4315    pub fn running_max(&self) -> Option<Decimal> {
4316        self.window.iter().copied().reduce(Decimal::max)
4317    }
4318
4319    /// Range of values in the current window: `running_max − running_min`.
4320    ///
4321    /// Returns `None` when the window is empty.
4322    pub fn window_range(&self) -> Option<Decimal> {
4323        let min = self.running_min()?;
4324        let max = self.running_max()?;
4325        Some(max - min)
4326    }
4327
4328    /// Coefficient of variation: `std_dev / |mean|`.
4329    ///
4330    /// A dimensionless measure of relative dispersion. Returns `None` when the
4331    /// window has fewer than 2 observations or when the mean is zero.
4332    pub fn coefficient_of_variation(&self) -> Option<f64> {
4333        let mean = self.mean()?;
4334        if mean.is_zero() {
4335            return None;
4336        }
4337        let std_dev = self.std_dev()?;
4338        let mean_f = mean.abs().to_f64()?;
4339        Some(std_dev / mean_f)
4340    }
4341
4342    /// Population variance of the current window as `f64`: `std_dev²`.
4343    ///
4344    /// Note: despite the name, this computes *population* variance (divides by
4345    /// `n`), consistent with [`std_dev`](Self::std_dev) and
4346    /// [`variance`](Self::variance). Returns `None` when the window has fewer
4347    /// than 2 observations.
4348    pub fn sample_variance(&self) -> Option<f64> {
4349        let sd = self.std_dev()?;
4350        Some(sd * sd)
4351    }
4352
4353    /// Current window mean as `f64`.
4354    ///
4355    /// A convenience over calling `mean()` and then converting to `f64`.
4356    /// Returns `None` when the window is empty.
4357    pub fn window_mean_f64(&self) -> Option<f64> {
4358        use rust_decimal::prelude::ToPrimitive;
4359        self.mean()?.to_f64()
4360    }
4361
4362    /// Returns `true` if `value` is within `sigma_tolerance` standard
4363    /// deviations of the window mean (inclusive).
4364    ///
4365    /// Equivalent to `|z_score(value)| <= sigma_tolerance`.  Returns `false`
4366    /// when the window has fewer than 2 observations (z-score undefined).
4367    pub fn is_near_mean(&self, value: Decimal, sigma_tolerance: f64) -> bool {
4368        // Requires at least 2 observations; with n < 2 the z-score is undefined.
4369        if self.window.len() < 2 {
4370            return false;
4371        }
4372        let Some(std_dev) = self.std_dev() else { return false; };
4373        if std_dev == 0.0 {
4374            return true;
4375        }
4376        let Some(mean) = self.mean() else { return false; };
4377        use rust_decimal::prelude::ToPrimitive;
4378        let diff = (value - mean).abs().to_f64().unwrap_or(f64::MAX);
4379        diff / std_dev <= sigma_tolerance
4380    }
4381
4382    /// Sum of all values currently in the window as `Decimal`.
4383    ///
4384    /// Returns `Decimal::ZERO` on an empty window.
4385    pub fn window_sum(&self) -> Decimal {
4386        self.sum
4387    }
4388
4389    /// Sum of all values currently in the window as `f64`.
4390    ///
4391    /// Returns `0.0` on an empty window.
4392    pub fn window_sum_f64(&self) -> f64 {
4393        use rust_decimal::prelude::ToPrimitive;
4394        self.sum.to_f64().unwrap_or(0.0)
4395    }
4396
4397    /// Maximum value currently in the window as `f64`.
4398    ///
4399    /// Returns `None` when the window is empty.
4400    pub fn window_max_f64(&self) -> Option<f64> {
4401        use rust_decimal::prelude::ToPrimitive;
4402        self.running_max()?.to_f64()
4403    }
4404
4405    /// Minimum value currently in the window as `f64`.
4406    ///
4407    /// Returns `None` when the window is empty.
4408    pub fn window_min_f64(&self) -> Option<f64> {
4409        use rust_decimal::prelude::ToPrimitive;
4410        self.running_min()?.to_f64()
4411    }
4412
4413    /// Difference between the window maximum and minimum, as `f64`.
4414    ///
4415    /// Returns `None` if the window is empty.
4416    pub fn window_span_f64(&self) -> Option<f64> {
4417        use rust_decimal::prelude::ToPrimitive;
4418        self.window_range()?.to_f64()
4419    }
4420
4421    /// Excess kurtosis of the window: `(Σ((x-mean)⁴/n) / std_dev⁴) - 3`.
4422    ///
4423    /// Returns `None` if the window has fewer than 4 observations or std dev is zero.
4424    /// A normal distribution has excess kurtosis of 0; positive values indicate
4425    /// heavier tails (leptokurtic); negative values indicate lighter tails (platykurtic).
4426    pub fn kurtosis(&self) -> Option<f64> {
4427        use rust_decimal::prelude::ToPrimitive;
4428        let n = self.window.len();
4429        if n < 4 {
4430            return None;
4431        }
4432        let n_f = n as f64;
4433        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
4434        if vals.len() < n {
4435            return None;
4436        }
4437        let mean = vals.iter().sum::<f64>() / n_f;
4438        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n_f;
4439        let std_dev = variance.sqrt();
4440        if std_dev == 0.0 {
4441            return None;
4442        }
4443        let kurt = vals.iter().map(|v| ((v - mean) / std_dev).powi(4)).sum::<f64>() / n_f - 3.0;
4444        Some(kurt)
4445    }
4446
4447    /// Fisher-Pearson skewness of the rolling window.
4448    ///
4449    /// Positive values indicate a right-tailed distribution; negative values
4450    /// indicate a left-tailed distribution. Returns `None` for fewer than 3
4451    /// observations or zero standard deviation.
4452    pub fn skewness(&self) -> Option<f64> {
4453        use rust_decimal::prelude::ToPrimitive;
4454        let n = self.window.len();
4455        if n < 3 {
4456            return None;
4457        }
4458        let n_f = n as f64;
4459        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
4460        if vals.len() < n {
4461            return None;
4462        }
4463        let mean = vals.iter().sum::<f64>() / n_f;
4464        let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n_f;
4465        let std_dev = variance.sqrt();
4466        if std_dev == 0.0 {
4467            return None;
4468        }
4469        let skew = vals.iter().map(|v| ((v - mean) / std_dev).powi(3)).sum::<f64>() / n_f;
4470        Some(skew)
4471    }
4472
4473    /// Returns `true` if the z-score of `value` exceeds `sigma` in absolute terms.
4474    ///
4475    /// Convenience wrapper around [`normalize`](Self::normalize) for alert logic.
4476    /// Returns `false` if the normalizer window is empty or std-dev is zero.
4477    pub fn is_extreme(&self, value: Decimal, sigma: f64) -> bool {
4478        self.normalize(value).ok().map_or(false, |z| z.abs() > sigma)
4479    }
4480
4481    /// The most recently added value, or `None` if the window is empty.
4482    pub fn latest(&self) -> Option<Decimal> {
4483        self.window.back().copied()
4484    }
4485
4486    /// Median of the current window, or `None` if empty.
4487    pub fn median(&self) -> Option<Decimal> {
4488        if self.window.is_empty() { return None; }
4489        let mut vals: Vec<Decimal> = self.window.iter().copied().collect();
4490        vals.sort();
4491        let mid = vals.len() / 2;
4492        if vals.len() % 2 == 0 {
4493            Some((vals[mid - 1] + vals[mid]) / Decimal::TWO)
4494        } else {
4495            Some(vals[mid])
4496        }
4497    }
4498
4499    /// Empirical percentile of `value` within the current window: fraction of values ≤ `value`.
4500    ///
4501    /// Alias for [`percentile_rank`](Self::percentile_rank).
4502    pub fn percentile(&self, value: Decimal) -> Option<f64> {
4503        self.percentile_rank(value)
4504    }
4505
4506    /// Interquartile range: Q3 (75th percentile) − Q1 (25th percentile) of the window.
4507    ///
4508    /// Returns `None` if the window has fewer than 4 observations.
4509    /// The IQR is a robust spread measure less sensitive to outliers than range or std dev.
4510    pub fn interquartile_range(&self) -> Option<Decimal> {
4511        let n = self.window.len();
4512        if n < 4 {
4513            return None;
4514        }
4515        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
4516        sorted.sort();
4517        let q1_idx = n / 4;
4518        let q3_idx = 3 * n / 4;
4519        Some(sorted[q3_idx] - sorted[q1_idx])
4520    }
4521
4522    /// Stateless EMA z-score helper: updates running `ema_mean` and `ema_var` and returns
4523    /// the z-score `(value - ema_mean) / sqrt(ema_var)`.
4524    ///
4525    /// `alpha ∈ (0, 1]` controls smoothing speed (higher = faster adaptation).
4526    /// Initialize `ema_mean = 0.0` and `ema_var = 0.0` before first call.
4527    /// Returns `None` if `value` cannot be converted to f64 or variance is still zero.
4528    pub fn ema_z_score(value: Decimal, alpha: f64, ema_mean: &mut f64, ema_var: &mut f64) -> Option<f64> {
4529        use rust_decimal::prelude::ToPrimitive;
4530        let v = value.to_f64()?;
4531        let delta = v - *ema_mean;
4532        *ema_mean += alpha * delta;
4533        *ema_var = (1.0 - alpha) * (*ema_var + alpha * delta * delta);
4534        let std = ema_var.sqrt();
4535        if std == 0.0 { return None; }
4536        Some((v - *ema_mean) / std)
4537    }
4538
4539    /// Z-score of the most recently added value.
4540    ///
4541    /// Returns `None` if the window is empty or std-dev is zero.
4542    pub fn z_score_of_latest(&self) -> Option<f64> {
4543        let latest = self.latest()?;
4544        self.normalize(latest).ok()
4545    }
4546
4547    /// Exponential moving average of z-scores for all values in the current window.
4548    ///
4549    /// `alpha` is the smoothing factor (0 < alpha ≤ 1). Higher alpha gives more weight
4550    /// to recent z-scores. Returns `None` if the window has fewer than 2 observations.
4551    pub fn ema_of_z_scores(&self, alpha: f64) -> Option<f64> {
4552        let n = self.window.len();
4553        if n < 2 {
4554            return None;
4555        }
4556        let mut ema: Option<f64> = None;
4557        for &value in &self.window {
4558            if let Ok(z) = self.normalize(value) {
4559                ema = Some(match ema {
4560                    None => z,
4561                    Some(prev) => alpha * z + (1.0 - alpha) * prev,
4562                });
4563            }
4564        }
4565        ema
4566    }
4567
4568    /// Chainable alias for `update`: feeds `value` into the window and returns `&mut Self`.
4569    pub fn add_observation(&mut self, value: Decimal) -> &mut Self {
4570        self.update(value);
4571        self
4572    }
4573
4574    /// Signed deviation of `value` from the window mean, as `f64`.
4575    ///
4576    /// Returns `None` if the window is empty.
4577    pub fn deviation_from_mean(&self, value: Decimal) -> Option<f64> {
4578        use rust_decimal::prelude::ToPrimitive;
4579        let mean = self.mean()?.to_f64()?;
4580        value.to_f64().map(|v| v - mean)
4581    }
4582
4583    /// Returns a `Vec` of window values that are within `sigma` standard deviations of the mean.
4584    ///
4585    /// Useful for robust statistics after removing extreme outliers.
4586    /// Returns all values if std-dev is zero (no outliers possible), empty vec if window is empty.
4587    pub fn trim_outliers(&self, sigma: f64) -> Vec<Decimal> {
4588        use rust_decimal::prelude::ToPrimitive;
4589        if self.window.is_empty() { return vec![]; }
4590        let Some(mean) = self.mean() else { return vec![]; };
4591        let std = match self.std_dev() {
4592            Some(s) if s > 0.0 => s,
4593            _ => return self.window.iter().copied().collect(),
4594        };
4595        let Some(mean_f64) = mean.to_f64() else { return vec![]; };
4596        self.window.iter().copied()
4597            .filter(|v| {
4598                v.to_f64().map_or(false, |vf| ((vf - mean_f64) / std).abs() <= sigma)
4599            })
4600            .collect()
4601    }
4602
4603    /// Batch normalize: returns z-scores for each value as if they were added one-by-one.
4604    ///
4605    /// Each z-score uses only the window state after incorporating that value.
4606    /// The internal state is modified; call `reset()` if you need to restore it.
4607    /// Returns `None` entries where normalization fails (window warming up or zero std-dev).
4608    pub fn rolling_zscore_batch(&mut self, values: &[Decimal]) -> Vec<Option<f64>> {
4609        values.iter().map(|&v| {
4610            self.update(v);
4611            self.normalize(v).ok()
4612        }).collect()
4613    }
4614
4615    /// Change in mean between the first half and second half of the current window.
4616    ///
4617    /// Splits the window in two, computes the mean of each half, and returns
4618    /// `second_half_mean - first_half_mean` as `f64`. Returns `None` if the
4619    /// window has fewer than 2 observations.
4620    pub fn rolling_mean_change(&self) -> Option<f64> {
4621        let n = self.window.len();
4622        if n < 2 {
4623            return None;
4624        }
4625        let mid = n / 2;
4626        let first: Decimal = self.window.iter().take(mid).copied().sum::<Decimal>()
4627            / Decimal::from(mid as u64);
4628        let second: Decimal = self.window.iter().skip(mid).copied().sum::<Decimal>()
4629            / Decimal::from((n - mid) as u64);
4630        (second - first).to_f64()
4631    }
4632
4633    /// Count of window values whose z-score is strictly positive (above the mean).
4634    ///
4635    /// Returns `0` if the window is empty or all values are equal (z-scores are all 0).
4636    pub fn count_positive_z_scores(&self) -> usize {
4637        self.window
4638            .iter()
4639            .filter(|&&v| self.normalize(v).map_or(false, |z| z > 0.0))
4640            .count()
4641    }
4642
4643    /// Returns `true` if the absolute change between first-half and second-half window means
4644    /// is below `threshold`. A stable mean indicates the distribution is not trending.
4645    ///
4646    /// Returns `false` if the window has fewer than 2 observations.
4647    pub fn is_mean_stable(&self, threshold: f64) -> bool {
4648        self.rolling_mean_change().map_or(false, |c| c.abs() < threshold)
4649    }
4650
4651    /// Count of window values whose absolute z-score exceeds `z_threshold`.
4652    ///
4653    /// Returns `0` if the window has fewer than 2 observations or std-dev is zero.
4654    pub fn above_threshold_count(&self, z_threshold: f64) -> usize {
4655        self.window
4656            .iter()
4657            .filter(|&&v| {
4658                self.normalize(v)
4659                    .map_or(false, |z| z.abs() > z_threshold)
4660            })
4661            .count()
4662    }
4663
4664    /// Median Absolute Deviation (MAD) of the current window.
4665    ///
4666    /// `MAD = median(|x_i - median(window)|)`. Returns `None` if the window
4667    /// is empty.
4668    pub fn mad(&self) -> Option<Decimal> {
4669        let med = self.median()?;
4670        let mut deviations: Vec<Decimal> = self.window.iter().map(|&x| (x - med).abs()).collect();
4671        deviations.sort();
4672        let n = deviations.len();
4673        if n == 0 { return None; }
4674        let mid = n / 2;
4675        if n % 2 == 0 {
4676            Some((deviations[mid - 1] + deviations[mid]) / Decimal::TWO)
4677        } else {
4678            Some(deviations[mid])
4679        }
4680    }
4681
4682    /// Robust z-score: `(value - median) / MAD`.
4683    ///
4684    /// More resistant to outliers than the standard z-score. Returns `None`
4685    /// when the window is empty or MAD is zero.
4686    pub fn robust_z_score(&self, value: Decimal) -> Option<f64> {
4687        use rust_decimal::prelude::ToPrimitive;
4688        let med = self.median()?;
4689        let mad = self.mad()?;
4690        if mad.is_zero() { return None; }
4691        ((value - med) / mad).to_f64()
4692    }
4693
4694    /// Count of window values strictly above `threshold`.
4695    pub fn count_above(&self, threshold: Decimal) -> usize {
4696        self.window.iter().filter(|&&v| v > threshold).count()
4697    }
4698
4699    /// Count of window values strictly below `threshold`.
4700    pub fn count_below(&self, threshold: Decimal) -> usize {
4701        self.window.iter().filter(|&&v| v < threshold).count()
4702    }
4703
4704    /// Value at the p-th percentile of the current window (0.0 ≤ p ≤ 1.0).
4705    ///
4706    /// Uses linear interpolation between adjacent sorted values.
4707    /// Returns `None` if the window is empty.
4708    pub fn percentile_value(&self, p: f64) -> Option<Decimal> {
4709        if self.window.is_empty() {
4710            return None;
4711        }
4712        let p = p.clamp(0.0, 1.0);
4713        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
4714        sorted.sort();
4715        let n = sorted.len();
4716        if n == 1 {
4717            return Some(sorted[0]);
4718        }
4719        let idx = p * (n - 1) as f64;
4720        let lo = idx.floor() as usize;
4721        let hi = idx.ceil() as usize;
4722        if lo == hi {
4723            Some(sorted[lo])
4724        } else {
4725            let frac = Decimal::try_from(idx - lo as f64).ok()?;
4726            Some(sorted[lo] + (sorted[hi] - sorted[lo]) * frac)
4727        }
4728    }
4729
4730    /// Exponentially-weighted moving average (EWMA) of the window values.
4731    ///
4732    /// `alpha` is the smoothing factor in (0.0, 1.0]; higher values weight
4733    /// recent observations more. Processes values in insertion order (oldest first).
4734    /// Returns `None` if the window is empty.
4735    pub fn ewma(&self, alpha: f64) -> Option<f64> {
4736        use rust_decimal::prelude::ToPrimitive;
4737        let alpha = alpha.clamp(1e-9, 1.0);
4738        let mut iter = self.window.iter();
4739        let first = iter.next()?.to_f64()?;
4740        let result = iter.fold(first, |acc, &v| {
4741            let vf = v.to_f64().unwrap_or(acc);
4742            alpha * vf + (1.0 - alpha) * acc
4743        });
4744        Some(result)
4745    }
4746
4747    /// Midpoint between the running minimum and maximum in the window.
4748    ///
4749    /// Returns `None` if the window is empty.
4750    pub fn midpoint(&self) -> Option<Decimal> {
4751        let lo = self.running_min()?;
4752        let hi = self.running_max()?;
4753        Some((lo + hi) / Decimal::from(2u64))
4754    }
4755
4756    /// Clamps `value` to the [running_min, running_max] range of the window.
4757    ///
4758    /// Returns `value` unchanged if the window is empty.
4759    pub fn clamp_to_window(&self, value: Decimal) -> Decimal {
4760        match (self.running_min(), self.running_max()) {
4761            (Some(lo), Some(hi)) => value.max(lo).min(hi),
4762            _ => value,
4763        }
4764    }
4765
4766    /// Fraction of window values strictly above the midpoint between the
4767    /// running minimum and maximum.
4768    ///
4769    /// Returns `None` if the window is empty or min == max.
4770    pub fn fraction_above_mid(&self) -> Option<f64> {
4771        let lo = self.running_min()?;
4772        let hi = self.running_max()?;
4773        if lo == hi {
4774            return None;
4775        }
4776        let mid = (lo + hi) / Decimal::from(2u64);
4777        let above = self.window.iter().filter(|&&v| v > mid).count();
4778        Some(above as f64 / self.window.len() as f64)
4779    }
4780
4781    /// Ratio of the window span (max − min) to the mean.
4782    ///
4783    /// Returns `None` if the window is empty, has fewer than 2 elements,
4784    /// or the mean is zero.
4785    pub fn normalized_range(&self) -> Option<f64> {
4786        use rust_decimal::prelude::ToPrimitive;
4787        let span = self.window_range()?;
4788        let mean = self.mean()?;
4789        if mean.is_zero() {
4790            return None;
4791        }
4792        (span / mean).to_f64()
4793    }
4794
4795    /// Returns the (running_min, running_max) pair for the current window.
4796    ///
4797    /// Returns `None` if the window is empty.
4798    pub fn min_max(&self) -> Option<(Decimal, Decimal)> {
4799        Some((self.running_min()?, self.running_max()?))
4800    }
4801
4802    /// All current window values as a `Vec<Decimal>`, in insertion order (oldest first).
4803    pub fn values(&self) -> Vec<Decimal> {
4804        self.window.iter().copied().collect()
4805    }
4806
4807    /// Fraction of window values that are strictly positive (> 0).
4808    ///
4809    /// Returns `None` if the window is empty.
4810    pub fn above_zero_fraction(&self) -> Option<f64> {
4811        if self.window.is_empty() {
4812            return None;
4813        }
4814        let above = self.window.iter().filter(|&&v| v > Decimal::ZERO).count();
4815        Some(above as f64 / self.window.len() as f64)
4816    }
4817
4818    /// Z-score of `value` relative to the current window, returned as `Option<f64>`.
4819    ///
4820    /// Returns `None` if the window has fewer than 2 elements or variance is zero.
4821    /// Unlike [`normalize`], this never returns an error for empty windows — it
4822    /// simply returns `None`.
4823    pub fn z_score_opt(&self, value: Decimal) -> Option<f64> {
4824        self.normalize(value).ok()
4825    }
4826
4827    /// Returns `true` if the absolute z-score of the latest observation is
4828    /// within `z_threshold` standard deviations of the mean.
4829    ///
4830    /// Returns `false` if the window is empty or has insufficient data.
4831    pub fn is_stable(&self, z_threshold: f64) -> bool {
4832        self.z_score_of_latest()
4833            .map_or(false, |z| z.abs() <= z_threshold)
4834    }
4835
4836    /// Fraction of window values strictly above `threshold`.
4837    ///
4838    /// Returns `None` if the window is empty.
4839    pub fn fraction_above(&self, threshold: Decimal) -> Option<f64> {
4840        if self.window.is_empty() {
4841            return None;
4842        }
4843        Some(self.count_above(threshold) as f64 / self.window.len() as f64)
4844    }
4845
4846    /// Fraction of window values strictly below `threshold`.
4847    ///
4848    /// Returns `None` if the window is empty.
4849    pub fn fraction_below(&self, threshold: Decimal) -> Option<f64> {
4850        if self.window.is_empty() {
4851            return None;
4852        }
4853        Some(self.count_below(threshold) as f64 / self.window.len() as f64)
4854    }
4855
4856    /// Returns all window values strictly above `threshold`.
4857    pub fn window_values_above(&self, threshold: Decimal) -> Vec<Decimal> {
4858        self.window.iter().copied().filter(|&v| v > threshold).collect()
4859    }
4860
4861    /// Returns all window values strictly below `threshold`.
4862    pub fn window_values_below(&self, threshold: Decimal) -> Vec<Decimal> {
4863        self.window.iter().copied().filter(|&v| v < threshold).collect()
4864    }
4865
4866    /// Count of window values equal to `value`.
4867    pub fn count_equal(&self, value: Decimal) -> usize {
4868        self.window.iter().filter(|&&v| v == value).count()
4869    }
4870
4871    /// Range of the current window: `running_max - running_min`.
4872    ///
4873    /// Returns `None` if the window is empty.
4874    pub fn rolling_range(&self) -> Option<Decimal> {
4875        let lo = self.running_min()?;
4876        let hi = self.running_max()?;
4877        Some(hi - lo)
4878    }
4879
4880    /// Lag-1 autocorrelation of the window values.
4881    ///
4882    /// Returns `None` if fewer than 2 values or variance is zero.
4883    pub fn autocorrelation_lag1(&self) -> Option<f64> {
4884        use rust_decimal::prelude::ToPrimitive;
4885        let n = self.window.len();
4886        if n < 2 {
4887            return None;
4888        }
4889        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
4890        if vals.len() < 2 {
4891            return None;
4892        }
4893        let mean = vals.iter().sum::<f64>() / vals.len() as f64;
4894        let var: f64 = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / vals.len() as f64;
4895        if var == 0.0 {
4896            return None;
4897        }
4898        let cov: f64 = vals.windows(2).map(|w| (w[0] - mean) * (w[1] - mean)).sum::<f64>()
4899            / (vals.len() - 1) as f64;
4900        Some(cov / var)
4901    }
4902
4903    /// Fraction of consecutive pairs where the second value > first (trending upward).
4904    ///
4905    /// Returns `None` if fewer than 2 values.
4906    pub fn trend_consistency(&self) -> Option<f64> {
4907        let n = self.window.len();
4908        if n < 2 {
4909            return None;
4910        }
4911        let up = self.window.iter().collect::<Vec<_>>().windows(2)
4912            .filter(|w| w[1] > w[0]).count();
4913        Some(up as f64 / (n - 1) as f64)
4914    }
4915
4916    /// Mean absolute deviation of the window values.
4917    ///
4918    /// Returns `None` if window is empty.
4919    pub fn mean_absolute_deviation(&self) -> Option<f64> {
4920        use rust_decimal::prelude::ToPrimitive;
4921        if self.window.is_empty() {
4922            return None;
4923        }
4924        let n = self.window.len();
4925        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
4926        let mean = vals.iter().sum::<f64>() / n as f64;
4927        let mad = vals.iter().map(|v| (v - mean).abs()).sum::<f64>() / n as f64;
4928        Some(mad)
4929    }
4930
4931    /// Percentile rank of the most recently added value within the window.
4932    ///
4933    /// Returns `None` if no value has been added yet. Uses the same `<=`
4934    /// semantics as [`percentile`](Self::percentile).
4935    pub fn percentile_of_latest(&self) -> Option<f64> {
4936        let latest = self.latest()?;
4937        self.percentile(latest)
4938    }
4939
4940    /// Tail ratio: `max(window) / 75th-percentile(window)`.
4941    ///
4942    /// A simple fat-tail indicator. Values well above 1.0 signal that the
4943    /// maximum observation is an outlier relative to the upper quartile.
4944    /// Returns `None` if the window is empty or the 75th percentile is zero.
4945    pub fn tail_ratio(&self) -> Option<f64> {
4946        use rust_decimal::prelude::ToPrimitive;
4947        let max = self.running_max()?;
4948        let p75 = self.percentile_value(0.75)?;
4949        if p75.is_zero() {
4950            return None;
4951        }
4952        (max / p75).to_f64()
4953    }
4954
4955    /// Z-score of the window minimum relative to the current mean and std dev.
4956    ///
4957    /// Returns `None` if the window is empty or std dev is zero.
4958    pub fn z_score_of_min(&self) -> Option<f64> {
4959        let min = self.running_min()?;
4960        self.z_score_opt(min)
4961    }
4962
4963    /// Z-score of the window maximum relative to the current mean and std dev.
4964    ///
4965    /// Returns `None` if the window is empty or std dev is zero.
4966    pub fn z_score_of_max(&self) -> Option<f64> {
4967        let max = self.running_max()?;
4968        self.z_score_opt(max)
4969    }
4970
4971    /// Shannon entropy of the window values.
4972    ///
4973    /// Each unique value is treated as a category. Returns `None` if the
4974    /// window is empty. A uniform distribution maximises entropy; identical
4975    /// values give `Some(0.0)`.
4976    pub fn window_entropy(&self) -> Option<f64> {
4977        if self.window.is_empty() {
4978            return None;
4979        }
4980        let n = self.window.len() as f64;
4981        let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
4982        for v in &self.window {
4983            *counts.entry(v.to_string()).or_insert(0) += 1;
4984        }
4985        let entropy: f64 = counts.values().map(|&c| {
4986            let p = c as f64 / n;
4987            -p * p.ln()
4988        }).sum();
4989        Some(entropy)
4990    }
4991
4992    /// Normalised standard deviation (alias for [`coefficient_of_variation`](Self::coefficient_of_variation)).
4993    pub fn normalized_std_dev(&self) -> Option<f64> {
4994        self.coefficient_of_variation()
4995    }
4996
4997    /// Count of window values that are strictly above the window mean.
4998    ///
4999    /// Returns `None` if the window is empty or the mean cannot be computed.
5000    pub fn value_above_mean_count(&self) -> Option<usize> {
5001        let mean = self.mean()?;
5002        Some(self.window.iter().filter(|&&v| v > mean).count())
5003    }
5004
5005    /// Length of the longest consecutive run of values above the window mean.
5006    ///
5007    /// Returns `None` if the window is empty or the mean cannot be computed.
5008    pub fn consecutive_above_mean(&self) -> Option<usize> {
5009        let mean = self.mean()?;
5010        let mut max_run = 0usize;
5011        let mut current = 0usize;
5012        for &v in &self.window {
5013            if v > mean {
5014                current += 1;
5015                if current > max_run {
5016                    max_run = current;
5017                }
5018            } else {
5019                current = 0;
5020            }
5021        }
5022        Some(max_run)
5023    }
5024
5025    /// Fraction of window values above `threshold`.
5026    ///
5027    /// Returns `None` if the window is empty.
5028    pub fn above_threshold_fraction(&self, threshold: Decimal) -> Option<f64> {
5029        if self.window.is_empty() {
5030            return None;
5031        }
5032        let count = self.window.iter().filter(|&&v| v > threshold).count();
5033        Some(count as f64 / self.window.len() as f64)
5034    }
5035
5036    /// Fraction of window values below `threshold`.
5037    ///
5038    /// Returns `None` if the window is empty.
5039    pub fn below_threshold_fraction(&self, threshold: Decimal) -> Option<f64> {
5040        if self.window.is_empty() {
5041            return None;
5042        }
5043        let count = self.window.iter().filter(|&&v| v < threshold).count();
5044        Some(count as f64 / self.window.len() as f64)
5045    }
5046
5047    /// Autocorrelation at lag `k` of the window values.
5048    ///
5049    /// Returns `None` if `k == 0`, `k >= window.len()`, or variance is zero.
5050    pub fn lag_k_autocorrelation(&self, k: usize) -> Option<f64> {
5051        use rust_decimal::prelude::ToPrimitive;
5052        let n = self.window.len();
5053        if k == 0 || k >= n {
5054            return None;
5055        }
5056        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
5057        if vals.len() != n {
5058            return None;
5059        }
5060        let mean = vals.iter().sum::<f64>() / n as f64;
5061        let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
5062        if var == 0.0 {
5063            return None;
5064        }
5065        let m = n - k;
5066        let cov: f64 = (0..m).map(|i| (vals[i] - mean) * (vals[i + k] - mean)).sum::<f64>() / m as f64;
5067        Some(cov / var)
5068    }
5069
5070    /// Estimated half-life of mean reversion using a simple AR(1) regression.
5071    ///
5072    /// Half-life ≈ `-ln(2) / ln(|β|)` where β is the AR(1) coefficient. Returns
5073    /// `None` if the window has fewer than 3 values, the regression denominator
5074    /// is zero, or β ≥ 0 (no mean-reversion signal).
5075    pub fn half_life_estimate(&self) -> Option<f64> {
5076        use rust_decimal::prelude::ToPrimitive;
5077        let n = self.window.len();
5078        if n < 3 {
5079            return None;
5080        }
5081        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
5082        if vals.len() != n {
5083            return None;
5084        }
5085        let diffs: Vec<f64> = vals.windows(2).map(|w| w[1] - w[0]).collect();
5086        let lagged: Vec<f64> = vals[..n - 1].to_vec();
5087        let nf = diffs.len() as f64;
5088        let mean_l = lagged.iter().sum::<f64>() / nf;
5089        let mean_d = diffs.iter().sum::<f64>() / nf;
5090        let cov: f64 = lagged.iter().zip(diffs.iter()).map(|(l, d)| (l - mean_l) * (d - mean_d)).sum::<f64>();
5091        let var: f64 = lagged.iter().map(|l| (l - mean_l).powi(2)).sum::<f64>();
5092        if var == 0.0 {
5093            return None;
5094        }
5095        let beta = cov / var;
5096        if beta >= 0.0 {
5097            return None;
5098        }
5099        let lambda = (1.0 + beta).abs().ln();
5100        if lambda == 0.0 {
5101            return None;
5102        }
5103        Some(-std::f64::consts::LN_2 / lambda)
5104    }
5105
5106    /// Geometric mean of the window values.
5107    ///
5108    /// `exp(mean(ln(v_i)))`. Returns `None` if the window is empty or any
5109    /// value is non-positive.
5110    pub fn geometric_mean(&self) -> Option<f64> {
5111        use rust_decimal::prelude::ToPrimitive;
5112        if self.window.is_empty() {
5113            return None;
5114        }
5115        let logs: Vec<f64> = self.window.iter()
5116            .filter_map(|v| v.to_f64())
5117            .filter_map(|f| if f > 0.0 { Some(f.ln()) } else { None })
5118            .collect();
5119        if logs.len() != self.window.len() {
5120            return None;
5121        }
5122        Some((logs.iter().sum::<f64>() / logs.len() as f64).exp())
5123    }
5124
5125    /// Harmonic mean of the window values.
5126    ///
5127    /// `n / sum(1/v_i)`. Returns `None` if the window is empty or any value
5128    /// is zero.
5129    pub fn harmonic_mean(&self) -> Option<f64> {
5130        use rust_decimal::prelude::ToPrimitive;
5131        if self.window.is_empty() {
5132            return None;
5133        }
5134        let reciprocals: Vec<f64> = self.window.iter()
5135            .filter_map(|v| v.to_f64())
5136            .filter_map(|f| if f != 0.0 { Some(1.0 / f) } else { None })
5137            .collect();
5138        if reciprocals.len() != self.window.len() {
5139            return None;
5140        }
5141        let n = reciprocals.len() as f64;
5142        Some(n / reciprocals.iter().sum::<f64>())
5143    }
5144
5145    /// Normalise `value` to the window's observed `[min, max]` range.
5146    ///
5147    /// Returns `None` if the window is empty or the range is zero.
5148    pub fn range_normalized_value(&self, value: Decimal) -> Option<f64> {
5149        use rust_decimal::prelude::ToPrimitive;
5150        let min = self.running_min()?;
5151        let max = self.running_max()?;
5152        let range = max - min;
5153        if range.is_zero() {
5154            return None;
5155        }
5156        ((value - min) / range).to_f64()
5157    }
5158
5159    /// Signed distance of `value` from the window median.
5160    ///
5161    /// `value - median`. Returns `None` if the window is empty.
5162    pub fn distance_from_median(&self, value: Decimal) -> Option<f64> {
5163        use rust_decimal::prelude::ToPrimitive;
5164        let med = self.median()?;
5165        (value - med).to_f64()
5166    }
5167
5168    /// Momentum: difference between the latest and oldest value in the window.
5169    ///
5170    /// Positive = window trended up; negative = trended down. Returns `None`
5171    /// if fewer than 2 values are in the window.
5172    pub fn momentum(&self) -> Option<f64> {
5173        use rust_decimal::prelude::ToPrimitive;
5174        if self.window.len() < 2 {
5175            return None;
5176        }
5177        let oldest = *self.window.front()?;
5178        let latest = *self.window.back()?;
5179        (latest - oldest).to_f64()
5180    }
5181
5182    /// Rank of `value` within the current window, normalised to `[0.0, 1.0]`.
5183    ///
5184    /// 0.0 means `value` is ≤ all window values; 1.0 means it is ≥ all window
5185    /// values. Returns `None` if the window is empty.
5186    pub fn value_rank(&self, value: Decimal) -> Option<f64> {
5187        if self.window.is_empty() {
5188            return None;
5189        }
5190        let n = self.window.len();
5191        let below = self.window.iter().filter(|&&v| v < value).count();
5192        Some(below as f64 / n as f64)
5193    }
5194
5195    /// Coefficient of variation: `std_dev / |mean|`.
5196    ///
5197    /// Returns `None` if the window has fewer than 2 values or the mean is
5198    /// zero.
5199    pub fn coeff_of_variation(&self) -> Option<f64> {
5200        use rust_decimal::prelude::ToPrimitive;
5201        let n = self.window.len();
5202        if n < 2 {
5203            return None;
5204        }
5205        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
5206        if vals.len() < 2 {
5207            return None;
5208        }
5209        let nf = vals.len() as f64;
5210        let mean = vals.iter().sum::<f64>() / nf;
5211        if mean == 0.0 {
5212            return None;
5213        }
5214        let std_dev = (vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (nf - 1.0)).sqrt();
5215        Some(std_dev / mean.abs())
5216    }
5217
5218    /// Inter-quartile range: Q3 (75th percentile) minus Q1 (25th percentile).
5219    ///
5220    /// Returns `None` if the window is empty.
5221    pub fn quantile_range(&self) -> Option<f64> {
5222        use rust_decimal::prelude::ToPrimitive;
5223        let q3 = self.percentile_value(0.75)?;
5224        let q1 = self.percentile_value(0.25)?;
5225        (q3 - q1).to_f64()
5226    }
5227
5228    /// Upper quartile (Q3, 75th percentile) of the window values.
5229    ///
5230    /// Returns `None` if the window is empty.
5231    pub fn upper_quartile(&self) -> Option<Decimal> {
5232        self.percentile_value(0.75)
5233    }
5234
5235    /// Lower quartile (Q1, 25th percentile) of the window values.
5236    ///
5237    /// Returns `None` if the window is empty.
5238    pub fn lower_quartile(&self) -> Option<Decimal> {
5239        self.percentile_value(0.25)
5240    }
5241
5242    /// Fraction of consecutive first-difference pairs whose sign flips.
5243    ///
5244    /// A high value indicates a rapidly oscillating series;
5245    /// a low value indicates persistent trends. Returns `None` for fewer than
5246    /// 3 observations.
5247    pub fn sign_change_rate(&self) -> Option<f64> {
5248        let n = self.window.len();
5249        if n < 3 {
5250            return None;
5251        }
5252        let vals: Vec<&Decimal> = self.window.iter().collect();
5253        let diffs: Vec<i32> = vals
5254            .windows(2)
5255            .map(|w| {
5256                if w[1] > w[0] { 1 } else if w[1] < w[0] { -1 } else { 0 }
5257            })
5258            .collect();
5259        let total_pairs = (diffs.len() - 1) as f64;
5260        if total_pairs == 0.0 {
5261            return None;
5262        }
5263        let changes = diffs
5264            .windows(2)
5265            .filter(|w| w[0] != 0 && w[1] != 0 && w[0] != w[1])
5266            .count();
5267        Some(changes as f64 / total_pairs)
5268    }
5269
5270    // ── round-80 ─────────────────────────────────────────────────────────────
5271
5272    /// Trimmed mean: arithmetic mean after discarding the bottom and top
5273    /// `p` fraction of window values.
5274    ///
5275    /// `p` is clamped to `[0.0, 0.499]`. Returns `None` if the window is
5276    /// empty or trimming removes all observations.
5277    pub fn trimmed_mean(&self, p: f64) -> Option<f64> {
5278        use rust_decimal::prelude::ToPrimitive;
5279        if self.window.is_empty() {
5280            return None;
5281        }
5282        let p = p.clamp(0.0, 0.499);
5283        let mut sorted: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
5284        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
5285        let n = sorted.len();
5286        let trim = (n as f64 * p).floor() as usize;
5287        let trimmed = &sorted[trim..n - trim];
5288        if trimmed.is_empty() {
5289            return None;
5290        }
5291        Some(trimmed.iter().sum::<f64>() / trimmed.len() as f64)
5292    }
5293
5294    /// OLS linear trend slope of window values over their insertion index.
5295    ///
5296    /// A positive slope indicates an upward trend; negative indicates downward.
5297    /// Returns `None` if the window has fewer than 2 observations.
5298    pub fn linear_trend_slope(&self) -> Option<f64> {
5299        use rust_decimal::prelude::ToPrimitive;
5300        let n = self.window.len();
5301        if n < 2 {
5302            return None;
5303        }
5304        let n_f = n as f64;
5305        let x_mean = (n_f - 1.0) / 2.0;
5306        let y_vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
5307        if y_vals.len() < 2 {
5308            return None;
5309        }
5310        let y_mean = y_vals.iter().sum::<f64>() / y_vals.len() as f64;
5311        let numerator: f64 = y_vals
5312            .iter()
5313            .enumerate()
5314            .map(|(i, &y)| (i as f64 - x_mean) * (y - y_mean))
5315            .sum();
5316        let denominator: f64 = (0..n).map(|i| (i as f64 - x_mean).powi(2)).sum();
5317        if denominator == 0.0 {
5318            return None;
5319        }
5320        Some(numerator / denominator)
5321    }
5322
5323    // ── round-81 ─────────────────────────────────────────────────────────────
5324
5325    /// Ratio of first-half to second-half window variance.
5326    ///
5327    /// Values above 1.0 indicate decreasing volatility; below 1.0 indicate
5328    /// increasing volatility. Returns `None` if fewer than 4 observations or
5329    /// second-half variance is zero.
5330    pub fn variance_ratio(&self) -> Option<f64> {
5331        use rust_decimal::prelude::ToPrimitive;
5332        let n = self.window.len();
5333        if n < 4 {
5334            return None;
5335        }
5336        let mid = n / 2;
5337        let first: Vec<f64> = self.window.iter().take(mid).filter_map(|v| v.to_f64()).collect();
5338        let second: Vec<f64> = self.window.iter().skip(mid).filter_map(|v| v.to_f64()).collect();
5339        let var = |vals: &[f64]| -> Option<f64> {
5340            let n_f = vals.len() as f64;
5341            if n_f < 2.0 { return None; }
5342            let mean = vals.iter().sum::<f64>() / n_f;
5343            Some(vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n_f - 1.0))
5344        };
5345        let v1 = var(&first)?;
5346        let v2 = var(&second)?;
5347        if v2 == 0.0 {
5348            return None;
5349        }
5350        Some(v1 / v2)
5351    }
5352
5353    /// Linear trend slope of the z-scores of window values.
5354    ///
5355    /// Computes the z-score of each window value then fits an OLS line over
5356    /// the sequence. Positive slope means z-scores are trending upward.
5357    /// Returns `None` if fewer than 2 observations or std-dev is zero.
5358    pub fn z_score_trend_slope(&self) -> Option<f64> {
5359        use rust_decimal::prelude::ToPrimitive;
5360        let n = self.window.len();
5361        if n < 2 {
5362            return None;
5363        }
5364        let mean_dec = self.mean()?;
5365        let std_dev = self.std_dev()?;
5366        if std_dev == 0.0 {
5367            return None;
5368        }
5369        let mean_f = mean_dec.to_f64()?;
5370        let z_vals: Vec<f64> = self
5371            .window
5372            .iter()
5373            .filter_map(|v| v.to_f64())
5374            .map(|v| (v - mean_f) / std_dev)
5375            .collect();
5376        if z_vals.len() < 2 {
5377            return None;
5378        }
5379        let n_f = z_vals.len() as f64;
5380        let x_mean = (n_f - 1.0) / 2.0;
5381        let z_mean = z_vals.iter().sum::<f64>() / n_f;
5382        let num: f64 = z_vals.iter().enumerate().map(|(i, &z)| (i as f64 - x_mean) * (z - z_mean)).sum();
5383        let den: f64 = (0..z_vals.len()).map(|i| (i as f64 - x_mean).powi(2)).sum();
5384        if den == 0.0 { return None; }
5385        Some(num / den)
5386    }
5387
5388    // ── round-82 ─────────────────────────────────────────────────────────────
5389
5390    /// Mean of `|x_i − x_{i-1}|` across consecutive window values; average absolute change.
5391    pub fn mean_absolute_change(&self) -> Option<f64> {
5392        use rust_decimal::prelude::ToPrimitive;
5393        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
5394        if vals.len() < 2 {
5395            return None;
5396        }
5397        let mac = vals.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f64>() / (vals.len() - 1) as f64;
5398        Some(mac)
5399    }
5400
5401    // ── round-83 ─────────────────────────────────────────────────────────────
5402
5403    /// Fraction of consecutive pairs that are monotonically increasing.
5404    pub fn monotone_increase_fraction(&self) -> Option<f64> {
5405        let vals: Vec<Decimal> = self.window.iter().copied().collect();
5406        let n = vals.len();
5407        if n < 2 {
5408            return None;
5409        }
5410        let inc = vals.windows(2).filter(|w| w[1] > w[0]).count();
5411        Some(inc as f64 / (n - 1) as f64)
5412    }
5413
5414    /// Maximum absolute value in the rolling window.
5415    pub fn abs_max(&self) -> Option<Decimal> {
5416        self.window.iter().map(|v| v.abs()).reduce(|a, b| a.max(b))
5417    }
5418
5419    /// Minimum absolute value in the rolling window.
5420    pub fn abs_min(&self) -> Option<Decimal> {
5421        self.window.iter().map(|v| v.abs()).reduce(|a, b| a.min(b))
5422    }
5423
5424    /// Count of values equal to the window maximum.
5425    pub fn max_count(&self) -> Option<usize> {
5426        let max = self.window.iter().copied().reduce(|a, b| a.max(b))?;
5427        Some(self.window.iter().filter(|&&v| v == max).count())
5428    }
5429
5430    /// Count of values equal to the window minimum.
5431    pub fn min_count(&self) -> Option<usize> {
5432        let min = self.window.iter().copied().reduce(|a, b| a.min(b))?;
5433        Some(self.window.iter().filter(|&&v| v == min).count())
5434    }
5435
5436    /// Ratio of current full-window mean to the mean of the first half.
5437    pub fn mean_ratio(&self) -> Option<f64> {
5438        use rust_decimal::prelude::ToPrimitive;
5439        let n = self.window.len();
5440        if n < 2 {
5441            return None;
5442        }
5443        let current_mean = self.mean()?;
5444        let half = (n / 2).max(1);
5445        let early_sum: Decimal = self.window.iter().take(half).copied().sum();
5446        let early_mean = early_sum / Decimal::from(half as i64);
5447        if early_mean.is_zero() {
5448            return None;
5449        }
5450        (current_mean / early_mean).to_f64()
5451    }
5452
5453    // ── round-84 ─────────────────────────────────────────────────────────────
5454
5455    /// Exponentially-weighted mean with decay factor `alpha` ∈ (0, 1]; most-recent value has highest weight.
5456    pub fn exponential_weighted_mean(&self, alpha: f64) -> Option<f64> {
5457        use rust_decimal::prelude::ToPrimitive;
5458        if self.window.is_empty() {
5459            return None;
5460        }
5461        let alpha = alpha.clamp(1e-6, 1.0);
5462        let vals: Vec<f64> = self.window.iter().filter_map(|v| v.to_f64()).collect();
5463        if vals.is_empty() {
5464            return None;
5465        }
5466        let mut ewm = vals[0];
5467        for &v in &vals[1..] {
5468            ewm = alpha * v + (1.0 - alpha) * ewm;
5469        }
5470        Some(ewm)
5471    }
5472
5473    /// Ratio of window maximum to window minimum; requires non-zero minimum.
5474    pub fn peak_to_trough_ratio(&self) -> Option<f64> {
5475        use rust_decimal::prelude::ToPrimitive;
5476        let max = self.window.iter().copied().reduce(|a, b| a.max(b))?;
5477        let min = self.window.iter().copied().reduce(|a, b| a.min(b))?;
5478        if min.is_zero() {
5479            return None;
5480        }
5481        (max / min).to_f64()
5482    }
5483
5484    /// Mean of squared values in the window (second raw moment).
5485    pub fn second_moment(&self) -> Option<f64> {
5486        use rust_decimal::prelude::ToPrimitive;
5487        if self.window.is_empty() {
5488            return None;
5489        }
5490        let sum: f64 = self.window.iter().filter_map(|v| v.to_f64()).map(|v| v * v).sum();
5491        Some(sum / self.window.len() as f64)
5492    }
5493
5494    /// Range / mean — coefficient of dispersion; `None` if mean is zero.
5495    pub fn range_over_mean(&self) -> Option<f64> {
5496        use rust_decimal::prelude::ToPrimitive;
5497        let max = self.window.iter().copied().reduce(|a, b| a.max(b))?;
5498        let min = self.window.iter().copied().reduce(|a, b| a.min(b))?;
5499        let mean = self.mean()?;
5500        if mean.is_zero() {
5501            return None;
5502        }
5503        ((max - min) / mean).to_f64()
5504    }
5505
5506    /// Fraction of window values strictly above the window median.
5507    pub fn above_median_fraction(&self) -> Option<f64> {
5508        if self.window.is_empty() {
5509            return None;
5510        }
5511        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
5512        sorted.sort();
5513        let mid = sorted.len() / 2;
5514        let median = if sorted.len() % 2 == 0 {
5515            (sorted[mid - 1] + sorted[mid]) / Decimal::from(2)
5516        } else {
5517            sorted[mid]
5518        };
5519        let count = self.window.iter().filter(|&&v| v > median).count();
5520        Some(count as f64 / self.window.len() as f64)
5521    }
5522
5523    // ── round-85 ─────────────────────────────────────────────────────────────
5524
5525    /// Mean of values strictly between Q1 and Q3 (the interquartile mean).
5526    pub fn interquartile_mean(&self) -> Option<f64> {
5527        use rust_decimal::prelude::ToPrimitive;
5528        if self.window.is_empty() {
5529            return None;
5530        }
5531        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
5532        sorted.sort();
5533        let n = sorted.len();
5534        let q1_idx = n / 4;
5535        let q3_idx = (3 * n) / 4;
5536        let iqr_vals: Vec<f64> = sorted[q1_idx..q3_idx]
5537            .iter()
5538            .filter_map(|v| v.to_f64())
5539            .collect();
5540        if iqr_vals.is_empty() {
5541            return None;
5542        }
5543        Some(iqr_vals.iter().sum::<f64>() / iqr_vals.len() as f64)
5544    }
5545
5546    /// Fraction of window values beyond `threshold` standard deviations from the mean.
5547    pub fn outlier_fraction(&self, threshold: f64) -> Option<f64> {
5548        use rust_decimal::prelude::ToPrimitive;
5549        if self.window.is_empty() {
5550            return None;
5551        }
5552        let std_dev = self.std_dev()?;
5553        let mean = self.mean()?.to_f64()?;
5554        if std_dev == 0.0 {
5555            return Some(0.0);
5556        }
5557        let count = self.window
5558            .iter()
5559            .filter_map(|v| v.to_f64())
5560            .filter(|&v| ((v - mean) / std_dev).abs() > threshold)
5561            .count();
5562        Some(count as f64 / self.window.len() as f64)
5563    }
5564
5565    /// Count of sign changes (transitions across zero) in the window.
5566    pub fn sign_flip_count(&self) -> Option<usize> {
5567        if self.window.len() < 2 {
5568            return None;
5569        }
5570        let count = self.window
5571            .iter()
5572            .collect::<Vec<_>>()
5573            .windows(2)
5574            .filter(|w| w[0].is_sign_negative() != w[1].is_sign_negative())
5575            .count();
5576        Some(count)
5577    }
5578
5579    /// Root mean square of window values.
5580    pub fn rms(&self) -> Option<f64> {
5581        use rust_decimal::prelude::ToPrimitive;
5582        if self.window.is_empty() {
5583            return None;
5584        }
5585        let sum_sq: f64 = self.window.iter().filter_map(|v| v.to_f64()).map(|v| v * v).sum();
5586        Some((sum_sq / self.window.len() as f64).sqrt())
5587    }
5588
5589    // ── round-86 ─────────────────────────────────────────────────────────────
5590
5591    /// Number of distinct values in the window.
5592    pub fn distinct_count(&self) -> usize {
5593        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
5594        sorted.sort();
5595        sorted.dedup();
5596        sorted.len()
5597    }
5598
5599    /// Fraction of window values that equal the window maximum.
5600    pub fn max_fraction(&self) -> Option<f64> {
5601        if self.window.is_empty() {
5602            return None;
5603        }
5604        let max = self.window.iter().copied().max()?;
5605        let count = self.window.iter().filter(|&&v| v == max).count();
5606        Some(count as f64 / self.window.len() as f64)
5607    }
5608
5609    /// Fraction of window values that equal the window minimum.
5610    pub fn min_fraction(&self) -> Option<f64> {
5611        if self.window.is_empty() {
5612            return None;
5613        }
5614        let min = self.window.iter().copied().min()?;
5615        let count = self.window.iter().filter(|&&v| v == min).count();
5616        Some(count as f64 / self.window.len() as f64)
5617    }
5618
5619    /// Difference between the latest value and the window mean (signed).
5620    pub fn latest_minus_mean(&self) -> Option<f64> {
5621        use rust_decimal::prelude::ToPrimitive;
5622        let latest = self.latest()?;
5623        let mean = self.mean()?;
5624        (latest - mean).to_f64()
5625    }
5626
5627    /// Ratio of the latest value to the window mean; `None` if mean is zero.
5628    pub fn latest_to_mean_ratio(&self) -> Option<f64> {
5629        use rust_decimal::prelude::ToPrimitive;
5630        let latest = self.latest()?;
5631        let mean = self.mean()?;
5632        if mean.is_zero() {
5633            return None;
5634        }
5635        (latest / mean).to_f64()
5636    }
5637
5638    // ── round-87 ─────────────────────────────────────────────────────────────
5639
5640    /// Fraction of window values strictly below the mean.
5641    pub fn below_mean_fraction(&self) -> Option<f64> {
5642        if self.window.is_empty() {
5643            return None;
5644        }
5645        let mean = self.mean()?;
5646        let count = self.window.iter().filter(|&&v| v < mean).count();
5647        Some(count as f64 / self.window.len() as f64)
5648    }
5649
5650    /// Variance of values lying outside the interquartile range.
5651    /// Returns `None` if fewer than 4 values or fewer than 2 tail values.
5652    pub fn tail_variance(&self) -> Option<f64> {
5653        use rust_decimal::prelude::ToPrimitive;
5654        if self.window.len() < 4 {
5655            return None;
5656        }
5657        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
5658        sorted.sort();
5659        let n = sorted.len();
5660        let q1 = sorted[n / 4];
5661        let q3 = sorted[(3 * n) / 4];
5662        let tails: Vec<f64> = sorted
5663            .iter()
5664            .filter(|&&v| v < q1 || v > q3)
5665            .filter_map(|v| v.to_f64())
5666            .collect();
5667        if tails.len() < 2 {
5668            return None;
5669        }
5670        let nt = tails.len() as f64;
5671        let mean = tails.iter().sum::<f64>() / nt;
5672        let var = tails.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (nt - 1.0);
5673        Some(var)
5674    }
5675
5676    // ── round-88 ─────────────────────────────────────────────────────────────
5677
5678    /// Number of times the window reaches a new running maximum (from index 0).
5679    pub fn new_max_count(&self) -> usize {
5680        if self.window.is_empty() {
5681            return 0;
5682        }
5683        let vals: Vec<Decimal> = self.window.iter().copied().collect();
5684        let mut running = vals[0];
5685        let mut count = 1usize;
5686        for &v in vals.iter().skip(1) {
5687            if v > running {
5688                running = v;
5689                count += 1;
5690            }
5691        }
5692        count
5693    }
5694
5695    /// Number of times the window reaches a new running minimum (from index 0).
5696    pub fn new_min_count(&self) -> usize {
5697        if self.window.is_empty() {
5698            return 0;
5699        }
5700        let vals: Vec<Decimal> = self.window.iter().copied().collect();
5701        let mut running = vals[0];
5702        let mut count = 1usize;
5703        for &v in vals.iter().skip(1) {
5704            if v < running {
5705                running = v;
5706                count += 1;
5707            }
5708        }
5709        count
5710    }
5711
5712    /// Fraction of window values strictly equal to zero.
5713    pub fn zero_fraction(&self) -> Option<f64> {
5714        if self.window.is_empty() {
5715            return None;
5716        }
5717        let count = self.window.iter().filter(|&&v| v == Decimal::ZERO).count();
5718        Some(count as f64 / self.window.len() as f64)
5719    }
5720
5721    // ── round-89 ─────────────────────────────────────────────────────────────
5722
5723    /// Cumulative sum of all window values.
5724    pub fn cumulative_sum(&self) -> Decimal {
5725        self.window.iter().copied().sum()
5726    }
5727
5728    /// Ratio of the window maximum to the window minimum.
5729    /// Returns `None` if the window is empty or minimum is zero.
5730    pub fn max_to_min_ratio(&self) -> Option<f64> {
5731        use rust_decimal::prelude::ToPrimitive;
5732        let max = self.window.iter().copied().max()?;
5733        let min = self.window.iter().copied().min()?;
5734        if min.is_zero() {
5735            return None;
5736        }
5737        (max / min).to_f64()
5738    }
5739
5740    // ── round-90 ─────────────────────────────────────────────────────────────
5741
5742    /// Fraction of window values strictly above the window midpoint `(min + max) / 2`.
5743    ///
5744    /// Returns `None` for an empty window.
5745    pub fn above_midpoint_fraction(&self) -> Option<f64> {
5746        if self.window.is_empty() {
5747            return None;
5748        }
5749        let min = self.window.iter().copied().min()?;
5750        let max = self.window.iter().copied().max()?;
5751        let mid = (min + max) / Decimal::TWO;
5752        let count = self.window.iter().filter(|&&v| v > mid).count();
5753        Some(count as f64 / self.window.len() as f64)
5754    }
5755
5756    /// Fraction of window values strictly greater than zero.
5757    ///
5758    /// Returns `None` for an empty window.
5759    pub fn positive_fraction(&self) -> Option<f64> {
5760        if self.window.is_empty() {
5761            return None;
5762        }
5763        let count = self.window.iter().filter(|&&v| v > Decimal::ZERO).count();
5764        Some(count as f64 / self.window.len() as f64)
5765    }
5766
5767    /// Fraction of window values strictly above the window mean.
5768    ///
5769    /// Returns `None` for an empty window.
5770    pub fn above_mean_fraction(&self) -> Option<f64> {
5771        use rust_decimal::prelude::ToPrimitive;
5772        if self.window.is_empty() {
5773            return None;
5774        }
5775        let n = self.window.len() as u32;
5776        let mean = self.window.iter().copied().sum::<Decimal>() / Decimal::from(n);
5777        let count = self.window.iter().filter(|&&v| v > mean).count();
5778        Some(count as f64 / self.window.len() as f64)
5779    }
5780
5781    // ── round-91 ─────────────────────────────────────────────────────────────
5782
5783    /// Interquartile range of the window: `Q3 − Q1`.
5784    ///
5785    /// Returns `None` for an empty window.
5786    pub fn window_iqr(&self) -> Option<Decimal> {
5787        if self.window.is_empty() {
5788            return None;
5789        }
5790        let mut sorted: Vec<Decimal> = self.window.iter().copied().collect();
5791        sorted.sort();
5792        let n = sorted.len();
5793        let q1 = sorted[n / 4];
5794        let q3 = sorted[(3 * n) / 4];
5795        Some(q3 - q1)
5796    }
5797
5798    /// Mean run length of monotone non-decreasing segments.
5799    ///
5800    /// Returns `None` for fewer than 2 values in the window.
5801    pub fn run_length_mean(&self) -> Option<f64> {
5802        if self.window.len() < 2 {
5803            return None;
5804        }
5805        let vals: Vec<Decimal> = self.window.iter().copied().collect();
5806        let mut runs: Vec<usize> = Vec::new();
5807        let mut run_len = 1usize;
5808        for w in vals.windows(2) {
5809            if w[1] >= w[0] {
5810                run_len += 1;
5811            } else {
5812                runs.push(run_len);
5813                run_len = 1;
5814            }
5815        }
5816        runs.push(run_len);
5817        Some(runs.iter().sum::<usize>() as f64 / runs.len() as f64)
5818    }
5819
5820}
5821
5822#[cfg(test)]
5823mod zscore_tests {
5824    use super::*;
5825    use rust_decimal_macros::dec;
5826
5827    fn znorm(w: usize) -> ZScoreNormalizer {
5828        ZScoreNormalizer::new(w).unwrap()
5829    }
5830
5831    #[test]
5832    fn test_zscore_new_zero_window_returns_error() {
5833        assert!(matches!(
5834            ZScoreNormalizer::new(0),
5835            Err(StreamError::ConfigError { .. })
5836        ));
5837    }
5838
5839    #[test]
5840    fn test_zscore_is_full_false_before_capacity() {
5841        let mut n = znorm(3);
5842        assert!(!n.is_full());
5843        n.update(dec!(1));
5844        n.update(dec!(2));
5845        assert!(!n.is_full());
5846        n.update(dec!(3));
5847        assert!(n.is_full());
5848    }
5849
5850    #[test]
5851    fn test_zscore_is_full_stays_true_after_eviction() {
5852        let mut n = znorm(3);
5853        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
5854            n.update(v);
5855        }
5856        assert!(n.is_full());
5857    }
5858
5859    #[test]
5860    fn test_zscore_empty_window_returns_error() {
5861        let n = znorm(4);
5862        assert!(matches!(
5863            n.normalize(dec!(1)),
5864            Err(StreamError::NormalizationError { .. })
5865        ));
5866    }
5867
5868    #[test]
5869    fn test_zscore_single_value_returns_zero() {
5870        let mut n = znorm(4);
5871        n.update(dec!(50));
5872        assert_eq!(n.normalize(dec!(50)).unwrap(), 0.0);
5873    }
5874
5875    #[test]
5876    fn test_zscore_mean_is_zero() {
5877        let mut n = znorm(5);
5878        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] {
5879            n.update(v);
5880        }
5881        // mean = 30; z-score of 30 should be 0
5882        let z = n.normalize(dec!(30)).unwrap();
5883        assert!((z - 0.0).abs() < 1e-9, "z-score of mean should be 0, got {z}");
5884    }
5885
5886    #[test]
5887    fn test_zscore_symmetric_around_mean() {
5888        let mut n = znorm(4);
5889        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
5890            n.update(v);
5891        }
5892        // mean = 25; values equidistant above and below mean have equal |z|
5893        let z_low = n.normalize(dec!(15)).unwrap();
5894        let z_high = n.normalize(dec!(35)).unwrap();
5895        assert!((z_low.abs() - z_high.abs()).abs() < 1e-9);
5896        assert!(z_low < 0.0, "below-mean z-score should be negative");
5897        assert!(z_high > 0.0, "above-mean z-score should be positive");
5898    }
5899
5900    #[test]
5901    fn test_zscore_all_same_returns_zero() {
5902        let mut n = znorm(4);
5903        for _ in 0..4 {
5904            n.update(dec!(100));
5905        }
5906        assert_eq!(n.normalize(dec!(100)).unwrap(), 0.0);
5907    }
5908
5909    #[test]
5910    fn test_zscore_rolling_window_eviction() {
5911        let mut n = znorm(3);
5912        n.update(dec!(1));
5913        n.update(dec!(2));
5914        n.update(dec!(3));
5915        // Evict 1, add 100 — window is [2, 3, 100]
5916        n.update(dec!(100));
5917        // mean ≈ 35; value 100 should have positive z-score
5918        let z = n.normalize(dec!(100)).unwrap();
5919        assert!(z > 0.0);
5920    }
5921
5922    #[test]
5923    fn test_zscore_reset_clears_state() {
5924        let mut n = znorm(4);
5925        for v in [dec!(10), dec!(20), dec!(30)] {
5926            n.update(v);
5927        }
5928        n.reset();
5929        assert!(n.is_empty());
5930        assert!(n.mean().is_none());
5931        assert!(matches!(
5932            n.normalize(dec!(1)),
5933            Err(StreamError::NormalizationError { .. })
5934        ));
5935    }
5936
5937    #[test]
5938    fn test_zscore_len_and_window_size() {
5939        let mut n = znorm(5);
5940        assert_eq!(n.len(), 0);
5941        assert!(n.is_empty());
5942        n.update(dec!(1));
5943        n.update(dec!(2));
5944        assert_eq!(n.len(), 2);
5945        assert_eq!(n.window_size(), 5);
5946    }
5947
5948    // ── std_dev ───────────────────────────────────────────────────────────────
5949
5950    #[test]
5951    fn test_std_dev_none_when_empty() {
5952        let n = znorm(5);
5953        assert!(n.std_dev().is_none());
5954    }
5955
5956    #[test]
5957    fn test_std_dev_zero_with_one_observation() {
5958        let mut n = znorm(5);
5959        n.update(dec!(42));
5960        assert_eq!(n.std_dev(), Some(0.0));
5961    }
5962
5963    #[test]
5964    fn test_std_dev_zero_when_all_same() {
5965        let mut n = znorm(4);
5966        for _ in 0..4 {
5967            n.update(dec!(10));
5968        }
5969        let sd = n.std_dev().unwrap();
5970        assert!(sd < f64::EPSILON);
5971    }
5972
5973    #[test]
5974    fn test_std_dev_positive_for_varying_values() {
5975        let mut n = znorm(4);
5976        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
5977            n.update(v);
5978        }
5979        let sd = n.std_dev().unwrap();
5980        // Population std dev of [10,20,30,40]: mean=25, var=125, sd≈11.18
5981        assert!((sd - 11.18).abs() < 0.01);
5982    }
5983
5984    // ── ZScoreNormalizer::variance ────────────────────────────────────────────
5985
5986    #[test]
5987    fn test_variance_none_when_fewer_than_two_observations() {
5988        let mut n = znorm(5);
5989        assert!(n.variance().is_none());
5990        n.update(dec!(10));
5991        assert!(n.variance().is_none());
5992    }
5993
5994    #[test]
5995    fn test_variance_zero_for_identical_values() {
5996        let mut n = znorm(4);
5997        for _ in 0..4 {
5998            n.update(dec!(7));
5999        }
6000        assert_eq!(n.variance().unwrap(), dec!(0));
6001    }
6002
6003    #[test]
6004    fn test_variance_correct_for_known_values() {
6005        let mut n = znorm(4);
6006        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] {
6007            n.update(v);
6008        }
6009        // Population variance of [10,20,30,40]: mean=25, var=125
6010        let var = n.variance().unwrap();
6011        let var_f64 = f64::try_from(var).unwrap();
6012        assert!((var_f64 - 125.0).abs() < 0.01, "expected 125 got {var_f64}");
6013    }
6014
6015    // ── ZScoreNormalizer::normalize_batch ─────────────────────────────────────
6016
6017    #[test]
6018    fn test_normalize_batch_same_length_as_input() {
6019        let mut n = znorm(5);
6020        let vals = [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)];
6021        let out = n.normalize_batch(&vals).unwrap();
6022        assert_eq!(out.len(), vals.len());
6023    }
6024
6025    #[test]
6026    fn test_normalize_batch_last_value_matches_single_normalize() {
6027        let mut n1 = znorm(5);
6028        let vals = [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)];
6029        let batch = n1.normalize_batch(&vals).unwrap();
6030
6031        let mut n2 = znorm(5);
6032        for &v in &vals {
6033            n2.update(v);
6034        }
6035        let single = n2.normalize(dec!(50)).unwrap();
6036        assert!((batch[4] - single).abs() < 1e-9);
6037    }
6038
6039    #[test]
6040    fn test_sum_empty_returns_none() {
6041        let n = znorm(4);
6042        assert!(n.sum().is_none());
6043    }
6044
6045    #[test]
6046    fn test_sum_matches_manual() {
6047        let mut n = znorm(4);
6048        n.update(dec!(10));
6049        n.update(dec!(20));
6050        n.update(dec!(30));
6051        // window = [10, 20, 30], sum = 60
6052        assert_eq!(n.sum().unwrap(), dec!(60));
6053    }
6054
6055    #[test]
6056    fn test_sum_evicts_old_values() {
6057        let mut n = znorm(2);
6058        n.update(dec!(10));
6059        n.update(dec!(20));
6060        n.update(dec!(30)); // evicts 10
6061        // window = [20, 30], sum = 50
6062        assert_eq!(n.sum().unwrap(), dec!(50));
6063    }
6064
6065    #[test]
6066    fn test_std_dev_single_observation_returns_some_zero() {
6067        let mut n = znorm(5);
6068        n.update(dec!(10));
6069        // Single sample → variance undefined, std_dev should return None or 0
6070        // ZScoreNormalizer::std_dev returns None for n < 2
6071        assert!(n.std_dev().is_none() || n.std_dev().unwrap() == 0.0);
6072    }
6073
6074    #[test]
6075    fn test_std_dev_constant_window_is_zero() {
6076        let mut n = znorm(4);
6077        for _ in 0..4 {
6078            n.update(dec!(5));
6079        }
6080        let sd = n.std_dev().unwrap();
6081        assert!(sd.abs() < 1e-9, "expected 0.0 got {sd}");
6082    }
6083
6084    #[test]
6085    fn test_std_dev_known_population() {
6086        // values [2, 4, 4, 4, 5, 5, 7, 9] → σ = 2.0
6087        let mut n = znorm(8);
6088        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
6089            n.update(v);
6090        }
6091        let sd = n.std_dev().unwrap();
6092        assert!((sd - 2.0).abs() < 1e-6, "expected ~2.0 got {sd}");
6093    }
6094
6095    // --- window_range / coefficient_of_variation ---
6096
6097    #[test]
6098    fn test_window_range_none_when_empty() {
6099        let n = znorm(5);
6100        assert!(n.window_range().is_none());
6101    }
6102
6103    #[test]
6104    fn test_window_range_correct_value() {
6105        let mut n = znorm(5);
6106        n.update(dec!(10));
6107        n.update(dec!(20));
6108        n.update(dec!(15));
6109        // max=20, min=10 → range=10
6110        assert_eq!(n.window_range().unwrap(), dec!(10));
6111    }
6112
6113    #[test]
6114    fn test_coefficient_of_variation_none_when_empty() {
6115        let n = znorm(5);
6116        assert!(n.coefficient_of_variation().is_none());
6117    }
6118
6119    #[test]
6120    fn test_coefficient_of_variation_none_when_mean_zero() {
6121        let mut n = znorm(5);
6122        n.update(dec!(-5));
6123        n.update(dec!(5)); // mean = 0
6124        assert!(n.coefficient_of_variation().is_none());
6125    }
6126
6127    #[test]
6128    fn test_coefficient_of_variation_positive_for_nonzero_mean() {
6129        let mut n = znorm(8);
6130        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
6131            n.update(v);
6132        }
6133        // mean = 5, std_dev = 2, cv = 2/5 = 0.4
6134        let cv = n.coefficient_of_variation().unwrap();
6135        assert!((cv - 0.4).abs() < 1e-5, "expected ~0.4 got {cv}");
6136    }
6137
6138    // --- sample_variance ---
6139
6140    #[test]
6141    fn test_sample_variance_none_when_empty() {
6142        let n = znorm(5);
6143        assert!(n.sample_variance().is_none());
6144    }
6145
6146    #[test]
6147    fn test_sample_variance_zero_for_constant_window() {
6148        let mut n = znorm(3);
6149        n.update(dec!(7));
6150        n.update(dec!(7));
6151        n.update(dec!(7));
6152        assert!(n.sample_variance().unwrap().abs() < 1e-10);
6153    }
6154
6155    #[test]
6156    fn test_sample_variance_equals_std_dev_squared() {
6157        let mut n = znorm(8);
6158        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
6159            n.update(v);
6160        }
6161        // std_dev ≈ 2.0, variance ≈ 4.0
6162        let variance = n.sample_variance().unwrap();
6163        let sd = n.std_dev().unwrap();
6164        assert!((variance - sd * sd).abs() < 1e-10);
6165    }
6166
6167    // --- window_mean_f64 ---
6168
6169    #[test]
6170    fn test_window_mean_f64_none_when_empty() {
6171        let n = znorm(5);
6172        assert!(n.window_mean_f64().is_none());
6173    }
6174
6175    #[test]
6176    fn test_window_mean_f64_correct_value() {
6177        let mut n = znorm(4);
6178        n.update(dec!(10));
6179        n.update(dec!(20));
6180        // mean = 15.0
6181        let m = n.window_mean_f64().unwrap();
6182        assert!((m - 15.0).abs() < 1e-10);
6183    }
6184
6185    #[test]
6186    fn test_window_mean_f64_matches_decimal_mean() {
6187        let mut n = znorm(8);
6188        for v in [dec!(2), dec!(4), dec!(4), dec!(4), dec!(5), dec!(5), dec!(7), dec!(9)] {
6189            n.update(v);
6190        }
6191        use rust_decimal::prelude::ToPrimitive;
6192        let expected = n.mean().unwrap().to_f64().unwrap();
6193        assert!((n.window_mean_f64().unwrap() - expected).abs() < 1e-10);
6194    }
6195
6196    // ── ZScoreNormalizer::kurtosis ────────────────────────────────────────────
6197
6198    #[test]
6199    fn test_kurtosis_none_when_fewer_than_4_observations() {
6200        let mut n = znorm(5);
6201        n.update(dec!(1));
6202        n.update(dec!(2));
6203        n.update(dec!(3));
6204        assert!(n.kurtosis().is_none());
6205    }
6206
6207    #[test]
6208    fn test_kurtosis_returns_some_with_4_observations() {
6209        let mut n = znorm(4);
6210        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
6211            n.update(v);
6212        }
6213        assert!(n.kurtosis().is_some());
6214    }
6215
6216    #[test]
6217    fn test_kurtosis_none_when_all_same_value() {
6218        let mut n = znorm(4);
6219        for _ in 0..4 {
6220            n.update(dec!(5));
6221        }
6222        // std_dev = 0 → kurtosis is None
6223        assert!(n.kurtosis().is_none());
6224    }
6225
6226    #[test]
6227    fn test_kurtosis_uniform_distribution_is_negative() {
6228        // Uniform distribution has excess kurtosis of -1.2
6229        let mut n = znorm(10);
6230        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5),
6231                  dec!(6), dec!(7), dec!(8), dec!(9), dec!(10)] {
6232            n.update(v);
6233        }
6234        let k = n.kurtosis().unwrap();
6235        // Excess kurtosis of uniform dist over integers is negative
6236        assert!(k < 0.0, "expected negative excess kurtosis for uniform dist, got {k}");
6237    }
6238
6239    // --- ZScoreNormalizer::is_near_mean ---
6240    #[test]
6241    fn test_is_near_mean_false_with_fewer_than_two_obs() {
6242        let mut n = znorm(5);
6243        n.update(dec!(10));
6244        assert!(!n.is_near_mean(dec!(10), 1.0));
6245    }
6246
6247    #[test]
6248    fn test_is_near_mean_true_within_one_sigma() {
6249        let mut n = znorm(10);
6250        // Feed 10, 10, 10, ..., 10, 20 → mean≈11, std_dev small-ish
6251        for _ in 0..9 {
6252            n.update(dec!(10));
6253        }
6254        n.update(dec!(20));
6255        // mean = (90 + 20) / 10 = 11
6256        assert!(n.is_near_mean(dec!(11), 1.0));
6257    }
6258
6259    #[test]
6260    fn test_is_near_mean_false_when_far_from_mean() {
6261        let mut n = znorm(5);
6262        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] {
6263            n.update(v);
6264        }
6265        // mean = 3, std_dev ≈ 1.41; 100 is many sigmas away
6266        assert!(!n.is_near_mean(dec!(100), 2.0));
6267    }
6268
6269    #[test]
6270    fn test_is_near_mean_true_when_all_identical_any_value() {
6271        let mut n = znorm(4);
6272        for _ in 0..4 {
6273            n.update(dec!(7));
6274        }
6275        // std_dev = 0 → any value returns true
6276        assert!(n.is_near_mean(dec!(999), 0.0));
6277    }
6278
6279    // --- ZScoreNormalizer::window_sum_f64 ---
6280    #[test]
6281    fn test_window_sum_f64_zero_on_empty() {
6282        let n = znorm(5);
6283        assert_eq!(n.window_sum_f64(), 0.0);
6284    }
6285
6286    #[test]
6287    fn test_window_sum_f64_correct_after_updates() {
6288        let mut n = znorm(5);
6289        n.update(dec!(10));
6290        n.update(dec!(20));
6291        n.update(dec!(30));
6292        assert!((n.window_sum_f64() - 60.0).abs() < 1e-10);
6293    }
6294
6295    #[test]
6296    fn test_window_sum_f64_rolls_out_old_values() {
6297        let mut n = znorm(2);
6298        n.update(dec!(100));
6299        n.update(dec!(200));
6300        n.update(dec!(300)); // 100 rolls out
6301        // window contains 200, 300 → sum = 500
6302        assert!((n.window_sum_f64() - 500.0).abs() < 1e-10);
6303    }
6304
6305    // ── ZScoreNormalizer::latest ────────────────────────────────────────────
6306
6307    #[test]
6308    fn test_zscore_latest_none_when_empty() {
6309        let n = znorm(5);
6310        assert!(n.latest().is_none());
6311    }
6312
6313    #[test]
6314    fn test_zscore_latest_returns_most_recent() {
6315        let mut n = znorm(5);
6316        n.update(dec!(10));
6317        n.update(dec!(20));
6318        assert_eq!(n.latest(), Some(dec!(20)));
6319    }
6320
6321    #[test]
6322    fn test_zscore_latest_updates_on_roll() {
6323        let mut n = znorm(2);
6324        n.update(dec!(1));
6325        n.update(dec!(2));
6326        n.update(dec!(3)); // rolls out 1
6327        assert_eq!(n.latest(), Some(dec!(3)));
6328    }
6329
6330    // --- ZScoreNormalizer::window_max_f64 / window_min_f64 ---
6331    #[test]
6332    fn test_window_max_f64_none_on_empty() {
6333        let n = znorm(5);
6334        assert!(n.window_max_f64().is_none());
6335    }
6336
6337    #[test]
6338    fn test_window_max_f64_correct_value() {
6339        let mut n = znorm(5);
6340        for v in [dec!(3), dec!(7), dec!(1), dec!(5)] {
6341            n.update(v);
6342        }
6343        assert!((n.window_max_f64().unwrap() - 7.0).abs() < 1e-10);
6344    }
6345
6346    #[test]
6347    fn test_window_min_f64_none_on_empty() {
6348        let n = znorm(5);
6349        assert!(n.window_min_f64().is_none());
6350    }
6351
6352    #[test]
6353    fn test_window_min_f64_correct_value() {
6354        let mut n = znorm(5);
6355        for v in [dec!(3), dec!(7), dec!(1), dec!(5)] {
6356            n.update(v);
6357        }
6358        assert!((n.window_min_f64().unwrap() - 1.0).abs() < 1e-10);
6359    }
6360
6361    // ── ZScoreNormalizer::percentile ────────────────────────────────────────
6362
6363    #[test]
6364    fn test_percentile_none_when_empty() {
6365        let n = znorm(5);
6366        assert!(n.percentile(dec!(10)).is_none());
6367    }
6368
6369    #[test]
6370    fn test_percentile_one_when_all_lte_value() {
6371        let mut n = znorm(4);
6372        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
6373            n.update(v);
6374        }
6375        assert!((n.percentile(dec!(4)).unwrap() - 1.0).abs() < 1e-9);
6376    }
6377
6378    #[test]
6379    fn test_percentile_zero_when_all_gt_value() {
6380        let mut n = znorm(4);
6381        for v in [dec!(5), dec!(6), dec!(7), dec!(8)] {
6382            n.update(v);
6383        }
6384        // 0 of 4 values are ≤ 4
6385        assert_eq!(n.percentile(dec!(4)).unwrap(), 0.0);
6386    }
6387
6388    #[test]
6389    fn test_percentile_half_at_median() {
6390        let mut n = znorm(4);
6391        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
6392            n.update(v);
6393        }
6394        // 2 of 4 values ≤ 2 → 0.5
6395        assert!((n.percentile(dec!(2)).unwrap() - 0.5).abs() < 1e-9);
6396    }
6397
6398    // ── ZScoreNormalizer::interquartile_range ────────────────────────────────
6399
6400    #[test]
6401    fn test_zscore_iqr_none_fewer_than_4_observations() {
6402        let mut n = znorm(5);
6403        for v in [dec!(1), dec!(2), dec!(3)] {
6404            n.update(v);
6405        }
6406        assert!(n.interquartile_range().is_none());
6407    }
6408
6409    #[test]
6410    fn test_zscore_iqr_some_with_4_observations() {
6411        let mut n = znorm(4);
6412        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
6413            n.update(v);
6414        }
6415        assert!(n.interquartile_range().is_some());
6416    }
6417
6418    #[test]
6419    fn test_zscore_iqr_zero_when_all_same() {
6420        let mut n = znorm(4);
6421        for _ in 0..4 {
6422            n.update(dec!(5));
6423        }
6424        assert_eq!(n.interquartile_range(), Some(dec!(0)));
6425    }
6426
6427    #[test]
6428    fn test_zscore_iqr_correct_for_sorted_data() {
6429        // [1,2,3,4,5,6,7,8]: q1_idx=2 → sorted[2]=3, q3_idx=6 → sorted[6]=7, IQR=4
6430        let mut n = znorm(8);
6431        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(6), dec!(7), dec!(8)] {
6432            n.update(v);
6433        }
6434        assert_eq!(n.interquartile_range(), Some(dec!(4)));
6435    }
6436
6437    // ── ZScoreNormalizer::z_score_of_latest / deviation_from_mean ───────────
6438
6439    #[test]
6440    fn test_z_score_of_latest_none_when_empty() {
6441        let n = znorm(5);
6442        assert!(n.z_score_of_latest().is_none());
6443    }
6444
6445    #[test]
6446    fn test_z_score_of_latest_zero_when_all_same() {
6447        let mut n = znorm(4);
6448        for _ in 0..4 {
6449            n.update(dec!(5));
6450        }
6451        // std_dev = 0 → normalize returns Ok(0.0) → z_score_of_latest returns Some(0.0)
6452        assert_eq!(n.z_score_of_latest(), Some(0.0));
6453    }
6454
6455    #[test]
6456    fn test_z_score_of_latest_returns_some_with_variance() {
6457        let mut n = znorm(4);
6458        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
6459            n.update(v);
6460        }
6461        // latest = 4; should produce Some
6462        assert!(n.z_score_of_latest().is_some());
6463    }
6464
6465    #[test]
6466    fn test_deviation_from_mean_none_when_empty() {
6467        let n = znorm(5);
6468        assert!(n.deviation_from_mean(dec!(10)).is_none());
6469    }
6470
6471    #[test]
6472    fn test_deviation_from_mean_correct() {
6473        let mut n = znorm(4);
6474        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
6475            n.update(v);
6476        }
6477        // mean = 2.5, value = 4 → deviation = 1.5
6478        let d = n.deviation_from_mean(dec!(4)).unwrap();
6479        assert!((d - 1.5).abs() < 1e-9);
6480    }
6481
6482    // ── ZScoreNormalizer::add_observation ─────────────────────────────────────
6483
6484    #[test]
6485    fn test_add_observation_same_as_update() {
6486        let mut n1 = znorm(4);
6487        let mut n2 = znorm(4);
6488        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
6489            n1.update(v);
6490            n2.add_observation(v);
6491        }
6492        assert_eq!(n1.mean(), n2.mean());
6493    }
6494
6495    #[test]
6496    fn test_add_observation_chainable() {
6497        let mut n = znorm(4);
6498        n.add_observation(dec!(1))
6499         .add_observation(dec!(2))
6500         .add_observation(dec!(3));
6501        assert_eq!(n.len(), 3);
6502    }
6503
6504    // ── ZScoreNormalizer::variance_f64 ────────────────────────────────────────
6505
6506    #[test]
6507    fn test_variance_f64_none_when_single_observation() {
6508        let mut n = znorm(4);
6509        n.update(dec!(5));
6510        assert!(n.variance_f64().is_none());
6511    }
6512
6513    #[test]
6514    fn test_variance_f64_zero_when_all_same() {
6515        let mut n = znorm(4);
6516        for _ in 0..4 { n.update(dec!(5)); }
6517        assert_eq!(n.variance_f64(), Some(0.0));
6518    }
6519
6520    #[test]
6521    fn test_variance_f64_positive_with_spread() {
6522        let mut n = znorm(4);
6523        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
6524        assert!(n.variance_f64().unwrap() > 0.0);
6525    }
6526
6527    // ── ZScoreNormalizer::ema_of_z_scores ────────────────────────────────────
6528
6529    #[test]
6530    fn test_ema_of_z_scores_none_when_single_value() {
6531        let mut n = znorm(4);
6532        n.update(dec!(5));
6533        assert!(n.ema_of_z_scores(0.5).is_none());
6534    }
6535
6536    #[test]
6537    fn test_ema_of_z_scores_returns_some_with_variance() {
6538        let mut n = znorm(4);
6539        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] {
6540            n.update(v);
6541        }
6542        let ema = n.ema_of_z_scores(0.3);
6543        assert!(ema.is_some());
6544    }
6545
6546    #[test]
6547    fn test_ema_of_z_scores_zero_when_all_same() {
6548        let mut n = znorm(4);
6549        for _ in 0..4 { n.update(dec!(5)); }
6550        // All z-scores are 0.0 → EMA = 0.0
6551        assert_eq!(n.ema_of_z_scores(0.5), Some(0.0));
6552    }
6553
6554    // ── ZScoreNormalizer::std_dev_f64 ─────────────────────────────────────────
6555
6556    #[test]
6557    fn test_std_dev_f64_none_when_single_observation() {
6558        let mut n = znorm(4);
6559        n.update(dec!(5));
6560        assert!(n.std_dev_f64().is_none());
6561    }
6562
6563    #[test]
6564    fn test_std_dev_f64_zero_when_all_same() {
6565        let mut n = znorm(4);
6566        for _ in 0..4 { n.update(dec!(5)); }
6567        assert_eq!(n.std_dev_f64(), Some(0.0));
6568    }
6569
6570    #[test]
6571    fn test_std_dev_f64_equals_sqrt_of_variance() {
6572        let mut n = znorm(4);
6573        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
6574        let var = n.variance_f64().unwrap();
6575        let std = n.std_dev_f64().unwrap();
6576        assert!((std - var.sqrt()).abs() < 1e-12);
6577    }
6578
6579    // ── rolling_mean_change ───────────────────────────────────────────────────
6580
6581    #[test]
6582    fn test_rolling_mean_change_none_when_one_observation() {
6583        let mut n = znorm(4);
6584        n.update(dec!(5));
6585        assert!(n.rolling_mean_change().is_none());
6586    }
6587
6588    #[test]
6589    fn test_rolling_mean_change_positive_when_rising() {
6590        let mut n = znorm(4);
6591        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
6592        // first half [1,2] mean=1.5, second half [3,4] mean=3.5 → change=2.0
6593        let change = n.rolling_mean_change().unwrap();
6594        assert!((change - 2.0).abs() < 1e-9);
6595    }
6596
6597    #[test]
6598    fn test_rolling_mean_change_negative_when_falling() {
6599        let mut n = znorm(4);
6600        for v in [dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
6601        let change = n.rolling_mean_change().unwrap();
6602        assert!(change < 0.0);
6603    }
6604
6605    #[test]
6606    fn test_rolling_mean_change_zero_when_flat() {
6607        let mut n = znorm(4);
6608        for _ in 0..4 { n.update(dec!(7)); }
6609        let change = n.rolling_mean_change().unwrap();
6610        assert!(change.abs() < 1e-9);
6611    }
6612
6613    // ── window_span_f64 ───────────────────────────────────────────────────────
6614
6615    #[test]
6616    fn test_window_span_f64_none_when_empty() {
6617        let n = znorm(4);
6618        assert!(n.window_span_f64().is_none());
6619    }
6620
6621    #[test]
6622    fn test_window_span_f64_zero_when_all_same() {
6623        let mut n = znorm(4);
6624        for _ in 0..4 { n.update(dec!(5)); }
6625        assert_eq!(n.window_span_f64(), Some(0.0));
6626    }
6627
6628    #[test]
6629    fn test_window_span_f64_correct_value() {
6630        let mut n = znorm(4);
6631        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
6632        // max=40, min=10, span=30
6633        assert!((n.window_span_f64().unwrap() - 30.0).abs() < 1e-9);
6634    }
6635
6636    // ── count_positive_z_scores ───────────────────────────────────────────────
6637
6638    #[test]
6639    fn test_count_positive_z_scores_zero_when_empty() {
6640        let n = znorm(4);
6641        assert_eq!(n.count_positive_z_scores(), 0);
6642    }
6643
6644    #[test]
6645    fn test_count_positive_z_scores_zero_when_all_same() {
6646        let mut n = znorm(4);
6647        for _ in 0..4 { n.update(dec!(5)); }
6648        assert_eq!(n.count_positive_z_scores(), 0);
6649    }
6650
6651    #[test]
6652    fn test_count_positive_z_scores_half_above_mean() {
6653        let mut n = znorm(4);
6654        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
6655        // mean=2.5, values above: 3 and 4
6656        assert_eq!(n.count_positive_z_scores(), 2);
6657    }
6658
6659    // ── above_threshold_count ─────────────────────────────────────────────────
6660
6661    #[test]
6662    fn test_above_threshold_count_zero_when_empty() {
6663        let n = znorm(4);
6664        assert_eq!(n.above_threshold_count(1.0), 0);
6665    }
6666
6667    #[test]
6668    fn test_above_threshold_count_zero_when_all_same() {
6669        let mut n = znorm(4);
6670        for _ in 0..4 { n.update(dec!(5)); }
6671        assert_eq!(n.above_threshold_count(0.5), 0);
6672    }
6673
6674    #[test]
6675    fn test_above_threshold_count_correct_with_extremes() {
6676        let mut n = znorm(6);
6677        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(100)] { n.update(v); }
6678        // 100 is many std devs from mean; threshold=1.0 should catch it
6679        assert!(n.above_threshold_count(1.0) >= 1);
6680    }
6681}
6682
6683#[cfg(test)]
6684mod minmax_extra_tests {
6685    use super::*;
6686    use rust_decimal_macros::dec;
6687
6688    fn norm(w: usize) -> MinMaxNormalizer {
6689        MinMaxNormalizer::new(w).unwrap()
6690    }
6691
6692    // ── fraction_above_mid ────────────────────────────────────────────────────
6693
6694    #[test]
6695    fn test_fraction_above_mid_none_when_empty() {
6696        let mut n = norm(4);
6697        assert!(n.fraction_above_mid().is_none());
6698    }
6699
6700    #[test]
6701    fn test_fraction_above_mid_zero_when_all_same() {
6702        let mut n = norm(4);
6703        for _ in 0..4 { n.update(dec!(5)); }
6704        assert_eq!(n.fraction_above_mid(), Some(0.0));
6705    }
6706
6707    #[test]
6708    fn test_fraction_above_mid_half_when_symmetric() {
6709        let mut n = norm(4);
6710        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
6711        // mid = (1+4)/2 = 2.5, above: 3 and 4 = 2/4 = 0.5
6712        let f = n.fraction_above_mid().unwrap();
6713        assert!((f - 0.5).abs() < 1e-10);
6714    }
6715}
6716
6717#[cfg(test)]
6718mod zscore_stability_tests {
6719    use super::*;
6720    use rust_decimal_macros::dec;
6721
6722    fn znorm(w: usize) -> ZScoreNormalizer {
6723        ZScoreNormalizer::new(w).unwrap()
6724    }
6725
6726    // ── is_mean_stable ────────────────────────────────────────────────────────
6727
6728    #[test]
6729    fn test_is_mean_stable_false_when_window_too_small() {
6730        let n = znorm(4);
6731        assert!(!n.is_mean_stable(1.0));
6732    }
6733
6734    #[test]
6735    fn test_is_mean_stable_true_when_flat() {
6736        let mut n = znorm(4);
6737        for _ in 0..4 { n.update(dec!(5)); }
6738        assert!(n.is_mean_stable(0.001));
6739    }
6740
6741    #[test]
6742    fn test_is_mean_stable_false_when_trending() {
6743        let mut n = znorm(4);
6744        for v in [dec!(1), dec!(2), dec!(10), dec!(20)] { n.update(v); }
6745        assert!(!n.is_mean_stable(0.5));
6746    }
6747
6748    // ── ZScoreNormalizer::count_above / count_below ───────────────────────────
6749
6750    #[test]
6751    fn test_zscore_count_above_zero_for_empty_window() {
6752        assert_eq!(znorm(4).count_above(dec!(10)), 0);
6753    }
6754
6755    #[test]
6756    fn test_zscore_count_above_correct() {
6757        let mut n = znorm(5);
6758        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
6759        // strictly above 3: [4, 5] → count = 2
6760        assert_eq!(n.count_above(dec!(3)), 2);
6761    }
6762
6763    #[test]
6764    fn test_zscore_count_below_correct() {
6765        let mut n = znorm(5);
6766        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
6767        // strictly below 3: [1, 2] → count = 2
6768        assert_eq!(n.count_below(dec!(3)), 2);
6769    }
6770
6771    #[test]
6772    fn test_zscore_count_above_excludes_at_threshold() {
6773        let mut n = znorm(3);
6774        for v in [dec!(5), dec!(5), dec!(5)] { n.update(v); }
6775        assert_eq!(n.count_above(dec!(5)), 0);
6776        assert_eq!(n.count_below(dec!(5)), 0);
6777    }
6778
6779    // ── ZScoreNormalizer::skewness ────────────────────────────────────────────
6780
6781    #[test]
6782    fn test_zscore_skewness_none_for_fewer_than_3_obs() {
6783        let mut n = znorm(5);
6784        n.update(dec!(10));
6785        n.update(dec!(20));
6786        assert!(n.skewness().is_none());
6787    }
6788
6789    #[test]
6790    fn test_zscore_skewness_none_for_all_identical() {
6791        let mut n = znorm(4);
6792        for _ in 0..4 { n.update(dec!(5)); }
6793        assert!(n.skewness().is_none());
6794    }
6795
6796    #[test]
6797    fn test_zscore_skewness_near_zero_for_symmetric_distribution() {
6798        let mut n = znorm(5);
6799        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
6800        let skew = n.skewness().unwrap();
6801        assert!(skew.abs() < 0.01, "symmetric distribution should have ~0 skewness, got {skew}");
6802    }
6803
6804    // ── ZScoreNormalizer::percentile_value ────────────────────────────────────
6805
6806    #[test]
6807    fn test_zscore_percentile_value_none_for_empty_window() {
6808        assert!(znorm(4).percentile_value(0.5).is_none());
6809    }
6810
6811    #[test]
6812    fn test_zscore_percentile_value_min_at_zero() {
6813        let mut n = znorm(5);
6814        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
6815        assert_eq!(n.percentile_value(0.0), Some(dec!(10)));
6816    }
6817
6818    #[test]
6819    fn test_zscore_percentile_value_max_at_one() {
6820        let mut n = znorm(5);
6821        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
6822        assert_eq!(n.percentile_value(1.0), Some(dec!(50)));
6823    }
6824
6825    // ── ZScoreNormalizer::ewma ────────────────────────────────────────────────
6826
6827    #[test]
6828    fn test_zscore_ewma_none_for_empty_window() {
6829        assert!(znorm(4).ewma(0.5).is_none());
6830    }
6831
6832    #[test]
6833    fn test_zscore_ewma_equals_value_for_single_obs() {
6834        let mut n = znorm(4);
6835        n.update(dec!(42));
6836        assert!((n.ewma(0.5).unwrap() - 42.0).abs() < 1e-10);
6837    }
6838
6839    #[test]
6840    fn test_zscore_ewma_weights_recent_more_with_high_alpha() {
6841        // With alpha=1.0 EWMA = last value
6842        let mut n = znorm(4);
6843        for v in [dec!(10), dec!(20), dec!(30), dec!(100)] { n.update(v); }
6844        let ewma = n.ewma(1.0).unwrap();
6845        assert!((ewma - 100.0).abs() < 1e-10);
6846    }
6847
6848    #[test]
6849    fn test_zscore_fraction_above_mid_none_for_empty_window() {
6850        let n = znorm(3);
6851        assert!(n.fraction_above_mid().is_none());
6852    }
6853
6854    #[test]
6855    fn test_zscore_fraction_above_mid_none_when_all_equal() {
6856        let mut n = znorm(3);
6857        for _ in 0..3 { n.update(dec!(5)); }
6858        assert!(n.fraction_above_mid().is_none());
6859    }
6860
6861    #[test]
6862    fn test_zscore_fraction_above_mid_half_above() {
6863        let mut n = znorm(4);
6864        for v in [dec!(0), dec!(10), dec!(6), dec!(4)] { n.update(v); }
6865        // mid = (0+10)/2 = 5; above 5: 10 and 6 → 2/4 = 0.5
6866        let frac = n.fraction_above_mid().unwrap();
6867        assert!((frac - 0.5).abs() < 1e-9);
6868    }
6869
6870    #[test]
6871    fn test_zscore_normalized_range_none_for_empty_window() {
6872        let n = znorm(3);
6873        assert!(n.normalized_range().is_none());
6874    }
6875
6876    #[test]
6877    fn test_zscore_normalized_range_zero_for_uniform_window() {
6878        let mut n = znorm(3);
6879        for _ in 0..3 { n.update(dec!(10)); }
6880        assert_eq!(n.normalized_range(), Some(0.0));
6881    }
6882
6883    #[test]
6884    fn test_zscore_normalized_range_positive_for_varying_window() {
6885        let mut n = znorm(3);
6886        for v in [dec!(8), dec!(10), dec!(12)] { n.update(v); }
6887        // span = 12 - 8 = 4, mean = 10, ratio = 0.4
6888        let nr = n.normalized_range().unwrap();
6889        assert!((nr - 0.4).abs() < 1e-9);
6890    }
6891
6892    // ── ZScoreNormalizer::midpoint ────────────────────────────────────────────
6893
6894    #[test]
6895    fn test_zscore_midpoint_none_for_empty_window() {
6896        assert!(znorm(3).midpoint().is_none());
6897    }
6898
6899    #[test]
6900    fn test_zscore_midpoint_correct_for_known_range() {
6901        let mut n = znorm(4);
6902        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
6903        // min=10, max=40, midpoint=25
6904        assert_eq!(n.midpoint(), Some(dec!(25)));
6905    }
6906
6907    // ── ZScoreNormalizer::clamp_to_window ─────────────────────────────────────
6908
6909    #[test]
6910    fn test_zscore_clamp_returns_value_unchanged_on_empty_window() {
6911        let n = znorm(3);
6912        assert_eq!(n.clamp_to_window(dec!(50)), dec!(50));
6913    }
6914
6915    #[test]
6916    fn test_zscore_clamp_clamps_to_min() {
6917        let mut n = znorm(3);
6918        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
6919        assert_eq!(n.clamp_to_window(dec!(-5)), dec!(10));
6920    }
6921
6922    #[test]
6923    fn test_zscore_clamp_clamps_to_max() {
6924        let mut n = znorm(3);
6925        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
6926        assert_eq!(n.clamp_to_window(dec!(100)), dec!(30));
6927    }
6928
6929    #[test]
6930    fn test_zscore_clamp_passes_through_in_range_value() {
6931        let mut n = znorm(3);
6932        for v in [dec!(10), dec!(20), dec!(30)] { n.update(v); }
6933        assert_eq!(n.clamp_to_window(dec!(15)), dec!(15));
6934    }
6935
6936    // ── ZScoreNormalizer::min_max ─────────────────────────────────────────────
6937
6938    #[test]
6939    fn test_zscore_min_max_none_for_empty_window() {
6940        assert!(znorm(3).min_max().is_none());
6941    }
6942
6943    #[test]
6944    fn test_zscore_min_max_returns_correct_pair() {
6945        let mut n = znorm(4);
6946        for v in [dec!(5), dec!(15), dec!(10), dec!(20)] { n.update(v); }
6947        assert_eq!(n.min_max(), Some((dec!(5), dec!(20))));
6948    }
6949
6950    #[test]
6951    fn test_zscore_min_max_single_value() {
6952        let mut n = znorm(3);
6953        n.update(dec!(42));
6954        assert_eq!(n.min_max(), Some((dec!(42), dec!(42))));
6955    }
6956
6957    // ── ZScoreNormalizer::values ──────────────────────────────────────────────
6958
6959    #[test]
6960    fn test_zscore_values_empty_for_empty_window() {
6961        assert!(znorm(3).values().is_empty());
6962    }
6963
6964    #[test]
6965    fn test_zscore_values_preserves_insertion_order() {
6966        let mut n = znorm(4);
6967        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
6968        assert_eq!(n.values(), vec![dec!(10), dec!(20), dec!(30), dec!(40)]);
6969    }
6970
6971    // ── ZScoreNormalizer::above_zero_fraction ─────────────────────────────────
6972
6973    #[test]
6974    fn test_zscore_above_zero_fraction_none_for_empty_window() {
6975        assert!(znorm(3).above_zero_fraction().is_none());
6976    }
6977
6978    #[test]
6979    fn test_zscore_above_zero_fraction_zero_for_all_negative() {
6980        let mut n = znorm(3);
6981        for v in [dec!(-3), dec!(-2), dec!(-1)] { n.update(v); }
6982        assert_eq!(n.above_zero_fraction(), Some(0.0));
6983    }
6984
6985    #[test]
6986    fn test_zscore_above_zero_fraction_one_for_all_positive() {
6987        let mut n = znorm(3);
6988        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
6989        assert_eq!(n.above_zero_fraction(), Some(1.0));
6990    }
6991
6992    #[test]
6993    fn test_zscore_above_zero_fraction_half_for_mixed() {
6994        let mut n = znorm(4);
6995        for v in [dec!(-2), dec!(-1), dec!(1), dec!(2)] { n.update(v); }
6996        let frac = n.above_zero_fraction().unwrap();
6997        assert!((frac - 0.5).abs() < 1e-9);
6998    }
6999
7000    // ── ZScoreNormalizer::z_score_opt ─────────────────────────────────────────
7001
7002    #[test]
7003    fn test_zscore_opt_none_for_empty_window() {
7004        assert!(znorm(3).z_score_opt(dec!(10)).is_none());
7005    }
7006
7007    #[test]
7008    fn test_zscore_opt_matches_normalize_for_populated_window() {
7009        let mut n = znorm(4);
7010        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
7011        let z_opt = n.z_score_opt(dec!(25)).unwrap();
7012        let z_norm = n.normalize(dec!(25)).unwrap();
7013        assert!((z_opt - z_norm).abs() < 1e-12);
7014    }
7015
7016    // ── ZScoreNormalizer::is_stable ───────────────────────────────────────────
7017
7018    #[test]
7019    fn test_zscore_is_stable_false_for_empty_window() {
7020        assert!(!znorm(3).is_stable(2.0));
7021    }
7022
7023    #[test]
7024    fn test_zscore_is_stable_true_for_near_mean_value() {
7025        let mut n = znorm(5);
7026        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(30)] { n.update(v); }
7027        // latest is 30, near mean of 26; should be stable with threshold=2
7028        assert!(n.is_stable(2.0));
7029    }
7030
7031    #[test]
7032    fn test_zscore_is_stable_false_for_extreme_value() {
7033        let mut n = znorm(5);
7034        for v in [dec!(10), dec!(10), dec!(10), dec!(10), dec!(100)] { n.update(v); }
7035        // latest is 100, far from mean ~28; should be unstable
7036        assert!(!n.is_stable(1.0));
7037    }
7038
7039    // ── ZScoreNormalizer::window_values_above / window_values_below (moved here) ─
7040
7041    #[test]
7042    fn test_zscore_window_values_above_via_znorm_empty() {
7043        assert!(znorm(3).window_values_above(dec!(5)).is_empty());
7044    }
7045
7046    #[test]
7047    fn test_zscore_window_values_above_via_znorm_filters() {
7048        let mut n = znorm(5);
7049        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
7050        let above = n.window_values_above(dec!(5));
7051        assert_eq!(above.len(), 2);
7052        assert!(above.contains(&dec!(7)));
7053        assert!(above.contains(&dec!(9)));
7054    }
7055
7056    #[test]
7057    fn test_zscore_window_values_below_via_znorm_empty() {
7058        assert!(znorm(3).window_values_below(dec!(5)).is_empty());
7059    }
7060
7061    #[test]
7062    fn test_zscore_window_values_below_via_znorm_filters() {
7063        let mut n = znorm(5);
7064        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
7065        let below = n.window_values_below(dec!(5));
7066        assert_eq!(below.len(), 2);
7067        assert!(below.contains(&dec!(1)));
7068        assert!(below.contains(&dec!(3)));
7069    }
7070
7071    // ── ZScoreNormalizer::fraction_above / fraction_below ─────────────────────
7072
7073    #[test]
7074    fn test_zscore_fraction_above_none_for_empty_window() {
7075        assert!(znorm(3).fraction_above(dec!(5)).is_none());
7076    }
7077
7078    #[test]
7079    fn test_zscore_fraction_above_correct() {
7080        let mut n = znorm(5);
7081        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7082        // above 3: {4, 5} = 2/5 = 0.4
7083        let frac = n.fraction_above(dec!(3)).unwrap();
7084        assert!((frac - 0.4).abs() < 1e-9);
7085    }
7086
7087    #[test]
7088    fn test_zscore_fraction_below_none_for_empty_window() {
7089        assert!(znorm(3).fraction_below(dec!(5)).is_none());
7090    }
7091
7092    #[test]
7093    fn test_zscore_fraction_below_correct() {
7094        let mut n = znorm(5);
7095        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7096        // below 3: {1, 2} = 2/5 = 0.4
7097        let frac = n.fraction_below(dec!(3)).unwrap();
7098        assert!((frac - 0.4).abs() < 1e-9);
7099    }
7100
7101    // ── ZScoreNormalizer::window_values_above / window_values_below ──────────
7102
7103    #[test]
7104    fn test_zscore_window_values_above_empty_window() {
7105        assert!(znorm(3).window_values_above(dec!(0)).is_empty());
7106    }
7107
7108    #[test]
7109    fn test_zscore_window_values_above_filters_correctly() {
7110        let mut n = znorm(5);
7111        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
7112        let above = n.window_values_above(dec!(5));
7113        assert_eq!(above.len(), 2);
7114        assert!(above.contains(&dec!(7)));
7115        assert!(above.contains(&dec!(9)));
7116    }
7117
7118    #[test]
7119    fn test_zscore_window_values_below_empty_window() {
7120        assert!(znorm(3).window_values_below(dec!(0)).is_empty());
7121    }
7122
7123    #[test]
7124    fn test_zscore_window_values_below_filters_correctly() {
7125        let mut n = znorm(5);
7126        for v in [dec!(1), dec!(3), dec!(5), dec!(7), dec!(9)] { n.update(v); }
7127        let below = n.window_values_below(dec!(5));
7128        assert_eq!(below.len(), 2);
7129        assert!(below.contains(&dec!(1)));
7130        assert!(below.contains(&dec!(3)));
7131    }
7132
7133    // ── ZScoreNormalizer::percentile_rank ─────────────────────────────────────
7134
7135    #[test]
7136    fn test_zscore_percentile_rank_none_for_empty_window() {
7137        assert!(znorm(3).percentile_rank(dec!(5)).is_none());
7138    }
7139
7140    #[test]
7141    fn test_zscore_percentile_rank_correct() {
7142        let mut n = znorm(5);
7143        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7144        // <= 3: {1, 2, 3} = 3/5 = 0.6
7145        let rank = n.percentile_rank(dec!(3)).unwrap();
7146        assert!((rank - 0.6).abs() < 1e-9);
7147    }
7148
7149    // ── ZScoreNormalizer::count_equal ─────────────────────────────────────────
7150
7151    #[test]
7152    fn test_zscore_count_equal_zero_for_no_match() {
7153        let mut n = znorm(3);
7154        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
7155        assert_eq!(n.count_equal(dec!(99)), 0);
7156    }
7157
7158    #[test]
7159    fn test_zscore_count_equal_counts_duplicates() {
7160        let mut n = znorm(5);
7161        for v in [dec!(5), dec!(5), dec!(3), dec!(5), dec!(2)] { n.update(v); }
7162        assert_eq!(n.count_equal(dec!(5)), 3);
7163    }
7164
7165    // ── ZScoreNormalizer::median ──────────────────────────────────────────────
7166
7167    #[test]
7168    fn test_zscore_median_none_for_empty_window() {
7169        assert!(znorm(3).median().is_none());
7170    }
7171
7172    #[test]
7173    fn test_zscore_median_correct_for_odd_count() {
7174        let mut n = znorm(5);
7175        for v in [dec!(3), dec!(1), dec!(5), dec!(4), dec!(2)] { n.update(v); }
7176        // sorted: 1,2,3,4,5 → middle (idx 2) = 3
7177        assert_eq!(n.median(), Some(dec!(3)));
7178    }
7179
7180    // ── ZScoreNormalizer::rolling_range ───────────────────────────────────────
7181
7182    #[test]
7183    fn test_zscore_rolling_range_none_for_empty() {
7184        assert!(znorm(3).rolling_range().is_none());
7185    }
7186
7187    #[test]
7188    fn test_zscore_rolling_range_correct() {
7189        let mut n = znorm(5);
7190        for v in [dec!(10), dec!(50), dec!(30), dec!(20), dec!(40)] { n.update(v); }
7191        assert_eq!(n.rolling_range(), Some(dec!(40)));
7192    }
7193
7194    // ── ZScoreNormalizer::skewness ─────────────────────────────────────────────
7195
7196    #[test]
7197    fn test_zscore_skewness_none_for_fewer_than_3() {
7198        let mut n = znorm(5);
7199        n.update(dec!(1)); n.update(dec!(2));
7200        assert!(n.skewness().is_none());
7201    }
7202
7203    #[test]
7204    fn test_zscore_skewness_near_zero_for_symmetric_data() {
7205        let mut n = znorm(5);
7206        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7207        let s = n.skewness().unwrap();
7208        assert!(s.abs() < 0.5);
7209    }
7210
7211    // ── ZScoreNormalizer::kurtosis ─────────────────────────────────────
7212
7213    #[test]
7214    fn test_zscore_kurtosis_none_for_fewer_than_4() {
7215        let mut n = znorm(5);
7216        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
7217        assert!(n.kurtosis().is_none());
7218    }
7219
7220    #[test]
7221    fn test_zscore_kurtosis_returns_f64_for_populated_window() {
7222        let mut n = znorm(5);
7223        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7224        assert!(n.kurtosis().is_some());
7225    }
7226
7227    // ── ZScoreNormalizer::autocorrelation_lag1 ────────────────────────────────
7228
7229    #[test]
7230    fn test_zscore_autocorrelation_none_for_single_value() {
7231        let mut n = znorm(3);
7232        n.update(dec!(1));
7233        assert!(n.autocorrelation_lag1().is_none());
7234    }
7235
7236    #[test]
7237    fn test_zscore_autocorrelation_positive_for_trending_data() {
7238        let mut n = znorm(5);
7239        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7240        let ac = n.autocorrelation_lag1().unwrap();
7241        assert!(ac > 0.0);
7242    }
7243
7244    // ── ZScoreNormalizer::trend_consistency ───────────────────────────────────
7245
7246    #[test]
7247    fn test_zscore_trend_consistency_none_for_single_value() {
7248        let mut n = znorm(3);
7249        n.update(dec!(1));
7250        assert!(n.trend_consistency().is_none());
7251    }
7252
7253    #[test]
7254    fn test_zscore_trend_consistency_one_for_strictly_rising() {
7255        let mut n = znorm(5);
7256        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7257        let tc = n.trend_consistency().unwrap();
7258        assert!((tc - 1.0).abs() < 1e-9);
7259    }
7260
7261    #[test]
7262    fn test_zscore_trend_consistency_zero_for_strictly_falling() {
7263        let mut n = znorm(5);
7264        for v in [dec!(5), dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
7265        let tc = n.trend_consistency().unwrap();
7266        assert!((tc - 0.0).abs() < 1e-9);
7267    }
7268
7269    // ── ZScoreNormalizer::coefficient_of_variation ────────────────────────────
7270
7271    #[test]
7272    fn test_zscore_cov_none_for_empty_window() {
7273        assert!(znorm(3).coefficient_of_variation().is_none());
7274    }
7275
7276    #[test]
7277    fn test_zscore_cov_positive_for_varied_data() {
7278        let mut n = znorm(5);
7279        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50)] { n.update(v); }
7280        let cov = n.coefficient_of_variation().unwrap();
7281        assert!(cov > 0.0);
7282    }
7283
7284    // ── ZScoreNormalizer::mean_absolute_deviation ─────────────────────────────
7285
7286    #[test]
7287    fn test_zscore_mad_none_for_empty() {
7288        assert!(znorm(3).mean_absolute_deviation().is_none());
7289    }
7290
7291    #[test]
7292    fn test_zscore_mad_zero_for_identical_values() {
7293        let mut n = znorm(3);
7294        for v in [dec!(5), dec!(5), dec!(5)] { n.update(v); }
7295        let mad = n.mean_absolute_deviation().unwrap();
7296        assert!((mad - 0.0).abs() < 1e-9);
7297    }
7298
7299    #[test]
7300    fn test_zscore_mad_positive_for_varied_data() {
7301        let mut n = znorm(4);
7302        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7303        let mad = n.mean_absolute_deviation().unwrap();
7304        assert!(mad > 0.0);
7305    }
7306
7307    // ── ZScoreNormalizer::percentile_of_latest ────────────────────────────────
7308
7309    #[test]
7310    fn test_zscore_percentile_of_latest_none_for_empty() {
7311        assert!(znorm(3).percentile_of_latest().is_none());
7312    }
7313
7314    #[test]
7315    fn test_zscore_percentile_of_latest_returns_some_after_update() {
7316        let mut n = znorm(4);
7317        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7318        assert!(n.percentile_of_latest().is_some());
7319    }
7320
7321    #[test]
7322    fn test_zscore_percentile_of_latest_max_has_high_rank() {
7323        let mut n = znorm(5);
7324        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7325        let rank = n.percentile_of_latest().unwrap();
7326        assert!(rank >= 0.9, "max value should have rank near 1.0, got {}", rank);
7327    }
7328
7329    // ── ZScoreNormalizer::tail_ratio ──────────────────────────────────────────
7330
7331    #[test]
7332    fn test_zscore_tail_ratio_none_for_empty() {
7333        assert!(znorm(4).tail_ratio().is_none());
7334    }
7335
7336    #[test]
7337    fn test_zscore_tail_ratio_one_for_identical_values() {
7338        let mut n = znorm(4);
7339        for _ in 0..4 { n.update(dec!(7)); }
7340        let r = n.tail_ratio().unwrap();
7341        assert!((r - 1.0).abs() < 1e-9, "expected 1.0, got {}", r);
7342    }
7343
7344    #[test]
7345    fn test_zscore_tail_ratio_above_one_with_outlier() {
7346        let mut n = znorm(5);
7347        for v in [dec!(1), dec!(1), dec!(1), dec!(1), dec!(10)] { n.update(v); }
7348        let r = n.tail_ratio().unwrap();
7349        assert!(r > 1.0, "outlier should push ratio above 1.0, got {}", r);
7350    }
7351
7352    // ── ZScoreNormalizer::z_score_of_min / z_score_of_max ────────────────────
7353
7354    #[test]
7355    fn test_zscore_z_score_of_min_none_for_empty() {
7356        assert!(znorm(4).z_score_of_min().is_none());
7357    }
7358
7359    #[test]
7360    fn test_zscore_z_score_of_max_none_for_empty() {
7361        assert!(znorm(4).z_score_of_max().is_none());
7362    }
7363
7364    #[test]
7365    fn test_zscore_z_score_of_min_negative_for_varied_window() {
7366        let mut n = znorm(5);
7367        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7368        let z = n.z_score_of_min().unwrap();
7369        assert!(z < 0.0, "z-score of min should be negative, got {}", z);
7370    }
7371
7372    #[test]
7373    fn test_zscore_z_score_of_max_positive_for_varied_window() {
7374        let mut n = znorm(5);
7375        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7376        let z = n.z_score_of_max().unwrap();
7377        assert!(z > 0.0, "z-score of max should be positive, got {}", z);
7378    }
7379
7380    // ── ZScoreNormalizer::window_entropy ──────────────────────────────────────
7381
7382    #[test]
7383    fn test_zscore_window_entropy_none_for_empty() {
7384        assert!(znorm(4).window_entropy().is_none());
7385    }
7386
7387    #[test]
7388    fn test_zscore_window_entropy_zero_for_identical_values() {
7389        let mut n = znorm(3);
7390        for _ in 0..3 { n.update(dec!(5)); }
7391        let e = n.window_entropy().unwrap();
7392        assert!((e - 0.0).abs() < 1e-9, "identical values should have zero entropy, got {}", e);
7393    }
7394
7395    #[test]
7396    fn test_zscore_window_entropy_positive_for_varied_values() {
7397        let mut n = znorm(4);
7398        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7399        let e = n.window_entropy().unwrap();
7400        assert!(e > 0.0, "varied values should have positive entropy, got {}", e);
7401    }
7402
7403    // ── ZScoreNormalizer::normalized_std_dev ──────────────────────────────────
7404
7405    #[test]
7406    fn test_zscore_normalized_std_dev_none_for_empty() {
7407        assert!(znorm(4).normalized_std_dev().is_none());
7408    }
7409
7410    #[test]
7411    fn test_zscore_normalized_std_dev_positive_for_varied_values() {
7412        let mut n = znorm(4);
7413        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7414        let r = n.normalized_std_dev().unwrap();
7415        assert!(r > 0.0, "expected positive normalized std dev, got {}", r);
7416    }
7417
7418    // ── ZScoreNormalizer::value_above_mean_count ──────────────────────────────
7419
7420    #[test]
7421    fn test_zscore_value_above_mean_count_none_for_empty() {
7422        assert!(znorm(4).value_above_mean_count().is_none());
7423    }
7424
7425    #[test]
7426    fn test_zscore_value_above_mean_count_correct() {
7427        let mut n = znorm(4);
7428        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7429        // mean=2.5, above: 3 and 4 → 2
7430        assert_eq!(n.value_above_mean_count().unwrap(), 2);
7431    }
7432
7433    // ── ZScoreNormalizer::consecutive_above_mean ──────────────────────────────
7434
7435    #[test]
7436    fn test_zscore_consecutive_above_mean_none_for_empty() {
7437        assert!(znorm(4).consecutive_above_mean().is_none());
7438    }
7439
7440    #[test]
7441    fn test_zscore_consecutive_above_mean_correct() {
7442        let mut n = znorm(4);
7443        for v in [dec!(1), dec!(5), dec!(6), dec!(7)] { n.update(v); }
7444        // mean=4.75, above: 5,6,7 → run=3
7445        assert_eq!(n.consecutive_above_mean().unwrap(), 3);
7446    }
7447
7448    // ── ZScoreNormalizer::above_threshold_fraction / below_threshold_fraction ─
7449
7450    #[test]
7451    fn test_zscore_above_threshold_fraction_none_for_empty() {
7452        assert!(znorm(4).above_threshold_fraction(dec!(5)).is_none());
7453    }
7454
7455    #[test]
7456    fn test_zscore_above_threshold_fraction_correct() {
7457        let mut n = znorm(4);
7458        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7459        let f = n.above_threshold_fraction(dec!(2)).unwrap();
7460        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
7461    }
7462
7463    #[test]
7464    fn test_zscore_below_threshold_fraction_none_for_empty() {
7465        assert!(znorm(4).below_threshold_fraction(dec!(5)).is_none());
7466    }
7467
7468    #[test]
7469    fn test_zscore_below_threshold_fraction_correct() {
7470        let mut n = znorm(4);
7471        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7472        let f = n.below_threshold_fraction(dec!(3)).unwrap();
7473        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
7474    }
7475
7476    // ── ZScoreNormalizer::lag_k_autocorrelation ───────────────────────────────
7477
7478    #[test]
7479    fn test_zscore_lag_k_autocorrelation_none_for_zero_k() {
7480        let mut n = znorm(5);
7481        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7482        assert!(n.lag_k_autocorrelation(0).is_none());
7483    }
7484
7485    #[test]
7486    fn test_zscore_lag_k_autocorrelation_none_when_k_gte_len() {
7487        let mut n = znorm(3);
7488        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
7489        assert!(n.lag_k_autocorrelation(3).is_none());
7490    }
7491
7492    #[test]
7493    fn test_zscore_lag_k_autocorrelation_positive_for_trend() {
7494        let mut n = znorm(6);
7495        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5), dec!(6)] { n.update(v); }
7496        let ac = n.lag_k_autocorrelation(1).unwrap();
7497        assert!(ac > 0.0, "trending series should have positive AC, got {}", ac);
7498    }
7499
7500    // ── ZScoreNormalizer::half_life_estimate ──────────────────────────────────
7501
7502    #[test]
7503    fn test_zscore_half_life_estimate_none_for_fewer_than_3() {
7504        let mut n = znorm(3);
7505        n.update(dec!(1)); n.update(dec!(2));
7506        assert!(n.half_life_estimate().is_none());
7507    }
7508
7509    #[test]
7510    fn test_zscore_half_life_no_panic_for_alternating() {
7511        let mut n = znorm(6);
7512        for v in [dec!(10), dec!(5), dec!(10), dec!(5), dec!(10), dec!(5)] { n.update(v); }
7513        let _ = n.half_life_estimate();
7514    }
7515
7516    // ── ZScoreNormalizer::geometric_mean ──────────────────────────────────────
7517
7518    #[test]
7519    fn test_zscore_geometric_mean_none_for_empty() {
7520        assert!(znorm(4).geometric_mean().is_none());
7521    }
7522
7523    #[test]
7524    fn test_zscore_geometric_mean_correct_for_powers_of_2() {
7525        let mut n = znorm(4);
7526        for v in [dec!(1), dec!(2), dec!(4), dec!(8)] { n.update(v); }
7527        let gm = n.geometric_mean().unwrap();
7528        assert!((gm - 64.0f64.powf(0.25)).abs() < 1e-6, "got {}", gm);
7529    }
7530
7531    // ── ZScoreNormalizer::harmonic_mean ───────────────────────────────────────
7532
7533    #[test]
7534    fn test_zscore_harmonic_mean_none_for_empty() {
7535        assert!(znorm(4).harmonic_mean().is_none());
7536    }
7537
7538    #[test]
7539    fn test_zscore_harmonic_mean_positive_for_positive_values() {
7540        let mut n = znorm(4);
7541        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7542        let hm = n.harmonic_mean().unwrap();
7543        assert!(hm > 0.0 && hm < 4.0, "HM should be in (0, max), got {}", hm);
7544    }
7545
7546    // ── ZScoreNormalizer::range_normalized_value ──────────────────────────────
7547
7548    #[test]
7549    fn test_zscore_range_normalized_value_none_for_empty() {
7550        assert!(znorm(4).range_normalized_value(dec!(5)).is_none());
7551    }
7552
7553    #[test]
7554    fn test_zscore_range_normalized_value_in_range() {
7555        let mut n = znorm(4);
7556        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7557        let r = n.range_normalized_value(dec!(2)).unwrap();
7558        assert!(r >= 0.0 && r <= 1.0, "expected [0,1], got {}", r);
7559    }
7560
7561    // ── ZScoreNormalizer::distance_from_median ────────────────────────────────
7562
7563    #[test]
7564    fn test_zscore_distance_from_median_none_for_empty() {
7565        assert!(znorm(4).distance_from_median(dec!(5)).is_none());
7566    }
7567
7568    #[test]
7569    fn test_zscore_distance_from_median_zero_at_median() {
7570        let mut n = znorm(5);
7571        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7572        let d = n.distance_from_median(dec!(3)).unwrap();
7573        assert!((d - 0.0).abs() < 1e-9, "distance from median=3 should be 0, got {}", d);
7574    }
7575
7576    #[test]
7577    fn test_zscore_momentum_none_for_single_value() {
7578        let mut n = znorm(5);
7579        n.update(dec!(10));
7580        assert!(n.momentum().is_none());
7581    }
7582
7583    #[test]
7584    fn test_zscore_momentum_positive_for_rising_window() {
7585        let mut n = znorm(3);
7586        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
7587        let m = n.momentum().unwrap();
7588        assert!(m > 0.0, "rising window → positive momentum, got {}", m);
7589    }
7590
7591    #[test]
7592    fn test_zscore_value_rank_none_for_empty() {
7593        assert!(znorm(4).value_rank(dec!(5)).is_none());
7594    }
7595
7596    #[test]
7597    fn test_zscore_value_rank_extremes() {
7598        let mut n = znorm(4);
7599        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7600        let low = n.value_rank(dec!(0)).unwrap();
7601        assert!((low - 0.0).abs() < 1e-9, "got {}", low);
7602        let high = n.value_rank(dec!(5)).unwrap();
7603        assert!((high - 1.0).abs() < 1e-9, "got {}", high);
7604    }
7605
7606    #[test]
7607    fn test_zscore_coeff_of_variation_none_for_single_value() {
7608        let mut n = znorm(5);
7609        n.update(dec!(10));
7610        assert!(n.coeff_of_variation().is_none());
7611    }
7612
7613    #[test]
7614    fn test_zscore_coeff_of_variation_positive_for_spread() {
7615        let mut n = znorm(4);
7616        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
7617        let cv = n.coeff_of_variation().unwrap();
7618        assert!(cv > 0.0, "expected positive CV, got {}", cv);
7619    }
7620
7621    #[test]
7622    fn test_zscore_quantile_range_none_for_empty() {
7623        assert!(znorm(4).quantile_range().is_none());
7624    }
7625
7626    #[test]
7627    fn test_zscore_quantile_range_non_negative() {
7628        let mut n = znorm(5);
7629        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7630        let iqr = n.quantile_range().unwrap();
7631        assert!(iqr >= 0.0, "IQR should be non-negative, got {}", iqr);
7632    }
7633
7634    // ── ZScoreNormalizer::upper_quartile / lower_quartile ─────────────────────
7635
7636    #[test]
7637    fn test_zscore_upper_quartile_none_for_empty() {
7638        assert!(znorm(4).upper_quartile().is_none());
7639    }
7640
7641    #[test]
7642    fn test_zscore_lower_quartile_none_for_empty() {
7643        assert!(znorm(4).lower_quartile().is_none());
7644    }
7645
7646    #[test]
7647    fn test_zscore_upper_ge_lower_quartile() {
7648        let mut n = znorm(8);
7649        for v in [dec!(10), dec!(20), dec!(30), dec!(40), dec!(50), dec!(60), dec!(70), dec!(80)] {
7650            n.update(v);
7651        }
7652        let q3 = n.upper_quartile().unwrap();
7653        let q1 = n.lower_quartile().unwrap();
7654        assert!(q3 >= q1, "Q3 ({}) should be >= Q1 ({})", q3, q1);
7655    }
7656
7657    // ── ZScoreNormalizer::sign_change_rate ────────────────────────────────────
7658
7659    #[test]
7660    fn test_zscore_sign_change_rate_none_for_fewer_than_3() {
7661        let mut n = znorm(4);
7662        n.update(dec!(1));
7663        n.update(dec!(2));
7664        assert!(n.sign_change_rate().is_none());
7665    }
7666
7667    #[test]
7668    fn test_zscore_sign_change_rate_one_for_zigzag() {
7669        let mut n = znorm(5);
7670        for v in [dec!(1), dec!(3), dec!(1), dec!(3), dec!(1)] { n.update(v); }
7671        let r = n.sign_change_rate().unwrap();
7672        assert!((r - 1.0).abs() < 1e-9, "zigzag should give 1.0, got {}", r);
7673    }
7674
7675    #[test]
7676    fn test_zscore_sign_change_rate_zero_for_monotone() {
7677        let mut n = znorm(5);
7678        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7679        let r = n.sign_change_rate().unwrap();
7680        assert!((r - 0.0).abs() < 1e-9, "monotone should give 0.0, got {}", r);
7681    }
7682
7683    // ── ZScoreNormalizer::trimmed_mean ────────────────────────────────────────
7684
7685    #[test]
7686    fn test_zscore_trimmed_mean_none_for_empty() {
7687        assert!(znorm(4).trimmed_mean(0.1).is_none());
7688    }
7689
7690    #[test]
7691    fn test_zscore_trimmed_mean_equals_mean_at_zero_trim() {
7692        let mut n = znorm(4);
7693        for v in [dec!(10), dec!(20), dec!(30), dec!(40)] { n.update(v); }
7694        let tm = n.trimmed_mean(0.0).unwrap();
7695        let m = n.mean().unwrap().to_f64().unwrap();
7696        assert!((tm - m).abs() < 1e-9, "0% trim should equal mean, got tm={} m={}", tm, m);
7697    }
7698
7699    #[test]
7700    fn test_zscore_trimmed_mean_reduces_outlier_effect() {
7701        let mut n = znorm(5);
7702        for v in [dec!(10), dec!(10), dec!(10), dec!(10), dec!(1000)] { n.update(v); }
7703        // p=0.2 → trim = floor(5*0.2) = 1 element removed from each end
7704        let tm = n.trimmed_mean(0.2).unwrap();
7705        let m = n.mean().unwrap().to_f64().unwrap();
7706        assert!(tm < m, "trimmed mean should be less than mean when outlier trimmed, tm={} m={}", tm, m);
7707    }
7708
7709    // ── ZScoreNormalizer::linear_trend_slope ─────────────────────────────────
7710
7711    #[test]
7712    fn test_zscore_linear_trend_slope_none_for_single_value() {
7713        let mut n = znorm(4);
7714        n.update(dec!(10));
7715        assert!(n.linear_trend_slope().is_none());
7716    }
7717
7718    #[test]
7719    fn test_zscore_linear_trend_slope_positive_for_rising() {
7720        let mut n = znorm(4);
7721        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7722        let slope = n.linear_trend_slope().unwrap();
7723        assert!(slope > 0.0, "rising window → positive slope, got {}", slope);
7724    }
7725
7726    #[test]
7727    fn test_zscore_linear_trend_slope_negative_for_falling() {
7728        let mut n = znorm(4);
7729        for v in [dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
7730        let slope = n.linear_trend_slope().unwrap();
7731        assert!(slope < 0.0, "falling window → negative slope, got {}", slope);
7732    }
7733
7734    #[test]
7735    fn test_zscore_linear_trend_slope_zero_for_flat() {
7736        let mut n = znorm(4);
7737        for v in [dec!(5), dec!(5), dec!(5), dec!(5)] { n.update(v); }
7738        let slope = n.linear_trend_slope().unwrap();
7739        assert!(slope.abs() < 1e-9, "flat window → slope=0, got {}", slope);
7740    }
7741
7742    // ── ZScoreNormalizer::variance_ratio ─────────────────────────────────────
7743
7744    #[test]
7745    fn test_zscore_variance_ratio_none_for_few_values() {
7746        let mut n = znorm(3);
7747        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
7748        assert!(n.variance_ratio().is_none());
7749    }
7750
7751    #[test]
7752    fn test_zscore_variance_ratio_gt_one_for_decreasing_vol() {
7753        let mut n = znorm(6);
7754        // first half: high variance [1, 10, 1]; second half: low variance [5, 6, 5]
7755        for v in [dec!(1), dec!(10), dec!(1), dec!(5), dec!(6), dec!(5)] { n.update(v); }
7756        let r = n.variance_ratio().unwrap();
7757        assert!(r > 1.0, "first half more volatile → ratio > 1, got {}", r);
7758    }
7759
7760    // ── ZScoreNormalizer::z_score_trend_slope ────────────────────────────────
7761
7762    #[test]
7763    fn test_zscore_z_score_trend_slope_none_for_single_value() {
7764        let mut n = znorm(4);
7765        n.update(dec!(10));
7766        assert!(n.z_score_trend_slope().is_none());
7767    }
7768
7769    #[test]
7770    fn test_zscore_z_score_trend_slope_positive_for_rising() {
7771        let mut n = znorm(5);
7772        for v in [dec!(1), dec!(2), dec!(3), dec!(4), dec!(5)] { n.update(v); }
7773        let slope = n.z_score_trend_slope().unwrap();
7774        assert!(slope > 0.0, "rising window → positive z-score slope, got {}", slope);
7775    }
7776
7777    // ── ZScoreNormalizer::mean_absolute_change ────────────────────────────────
7778
7779    #[test]
7780    fn test_zscore_mean_absolute_change_none_for_single_value() {
7781        let mut n = znorm(4);
7782        n.update(dec!(10));
7783        assert!(n.mean_absolute_change().is_none());
7784    }
7785
7786    #[test]
7787    fn test_zscore_mean_absolute_change_zero_for_constant() {
7788        let mut n = znorm(4);
7789        for _ in 0..4 { n.update(dec!(5)); }
7790        let mac = n.mean_absolute_change().unwrap();
7791        assert!(mac.abs() < 1e-9, "constant window → MAC=0, got {}", mac);
7792    }
7793
7794    #[test]
7795    fn test_zscore_mean_absolute_change_positive_for_varying() {
7796        let mut n = znorm(4);
7797        for v in [dec!(1), dec!(3), dec!(2), dec!(5)] { n.update(v); }
7798        let mac = n.mean_absolute_change().unwrap();
7799        assert!(mac > 0.0, "varying window → MAC > 0, got {}", mac);
7800    }
7801
7802    // ── round-83 tests ─────────────────────────────────────────────────────
7803
7804    #[test]
7805    fn test_zscore_monotone_increase_fraction_none_for_single() {
7806        let mut n = znorm(4);
7807        n.update(dec!(5));
7808        assert!(n.monotone_increase_fraction().is_none());
7809    }
7810
7811    #[test]
7812    fn test_zscore_monotone_increase_fraction_one_for_rising() {
7813        let mut n = znorm(4);
7814        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7815        let f = n.monotone_increase_fraction().unwrap();
7816        assert!((f - 1.0).abs() < 1e-9, "all rising → fraction=1, got {}", f);
7817    }
7818
7819    #[test]
7820    fn test_zscore_abs_max_none_for_empty() {
7821        let n = znorm(4);
7822        assert!(n.abs_max().is_none());
7823    }
7824
7825    #[test]
7826    fn test_zscore_abs_max_returns_max_absolute() {
7827        let mut n = znorm(4);
7828        for v in [dec!(1), dec!(3), dec!(2)] { n.update(v); }
7829        assert_eq!(n.abs_max().unwrap(), dec!(3));
7830    }
7831
7832    #[test]
7833    fn test_zscore_max_count_none_for_empty() {
7834        let n = znorm(4);
7835        assert!(n.max_count().is_none());
7836    }
7837
7838    #[test]
7839    fn test_zscore_max_count_correct() {
7840        let mut n = znorm(4);
7841        for v in [dec!(1), dec!(5), dec!(3), dec!(5)] { n.update(v); }
7842        assert_eq!(n.max_count().unwrap(), 2);
7843    }
7844
7845    #[test]
7846    fn test_zscore_mean_ratio_none_for_single() {
7847        let mut n = znorm(4);
7848        n.update(dec!(10));
7849        assert!(n.mean_ratio().is_none());
7850    }
7851
7852    // ── round-84 tests ─────────────────────────────────────────────────────
7853
7854    #[test]
7855    fn test_zscore_exponential_weighted_mean_none_for_empty() {
7856        let n = znorm(4);
7857        assert!(n.exponential_weighted_mean(0.5).is_none());
7858    }
7859
7860    #[test]
7861    fn test_zscore_exponential_weighted_mean_returns_value() {
7862        let mut n = znorm(4);
7863        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7864        let ewm = n.exponential_weighted_mean(0.5).unwrap();
7865        assert!(ewm > 0.0, "EWM should be positive, got {}", ewm);
7866    }
7867
7868    #[test]
7869    fn test_zscore_peak_to_trough_none_for_empty() {
7870        let n = znorm(4);
7871        assert!(n.peak_to_trough_ratio().is_none());
7872    }
7873
7874    #[test]
7875    fn test_zscore_peak_to_trough_correct() {
7876        let mut n = znorm(4);
7877        for v in [dec!(2), dec!(4), dec!(1), dec!(8)] { n.update(v); }
7878        let r = n.peak_to_trough_ratio().unwrap();
7879        assert!((r - 8.0).abs() < 1e-9, "max=8, min=1 → ratio=8, got {}", r);
7880    }
7881
7882    #[test]
7883    fn test_zscore_second_moment_none_for_empty() {
7884        let n = znorm(4);
7885        assert!(n.second_moment().is_none());
7886    }
7887
7888    #[test]
7889    fn test_zscore_second_moment_correct() {
7890        let mut n = znorm(4);
7891        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
7892        let m = n.second_moment().unwrap();
7893        assert!((m - 14.0 / 3.0).abs() < 1e-9, "second moment ≈ 4.667, got {}", m);
7894    }
7895
7896    #[test]
7897    fn test_zscore_range_over_mean_none_for_empty() {
7898        let n = znorm(4);
7899        assert!(n.range_over_mean().is_none());
7900    }
7901
7902    #[test]
7903    fn test_zscore_range_over_mean_positive() {
7904        let mut n = znorm(4);
7905        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7906        let r = n.range_over_mean().unwrap();
7907        assert!(r > 0.0, "range/mean should be positive, got {}", r);
7908    }
7909
7910    #[test]
7911    fn test_zscore_above_median_fraction_none_for_empty() {
7912        let n = znorm(4);
7913        assert!(n.above_median_fraction().is_none());
7914    }
7915
7916    #[test]
7917    fn test_zscore_above_median_fraction_in_range() {
7918        let mut n = znorm(4);
7919        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7920        let f = n.above_median_fraction().unwrap();
7921        assert!(f >= 0.0 && f <= 1.0, "fraction in [0,1], got {}", f);
7922    }
7923
7924    // ── round-85 tests ─────────────────────────────────────────────────────
7925
7926    #[test]
7927    fn test_zscore_interquartile_mean_none_for_empty() {
7928        let n = znorm(4);
7929        assert!(n.interquartile_mean().is_none());
7930    }
7931
7932    #[test]
7933    fn test_zscore_outlier_fraction_none_for_empty() {
7934        let n = znorm(4);
7935        assert!(n.outlier_fraction(2.0).is_none());
7936    }
7937
7938    #[test]
7939    fn test_zscore_outlier_fraction_zero_for_constant() {
7940        let mut n = znorm(4);
7941        for _ in 0..4 { n.update(dec!(5)); }
7942        let f = n.outlier_fraction(1.0).unwrap();
7943        assert!(f.abs() < 1e-9, "constant window → no outliers, got {}", f);
7944    }
7945
7946    #[test]
7947    fn test_zscore_sign_flip_count_none_for_single() {
7948        let mut n = znorm(4);
7949        n.update(dec!(1));
7950        assert!(n.sign_flip_count().is_none());
7951    }
7952
7953    #[test]
7954    fn test_zscore_sign_flip_count_correct() {
7955        let mut n = znorm(6);
7956        for v in [dec!(1), dec!(-1), dec!(1), dec!(-1)] { n.update(v); }
7957        let c = n.sign_flip_count().unwrap();
7958        assert_eq!(c, 3, "3 sign flips expected, got {}", c);
7959    }
7960
7961    #[test]
7962    fn test_zscore_rms_none_for_empty() {
7963        let n = znorm(4);
7964        assert!(n.rms().is_none());
7965    }
7966
7967    #[test]
7968    fn test_zscore_rms_positive_for_nonzero_values() {
7969        let mut n = znorm(4);
7970        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
7971        let r = n.rms().unwrap();
7972        assert!(r > 0.0, "RMS should be positive, got {}", r);
7973    }
7974
7975    // ── round-86 tests ─────────────────────────────────────────────────────
7976
7977    #[test]
7978    fn test_zscore_distinct_count_zero_for_empty() {
7979        let n = znorm(4);
7980        assert_eq!(n.distinct_count(), 0);
7981    }
7982
7983    #[test]
7984    fn test_zscore_distinct_count_correct() {
7985        let mut n = znorm(4);
7986        for v in [dec!(1), dec!(1), dec!(2), dec!(3)] { n.update(v); }
7987        assert_eq!(n.distinct_count(), 3);
7988    }
7989
7990    #[test]
7991    fn test_zscore_max_fraction_none_for_empty() {
7992        let n = znorm(4);
7993        assert!(n.max_fraction().is_none());
7994    }
7995
7996    #[test]
7997    fn test_zscore_max_fraction_correct() {
7998        let mut n = znorm(4);
7999        for v in [dec!(1), dec!(2), dec!(3), dec!(3)] { n.update(v); }
8000        let f = n.max_fraction().unwrap();
8001        assert!((f - 0.5).abs() < 1e-9, "2/4 are max → 0.5, got {}", f);
8002    }
8003
8004    #[test]
8005    fn test_zscore_latest_minus_mean_none_for_empty() {
8006        let n = znorm(4);
8007        assert!(n.latest_minus_mean().is_none());
8008    }
8009
8010    #[test]
8011    fn test_zscore_latest_to_mean_ratio_none_for_empty() {
8012        let n = znorm(4);
8013        assert!(n.latest_to_mean_ratio().is_none());
8014    }
8015
8016    #[test]
8017    fn test_zscore_latest_to_mean_ratio_one_for_constant() {
8018        let mut n = znorm(4);
8019        for _ in 0..4 { n.update(dec!(5)); }
8020        let r = n.latest_to_mean_ratio().unwrap();
8021        assert!((r - 1.0).abs() < 1e-9, "latest=mean → ratio=1, got {}", r);
8022    }
8023
8024    // ── round-87 tests ────────────────────────────────────────────────────────
8025
8026    #[test]
8027    fn test_zscore_below_mean_fraction_none_for_empty() {
8028        assert!(znorm(4).below_mean_fraction().is_none());
8029    }
8030
8031    #[test]
8032    fn test_zscore_below_mean_fraction_symmetric_data() {
8033        let mut n = znorm(4);
8034        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
8035        // mean=2.5; values strictly below: 1, 2 → 2/4 = 0.5
8036        let f = n.below_mean_fraction().unwrap();
8037        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
8038    }
8039
8040    #[test]
8041    fn test_zscore_tail_variance_none_for_small_window() {
8042        let mut n = znorm(3);
8043        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
8044        assert!(n.tail_variance().is_none());
8045    }
8046
8047    #[test]
8048    fn test_zscore_tail_variance_nonneg_for_varied_data() {
8049        let mut n = znorm(6);
8050        for v in [dec!(1), dec!(2), dec!(5), dec!(6), dec!(9), dec!(10)] { n.update(v); }
8051        let tv = n.tail_variance().unwrap();
8052        assert!(tv >= 0.0, "tail variance should be non-negative, got {}", tv);
8053    }
8054
8055    // ── round-88 tests ─────────────────────────────────────────────────────
8056
8057    #[test]
8058    fn test_zscore_new_max_count_zero_for_empty() {
8059        let n = znorm(4);
8060        assert_eq!(n.new_max_count(), 0);
8061    }
8062
8063    #[test]
8064    fn test_zscore_new_max_count_all_rising() {
8065        let mut n = znorm(4);
8066        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
8067        assert_eq!(n.new_max_count(), 4, "each value is a new high");
8068    }
8069
8070    #[test]
8071    fn test_zscore_new_min_count_zero_for_empty() {
8072        let n = znorm(4);
8073        assert_eq!(n.new_min_count(), 0);
8074    }
8075
8076    #[test]
8077    fn test_zscore_new_min_count_all_falling() {
8078        let mut n = znorm(4);
8079        for v in [dec!(4), dec!(3), dec!(2), dec!(1)] { n.update(v); }
8080        assert_eq!(n.new_min_count(), 4, "each value is a new low");
8081    }
8082
8083    #[test]
8084    fn test_zscore_zero_fraction_none_for_empty() {
8085        let n = znorm(4);
8086        assert!(n.zero_fraction().is_none());
8087    }
8088
8089    #[test]
8090    fn test_zscore_zero_fraction_zero_when_no_zeros() {
8091        let mut n = znorm(4);
8092        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
8093        let f = n.zero_fraction().unwrap();
8094        assert!(f.abs() < 1e-9, "no zeros → fraction=0, got {}", f);
8095    }
8096
8097    // ── round-89 tests ────────────────────────────────────────────────────────
8098
8099    #[test]
8100    fn test_zscore_cumulative_sum_zero_for_empty() {
8101        let n = znorm(4);
8102        assert_eq!(n.cumulative_sum(), rust_decimal::Decimal::ZERO);
8103    }
8104
8105    #[test]
8106    fn test_zscore_cumulative_sum_correct() {
8107        let mut n = znorm(4);
8108        for v in [dec!(1), dec!(2), dec!(3)] { n.update(v); }
8109        assert_eq!(n.cumulative_sum(), dec!(6));
8110    }
8111
8112    #[test]
8113    fn test_zscore_max_to_min_ratio_none_for_empty() {
8114        assert!(znorm(4).max_to_min_ratio().is_none());
8115    }
8116
8117    #[test]
8118    fn test_zscore_max_to_min_ratio_one_for_constant() {
8119        let mut n = znorm(4);
8120        for _ in 0..4 { n.update(dec!(5)); }
8121        let r = n.max_to_min_ratio().unwrap();
8122        assert!((r - 1.0).abs() < 1e-9, "constant window → ratio=1, got {}", r);
8123    }
8124
8125    // ── round-90 tests ────────────────────────────────────────────────────────
8126
8127    #[test]
8128    fn test_zscore_above_midpoint_fraction_none_for_empty() {
8129        assert!(znorm(4).above_midpoint_fraction().is_none());
8130    }
8131
8132    #[test]
8133    fn test_zscore_above_midpoint_fraction_half_for_symmetric() {
8134        let mut n = znorm(4);
8135        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
8136        // midpoint = (1+4)/2 = 2.5; values above: 3 and 4 → 2/4 = 0.5
8137        let f = n.above_midpoint_fraction().unwrap();
8138        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
8139    }
8140
8141    #[test]
8142    fn test_zscore_positive_fraction_none_for_empty() {
8143        assert!(znorm(4).positive_fraction().is_none());
8144    }
8145
8146    #[test]
8147    fn test_zscore_positive_fraction_zero_for_all_nonpositive() {
8148        let mut n = znorm(3);
8149        for v in [dec!(-3), dec!(-1), dec!(0)] { n.update(v); }
8150        let f = n.positive_fraction().unwrap();
8151        assert!((f - 0.0).abs() < 1e-9, "no positives → 0.0, got {}", f);
8152    }
8153
8154    #[test]
8155    fn test_zscore_above_mean_fraction_none_for_empty() {
8156        assert!(znorm(4).above_mean_fraction().is_none());
8157    }
8158
8159    #[test]
8160    fn test_zscore_above_mean_fraction_half_for_symmetric() {
8161        let mut n = znorm(4);
8162        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
8163        // mean = 2.5; values above: 3 and 4 → 0.5
8164        let f = n.above_mean_fraction().unwrap();
8165        assert!((f - 0.5).abs() < 1e-9, "expected 0.5, got {}", f);
8166    }
8167
8168    // ── round-91 tests ────────────────────────────────────────────────────────
8169
8170    #[test]
8171    fn test_zscore_window_iqr_none_for_empty() {
8172        assert!(znorm(4).window_iqr().is_none());
8173    }
8174
8175    #[test]
8176    fn test_zscore_window_iqr_zero_for_constant() {
8177        let mut n = znorm(4);
8178        for _ in 0..4 { n.update(dec!(5)); }
8179        assert_eq!(n.window_iqr().unwrap(), dec!(0));
8180    }
8181
8182    #[test]
8183    fn test_zscore_mean_absolute_deviation_none_for_empty() {
8184        assert!(znorm(4).mean_absolute_deviation().is_none());
8185    }
8186
8187    #[test]
8188    fn test_zscore_mean_absolute_deviation_zero_for_constant() {
8189        let mut n = znorm(4);
8190        for _ in 0..4 { n.update(dec!(7)); }
8191        let mad = n.mean_absolute_deviation().unwrap();
8192        assert!(mad.abs() < 1e-9, "constant window → MAD=0, got {}", mad);
8193    }
8194
8195    #[test]
8196    fn test_zscore_run_length_mean_none_for_single_value() {
8197        let mut n = znorm(4);
8198        n.update(dec!(1));
8199        assert!(n.run_length_mean().is_none());
8200    }
8201
8202    #[test]
8203    fn test_zscore_run_length_mean_all_increasing() {
8204        let mut n = znorm(4);
8205        for v in [dec!(1), dec!(2), dec!(3), dec!(4)] { n.update(v); }
8206        // one monotone run of length 4 → mean = 4.0
8207        let r = n.run_length_mean().unwrap();
8208        assert!((r - 4.0).abs() < 1e-9, "monotone up → run_len=4, got {}", r);
8209    }
8210}