diff --git a/Cargo.lock b/Cargo.lock index ce3cd8f..d819f4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1063,7 +1063,7 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "sliding_features" -version = "8.0.0" +version = "9.0.0" dependencies = [ "criterion", "getset", diff --git a/Cargo.toml b/Cargo.toml index d251c59..9a3d1cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ cargo-features = ["edition2024"] [package] name = "sliding_features" -version = "8.0.0" +version = "9.0.0" authors = ["MathisWellmann "] edition = "2024" license-file = "LICENSE" diff --git a/benches/hl_normalizer.rs b/benches/hl_normalizer.rs index 3ab1048..53eb746 100644 --- a/benches/hl_normalizer.rs +++ b/benches/hl_normalizer.rs @@ -15,7 +15,7 @@ use rand::{ use sliding_features::{ View, pure_functions::Echo, - sliding_windows::HLNormalizer, + sliding_windows::MinMaxNormalizer, }; fn criterion_benchmark(c: &mut Criterion) { @@ -27,7 +27,7 @@ fn criterion_benchmark(c: &mut Criterion) { let vals = Vec::::from_iter((0..N).map(|_| rng.random())); b.iter(|| { let mut view = - HLNormalizer::::new(Echo::new(), NonZeroUsize::new(1024).unwrap()); + MinMaxNormalizer::::new(Echo::new(), NonZeroUsize::new(1024).unwrap()); for v in vals.iter() { view.update(*v); let _ = black_box(view.last()); @@ -38,7 +38,7 @@ fn criterion_benchmark(c: &mut Criterion) { let vals = Vec::::from_iter((0..N).map(|_| rng.random())); b.iter(|| { let mut view = - HLNormalizer::::new(Echo::new(), NonZeroUsize::new(1024).unwrap()); + MinMaxNormalizer::::new(Echo::new(), NonZeroUsize::new(1024).unwrap()); for v in vals.iter() { view.update(*v); let _ = black_box(view.last()); diff --git a/img/trend_flex.png b/img/trend_flex.png index 7ead302..b02620d 100644 Binary files a/img/trend_flex.png and b/img/trend_flex.png differ diff --git a/src/sliding_windows/hl_normalizer.rs b/src/sliding_windows/hl_normalizer.rs deleted file mode 100644 index d4ae028..0000000 --- a/src/sliding_windows/hl_normalizer.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! A sliding High - Low Normalizer - -use std::{ - collections::VecDeque, - num::NonZeroUsize, -}; - -use getset::CopyGetters; -use num::Float; - -use crate::View; - -/// A sliding High - Low Normalizer -#[derive(Clone, Debug, CopyGetters)] -pub struct HLNormalizer { - view: V, - /// The sliding window length - #[getset(get_copy = "pub")] - window_len: NonZeroUsize, - q_vals: VecDeque, - min: T, - max: T, - last: T, - init: bool, -} - -impl HLNormalizer -where - V: View, - T: Float, -{ - /// Create a new HLNormalizer with a chained View - /// and a given sliding window length - pub fn new(view: V, window_len: NonZeroUsize) -> Self { - HLNormalizer { - view, - window_len, - q_vals: VecDeque::with_capacity(window_len.get()), - min: T::zero(), - max: T::zero(), - last: T::zero(), - init: true, - } - } -} - -fn extent_queue(q: &VecDeque) -> (T, T) { - let mut min = *q.front().unwrap(); - let mut max = *q.front().unwrap(); - - for i in 0..q.len() { - let val = *q.get(i).unwrap(); - if val > max { - max = val; - } - if val < min { - min = val; - } - } - - (min, max) -} - -impl View for HLNormalizer -where - V: View, - T: Float, -{ - fn update(&mut self, val: T) { - debug_assert!(val.is_finite(), "value must be finite"); - self.view.update(val); - let Some(view_last) = self.view.last() else { - return; - }; - debug_assert!(val.is_finite(), "value must be finite"); - - if self.init { - self.init = false; - self.min = view_last; - self.max = view_last; - self.last = view_last; - } - if self.q_vals.len() >= self.window_len.get() { - let old = *self.q_vals.front().unwrap(); - if old <= self.min || old >= self.max { - let (min, max) = extent_queue(&self.q_vals); - self.min = min; - self.max = max; - } - self.q_vals.pop_front(); - } - self.q_vals.push_back(view_last); - if view_last > self.max { - self.max = view_last; - } - if view_last < self.min { - self.min = view_last; - } - self.last = view_last; - } - - fn last(&self) -> Option { - if self.last == self.min && self.last == self.max { - Some(T::zero()) - } else { - let out = -T::one() - + (((self.last - self.min) * T::from(2.0).expect("can convert")) - / (self.max - self.min)); - - debug_assert!(out.is_finite(), "value must be finite"); - Some(out) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - pure_functions::Echo, - test_data::TEST_DATA, - }; - - #[test] - fn normalizer() { - let mut n = HLNormalizer::new(Echo::new(), NonZeroUsize::new(16).unwrap()); - for v in &TEST_DATA { - n.update(*v); - let last = n.last().unwrap(); - assert!(last <= 1.0); - assert!(last >= -1.0); - } - } -} diff --git a/src/sliding_windows/min_max_normalizer.rs b/src/sliding_windows/min_max_normalizer.rs new file mode 100644 index 0000000..1d9c327 --- /dev/null +++ b/src/sliding_windows/min_max_normalizer.rs @@ -0,0 +1,443 @@ +//! A sliding Min - Max Normalizer + +use std::{ + collections::VecDeque, + num::NonZeroUsize, +}; + +use getset::CopyGetters; +use num::Float; + +use crate::View; + +/// A sliding Min - Max Normalizer +/// +/// Normalizes values to the [-1, 1] range using the min and max of a sliding +/// window of *past* values. The current value is intentionally excluded from +/// the normalization window to avoid lookahead / data-leakage bias. +/// +/// No output is emitted until the sliding window is completely filled. +/// During warm-up `last()` returns `None`. +#[derive(Clone, Debug, CopyGetters)] +pub struct MinMaxNormalizer { + view: V, + /// The sliding window length + #[getset(get_copy = "pub")] + window_len: NonZeroUsize, + q_vals: VecDeque, + min: T, + max: T, + out: Option, + init: bool, +} + +impl MinMaxNormalizer +where + V: View, + T: Float, +{ + /// Create a new instance with a chained View + /// and a given sliding window length. + pub fn new(view: V, window_len: NonZeroUsize) -> Self { + MinMaxNormalizer { + view, + window_len, + q_vals: VecDeque::with_capacity(window_len.get()), + min: T::zero(), + max: T::zero(), + out: None, + init: true, + } + } +} + +fn extent_queue(q: &VecDeque) -> (T, T) { + let mut min = *q.front().unwrap(); + let mut max = *q.front().unwrap(); + + for i in 1..q.len() { + let val = *q.get(i).unwrap(); + if val > max { + max = val; + } + if val < min { + min = val; + } + } + + (min, max) +} + +impl View for MinMaxNormalizer +where + V: View, + T: Float, +{ + fn update(&mut self, val: T) { + debug_assert!(val.is_finite(), "value must be finite"); + self.view.update(val); + let Some(view_last) = self.view.last() else { + return; + }; + debug_assert!(view_last.is_finite(), "value must be finite"); + + if self.init { + self.init = false; + self.min = view_last; + self.max = view_last; + self.q_vals.push_back(view_last); + // Warm-up: first value goes into the queue but no output yet. + // We need window_len values before min/max are meaningful. + return; + } + + // Only emit output once the window is full. + // Up to this point `out` remains None. + if self.q_vals.len() >= self.window_len.get() { + // Normalize the incoming value against the *previous* window's + // min/max. This avoids lookahead bias. + if self.min == self.max { + self.out = Some(T::zero()); + } else { + self.out = Some( + -T::one() + + ((view_last - self.min) * T::from(2.0).expect("can convert")) + / (self.max - self.min), + ); + } + debug_assert!(self.out.unwrap().is_finite(), "output must be finite"); + + // Slide the window. + let old = self.q_vals.pop_front().expect("Its checked above that the length is >= the non-zero window length, therefore this must be `Some`"); + self.q_vals.push_back(view_last); + + if old <= self.min || old >= self.max { + let (min, max) = extent_queue(&self.q_vals); + self.min = min; + self.max = max; + } else { + if view_last > self.max { + self.max = view_last; + } + if view_last < self.min { + self.min = view_last; + } + } + } else { + // Still filling the window — no output yet. + self.q_vals.push_back(view_last); + if view_last > self.max { + self.max = view_last; + } + if view_last < self.min { + self.min = view_last; + } + } + } + + fn last(&self) -> Option { + self.out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + plot::plot_values, + pure_functions::Echo, + test_data::TEST_DATA, + }; + + #[test] + fn normalizer() { + let mut n = MinMaxNormalizer::new(Echo::new(), NonZeroUsize::new(16).unwrap()); + for v in &TEST_DATA { + n.update(*v); + if let Some(last) = n.last() { + assert!(last.is_finite()); + } + } + } + + #[test] + fn min_max_normalizer_plot() { + let mut n = MinMaxNormalizer::new(Echo::new(), NonZeroUsize::new(16).unwrap()); + let mut out: Vec = Vec::new(); + for v in &TEST_DATA { + n.update(*v); + if let Some(val) = n.last() { + out.push(val); + } + } + let filename = "img/min_max_normalizer.png"; + plot_values(out, filename).unwrap(); + } + + // ── Warm-up tests ── + + /// Outputs must be suppressed until the sliding window is fully filled. + #[test] + fn min_max_normalizer_warmup() { + let mut n = MinMaxNormalizer::new(Echo::new(), NonZeroUsize::new(5).unwrap()); + // First 5 updates: window not yet full → None. + // (The 1st goes through init, 2nd–5th have = (1..=10).map(|i| i as f64 * 10.0).collect(); + // [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] + + for v in &values { + n.update(*v); + } + + // After all updates, the last value (100) was normalized against + // the previous window [60, 70, 80, 90] (min=60, max=90). + // normalized(100) = -1 + 2*(100-60)/(90-60) = -1 + 2*40/30 = 5/3 + let got = n.last().unwrap(); + let expected = 5.0 / 3.0; + let diff = (got - expected).abs(); + assert!( + diff < 1e-12, + "rising sequence last: expected {expected}, got {got}, diff {diff}" + ); + } + + // ── Stale min/max bug tests ── + + /// When the singular minimum leaves the window, the min must be recalculated. + #[test] + fn min_max_normalizer_min_recalculated_when_singular_min_leaves() { + let mut n = MinMaxNormalizer::new(Echo::new(), NonZeroUsize::new(4).unwrap()); + + // Fill the window to warm up. + n.update(1.0); + n.update(100.0); + n.update(100.0); + n.update(100.0); + // Queue: [1, 100, 100, 100], min=1, max=100. + + // Next 100: pops 1. This is normalized against [1,100,100,100] → 1.0. + n.update(100.0); + let _ = n.last(); + // Queue now: [100, 100, 100, 100]. + + // Next 100: normalized against [100,100,100,100] → min=max=100 → 0. + n.update(100.0); + let got = n.last().unwrap(); + assert!( + got.abs() < 1e-12, + "after singular min left + one more update, expected 0.0, got {got}" + ); + } + + /// Symmetric test: when the singular maximum leaves, max must be recalculated. + #[test] + fn min_max_normalizer_max_recalculated_when_singular_max_leaves() { + let mut n = MinMaxNormalizer::new(Echo::new(), NonZeroUsize::new(4).unwrap()); + + // Fill to warm up. + n.update(100.0); + n.update(1.0); + n.update(1.0); + n.update(1.0); + // Queue: [100, 1, 1, 1], min=1, max=100. + + // Next 1: pops 100. Normalized against [100,1,1,1] → -1.0. + n.update(1.0); + let _ = n.last(); + // Queue now: [1, 1, 1, 1]. + + // Next 1: normalized against [1,1,1,1] → min=max=1 → 0. + n.update(1.0); + let got = n.last().unwrap(); + assert!( + got.abs() < 1e-12, + "after singular max left + one more update, expected 0.0, got {got}" + ); + } + + // ── General correctness tests ── + + /// When the window contains identical values, output is always 0. + #[test] + fn min_max_normalizer_identical_values_yield_zero() { + let mut n = MinMaxNormalizer::new(Echo::new(), NonZeroUsize::new(5).unwrap()); + // Warm up: first 5 values produce None. + for _ in 0..5 { + n.update(42.0); + assert!(n.last().is_none(), "warmup should suppress output"); + } + // From the 6th onward, every output should be 0. + for _ in 0..20 { + n.update(42.0); + assert!( + n.last().unwrap().abs() < 1e-12, + "identical values should yield normalized output 0" + ); + } + } + + /// Values at exactly the min normalize to -1.0; at exactly the max to +1.0. + #[test] + fn min_max_normalizer_bounds() { + let mut n = MinMaxNormalizer::new(Echo::new(), NonZeroUsize::new(3).unwrap()); + + // Warm up the window. + n.update(0.0); + n.update(100.0); + n.update(100.0); + // Queue: [0, 100, 100], min=0, max=100. + // After 3rd update we now have a full window, first output coming next. + + // Update with a value at the min. + n.update(0.0); + let got_min = n.last().unwrap(); + assert!( + (got_min - (-1.0)).abs() < 1e-12, + "expected -1.0, got {got_min}" + ); + + // Queue is now [100, 100, 0], min=0, max=100. + // Update with a value at the max. + n.update(100.0); + let got_max = n.last().unwrap(); + assert!((got_max - 1.0).abs() < 1e-12, "expected 1.0, got {got_max}"); + } + + /// Ensure MinMaxNormalizer works when chained after another View. + #[test] + fn min_max_normalizer_chained() { + use crate::sliding_windows::Ema; + let mut n = MinMaxNormalizer::new( + Ema::new(Echo::new(), NonZeroUsize::new(5).unwrap()), + NonZeroUsize::new(8).unwrap(), + ); + for v in &TEST_DATA { + n.update(*v); + if let Some(val) = n.last() { + assert!( + val.is_finite(), + "chained output should be finite, got {val}" + ); + } + } + } + + /// The normalizer should handle the case where window_len == 1. + #[test] + fn min_max_normalizer_window_len_one() { + let mut n = MinMaxNormalizer::new(Echo::new(), NonZeroUsize::new(1).unwrap()); + + // First update goes through init, no output yet. + n.update(5.0); + assert!(n.last().is_none(), "first update: init, no output"); + + // Second update: queue has 1 value, window full → output. + n.update(10.0); + // Normalize 10 against [5] → min=max=5 → 0.0 + assert_eq!(n.last().unwrap(), 0.0); + } + + /// Verify the normalizer produces finite outputs for all test data. + #[test] + fn min_max_normalizer_all_outputs_finite() { + let mut n = MinMaxNormalizer::new(Echo::new(), NonZeroUsize::new(16).unwrap()); + for v in &TEST_DATA { + n.update(*v); + if let Some(out) = n.last() { + assert!(out.is_finite(), "output should be finite, got {out}"); + } + } + } + + /// Regression: the normalizer should not panic and should produce + /// valid output when fed many values. + #[test] + fn min_max_normalizer_stress_test() { + let mut n = MinMaxNormalizer::new(Echo::new(), NonZeroUsize::new(64).unwrap()); + for i in 0..1000 { + let val = (i as f64).sin(); + n.update(val); + if let Some(out) = n.last() { + assert!(out.is_finite(), "output should be finite, got {out}"); + } + } + } +} diff --git a/src/sliding_windows/mod.rs b/src/sliding_windows/mod.rs index e916962..d9690c8 100644 --- a/src/sliding_windows/mod.rs +++ b/src/sliding_windows/mod.rs @@ -9,12 +9,12 @@ mod cumulative; mod cyber_cycle; mod ehlers_fisher_transform; mod ema; -mod hl_normalizer; mod lag; mod laguerre_filter; mod laguerre_rsi; mod max; mod min; +mod min_max_normalizer; mod my_rsi; mod noise_elimination_technology; mod polarized_fractal_efficiency; @@ -37,12 +37,12 @@ pub use cumulative::Cumulative; pub use cyber_cycle::CyberCycle; pub use ehlers_fisher_transform::EhlersFisherTransform; pub use ema::Ema; -pub use hl_normalizer::HLNormalizer; pub use lag::Lag; pub use laguerre_filter::LaguerreFilter; pub use laguerre_rsi::LaguerreRSI; pub use max::Max; pub use min::Min; +pub use min_max_normalizer::MinMaxNormalizer; pub use my_rsi::MyRSI; pub use noise_elimination_technology::NoiseEliminationTechnology; pub use polarized_fractal_efficiency::PolarizedFractalEfficiency;