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