From 99497eb0626acf8fe4c8b492a0d38fd4bd2cbf3c Mon Sep 17 00:00:00 2001 From: kingchenc Date: Thu, 11 Jun 2026 03:01:24 +0200 Subject: [PATCH] fix(core): reject non-finite input in 16 pairwise indicators (#251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem 16 of the 24 pairwise indicators (`type Input = (f64, f64)`) accepted non-finite input unchecked. A single NaN/Inf tick produced a NaN reading, contradicting the streaming-robustness guarantee that *a single bad tick cannot silently poison state* (README, `ARCHITECTURE.md`). The other 8 pairwise indicators (`Alpha`, `InformationRatio`, `KalmanHedgeRatio`, `PairSpreadZScore`, `PairwiseBeta`, `RelativeStrengthAb`, `SpreadBollingerBands`, `TreynorRatio`) already guard finite input and are untouched. Scalar (169 files) and candle (`Candle::new`) inputs were already protected. ## Severity split - **7 running-sum indicators** \(permanent corruption — NaN entered `Σ` and stayed until `reset()`\): `Beta`, `BetaNeutralSpread`, `Cointegration`, `HasbrouckInformationShare`, `PearsonCorrelation`, `RollingCorrelation`, `RollingCovariance`. - **9 buffer-recompute indicators** \(transient — NaN cleared once evicted, but still surfaced in the reading\): `DistanceSsd`, `GrangerCausality`, `KendallTau`, `LeadLagCrossCorrelation`, `OuHalfLife`, `SpearmanCorrelation`, `SpreadAr1Coefficient`, `SpreadHurst`, `VarianceRatio`. ## Fix Add the established finite-input guard (the same pattern `Alpha` already uses) as the first step of `update()`, before any window or sum mutation. Signature unchanged → bindings re-export unchanged, no binding regen needed; core-only. Each indicator gains a `non_finite_input_returns_none` test that exercises the guard (NaN and Inf, covering both `||` operands) and proves a clean warmup is unaffected by the rejected ticks. Split into two commits by severity class. ## Verification - `cargo fmt --all` clean - `cargo test --workspace --all-features` — 4225 core tests pass (+16 new) - `cargo clippy --workspace --all-targets --all-features -- -D warnings` clean --- crates/wickra-core/src/indicators/beta.rs | 14 ++++++++++++++ .../src/indicators/beta_neutral_spread.rs | 14 ++++++++++++++ .../wickra-core/src/indicators/cointegration.rs | 15 +++++++++++++++ .../wickra-core/src/indicators/distance_ssd.rs | 14 ++++++++++++++ .../src/indicators/granger_causality.rs | 15 +++++++++++++++ .../indicators/hasbrouck_information_share.rs | 14 ++++++++++++++ crates/wickra-core/src/indicators/kendall_tau.rs | 13 +++++++++++++ .../src/indicators/lead_lag_cross_correlation.rs | 16 ++++++++++++++++ .../wickra-core/src/indicators/ou_half_life.rs | 15 +++++++++++++++ .../src/indicators/pearson_correlation.rs | 14 ++++++++++++++ .../src/indicators/rolling_correlation.rs | 14 ++++++++++++++ .../src/indicators/rolling_covariance.rs | 14 ++++++++++++++ .../src/indicators/spearman_correlation.rs | 13 +++++++++++++ .../src/indicators/spread_ar1_coefficient.rs | 15 +++++++++++++++ .../wickra-core/src/indicators/spread_hurst.rs | 15 +++++++++++++++ .../wickra-core/src/indicators/variance_ratio.rs | 15 +++++++++++++++ 16 files changed, 230 insertions(+) diff --git a/crates/wickra-core/src/indicators/beta.rs b/crates/wickra-core/src/indicators/beta.rs index 95763049..3d7be811 100644 --- a/crates/wickra-core/src/indicators/beta.rs +++ b/crates/wickra-core/src/indicators/beta.rs @@ -92,6 +92,9 @@ impl Indicator for Beta { fn update(&mut self, input: (f64, f64)) -> Option { let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } if self.window.len() == self.period { let (oa, ob) = self.window.pop_front().expect("non-empty"); self.sum_a -= oa; @@ -225,4 +228,15 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut b = Beta::new(3).unwrap(); + assert_eq!(b.update((f64::NAN, 1.0)), None); + assert_eq!(b.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(b.update((1.0, 2.0)), None); + assert_eq!(b.update((2.0, 5.0)), None); + assert!(b.update((3.0, 7.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/beta_neutral_spread.rs b/crates/wickra-core/src/indicators/beta_neutral_spread.rs index 52d8e882..8d43bd86 100644 --- a/crates/wickra-core/src/indicators/beta_neutral_spread.rs +++ b/crates/wickra-core/src/indicators/beta_neutral_spread.rs @@ -88,6 +88,9 @@ impl Indicator for BetaNeutralSpread { fn update(&mut self, input: (f64, f64)) -> Option { let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } if self.window.len() == self.period { let (oa, ob) = self.window.pop_front().expect("non-empty"); self.sum_a -= oa; @@ -244,4 +247,15 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| s.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut s = BetaNeutralSpread::new(3).unwrap(); + assert_eq!(s.update((f64::NAN, 1.0)), None); + assert_eq!(s.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(s.update((1.0, 2.0)), None); + assert_eq!(s.update((2.0, 5.0)), None); + assert!(s.update((3.0, 7.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/cointegration.rs b/crates/wickra-core/src/indicators/cointegration.rs index f307b0d8..dfee22b2 100644 --- a/crates/wickra-core/src/indicators/cointegration.rs +++ b/crates/wickra-core/src/indicators/cointegration.rs @@ -116,6 +116,9 @@ impl Indicator for Cointegration { fn update(&mut self, input: (f64, f64)) -> Option { let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } if self.window.len() == self.period { let (oa, ob) = self.window.pop_front().expect("non-empty"); self.sum_a -= oa; @@ -443,4 +446,16 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| c.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut c = Cointegration::new(4, 0).unwrap(); + assert_eq!(c.update((f64::NAN, 1.0)), None); + assert_eq!(c.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(c.update((1.0, 2.0)), None); + assert_eq!(c.update((2.0, 5.0)), None); + assert_eq!(c.update((3.0, 7.0)), None); + assert!(c.update((4.0, 11.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/distance_ssd.rs b/crates/wickra-core/src/indicators/distance_ssd.rs index f338e278..ac0db62e 100644 --- a/crates/wickra-core/src/indicators/distance_ssd.rs +++ b/crates/wickra-core/src/indicators/distance_ssd.rs @@ -76,6 +76,9 @@ impl Indicator for DistanceSsd { type Output = f64; fn update(&mut self, input: (f64, f64)) -> Option { + if !input.0.is_finite() || !input.1.is_finite() { + return None; + } if self.window.len() == self.period { self.window.pop_front(); } @@ -232,4 +235,15 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| d.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut d = DistanceSsd::new(3).unwrap(); + assert_eq!(d.update((f64::NAN, 1.0)), None); + assert_eq!(d.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(d.update((1.0, 1.0)), None); + assert_eq!(d.update((2.0, 4.0)), None); + assert!(d.update((3.0, 9.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/granger_causality.rs b/crates/wickra-core/src/indicators/granger_causality.rs index 30c48517..27636198 100644 --- a/crates/wickra-core/src/indicators/granger_causality.rs +++ b/crates/wickra-core/src/indicators/granger_causality.rs @@ -96,6 +96,9 @@ impl Indicator for GrangerCausality { type Output = f64; fn update(&mut self, input: (f64, f64)) -> Option { + if !input.0.is_finite() || !input.1.is_finite() { + return None; + } if self.window.len() == self.period { self.window.pop_front(); } @@ -332,4 +335,16 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| g.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut g = GrangerCausality::new(5, 1).unwrap(); + assert_eq!(g.update((f64::NAN, 1.0)), None); + assert_eq!(g.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + for t in 0..4 { + assert_eq!(g.update((f64::from(t), f64::from(t) * 0.5)), None); + } + assert!(g.update((4.0, 2.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/hasbrouck_information_share.rs b/crates/wickra-core/src/indicators/hasbrouck_information_share.rs index a74e2c54..a7ea3d1f 100644 --- a/crates/wickra-core/src/indicators/hasbrouck_information_share.rs +++ b/crates/wickra-core/src/indicators/hasbrouck_information_share.rs @@ -87,6 +87,9 @@ impl Indicator for HasbrouckInformationShare { fn update(&mut self, input: (f64, f64)) -> Option { let (x, y) = input; + if !x.is_finite() || !y.is_finite() { + return None; + } let Some((px, py)) = self.prev else { self.prev = Some((x, y)); return None; @@ -248,4 +251,15 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut h = HasbrouckInformationShare::new(2).unwrap(); + assert_eq!(h.update((f64::NAN, 1.0)), None); + assert_eq!(h.update((1.0, f64::INFINITY)), None); + // First finite tick seeds prev; two more returns fill the window. + assert_eq!(h.update((1.0, 1.0)), None); + assert_eq!(h.update((2.0, 3.0)), None); + assert!(h.update((3.0, 4.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/kendall_tau.rs b/crates/wickra-core/src/indicators/kendall_tau.rs index ccf4dc2f..6c9d8612 100644 --- a/crates/wickra-core/src/indicators/kendall_tau.rs +++ b/crates/wickra-core/src/indicators/kendall_tau.rs @@ -130,6 +130,9 @@ impl Indicator for KendallTau { type Output = f64; fn update(&mut self, input: (f64, f64)) -> Option { + if !input.0.is_finite() || !input.1.is_finite() { + return None; + } if self.window.len() == self.period { self.window.pop_front(); } @@ -293,4 +296,14 @@ mod tests { let v = k.update((3.0, 3.0)).unwrap(); assert!((-1.0..=1.0).contains(&v), "got {v}"); } + + #[test] + fn non_finite_input_returns_none() { + let mut k = KendallTau::new(2).unwrap(); + assert_eq!(k.update((f64::NAN, 1.0)), None); + assert_eq!(k.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(k.update((1.0, 2.0)), None); + assert!(k.update((2.0, 5.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs b/crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs index dad02f84..2a303f5f 100644 --- a/crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs +++ b/crates/wickra-core/src/indicators/lead_lag_cross_correlation.rs @@ -152,6 +152,9 @@ impl Indicator for LeadLagCrossCorrelation { fn update(&mut self, input: (f64, f64)) -> Option { let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } if self.a_buf.len() == self.len { self.a_buf.pop_front(); self.b_buf.pop_front(); @@ -321,4 +324,17 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| ll.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + // len = window + 2*max_lag = 2 + 2 = 4 finite ticks fill the buffers. + let mut ll = LeadLagCrossCorrelation::new(2, 1).unwrap(); + assert_eq!(ll.update((f64::NAN, 1.0)), None); + assert_eq!(ll.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(ll.update((1.0, 2.0)), None); + assert_eq!(ll.update((2.0, 1.0)), None); + assert_eq!(ll.update((3.0, 4.0)), None); + assert!(ll.update((4.0, 2.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/ou_half_life.rs b/crates/wickra-core/src/indicators/ou_half_life.rs index c4ccb10d..c9c40bc0 100644 --- a/crates/wickra-core/src/indicators/ou_half_life.rs +++ b/crates/wickra-core/src/indicators/ou_half_life.rs @@ -80,6 +80,9 @@ impl Indicator for OuHalfLife { fn update(&mut self, input: (f64, f64)) -> Option { let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } if self.window.len() == self.period { self.window.pop_front(); } @@ -242,4 +245,16 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| hl.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut hl = OuHalfLife::new(4).unwrap(); + assert_eq!(hl.update((f64::NAN, 1.0)), None); + assert_eq!(hl.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(hl.update((1.0, 0.0)), None); + assert_eq!(hl.update((2.0, 0.0)), None); + assert_eq!(hl.update((3.0, 0.0)), None); + assert!(hl.update((4.0, 0.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/pearson_correlation.rs b/crates/wickra-core/src/indicators/pearson_correlation.rs index bc0f7fe7..39a6d573 100644 --- a/crates/wickra-core/src/indicators/pearson_correlation.rs +++ b/crates/wickra-core/src/indicators/pearson_correlation.rs @@ -89,6 +89,9 @@ impl Indicator for PearsonCorrelation { fn update(&mut self, input: (f64, f64)) -> Option { let (x, y) = input; + if !x.is_finite() || !y.is_finite() { + return None; + } if self.window.len() == self.period { let (ox, oy) = self.window.pop_front().expect("non-empty"); self.sum_x -= ox; @@ -243,4 +246,15 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut p = PearsonCorrelation::new(3).unwrap(); + assert_eq!(p.update((f64::NAN, 1.0)), None); + assert_eq!(p.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(p.update((1.0, 2.0)), None); + assert_eq!(p.update((2.0, 5.0)), None); + assert!(p.update((3.0, 7.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/rolling_correlation.rs b/crates/wickra-core/src/indicators/rolling_correlation.rs index b6d53487..50e173f9 100644 --- a/crates/wickra-core/src/indicators/rolling_correlation.rs +++ b/crates/wickra-core/src/indicators/rolling_correlation.rs @@ -90,6 +90,9 @@ impl Indicator for RollingCorrelation { fn update(&mut self, input: (f64, f64)) -> Option { let (x, y) = input; + if !x.is_finite() || !y.is_finite() { + return None; + } let Some((px, py)) = self.prev else { // First level in each channel: store it, no return yet. self.prev = Some((x, y)); @@ -272,4 +275,15 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut rc = RollingCorrelation::new(2).unwrap(); + assert_eq!(rc.update((f64::NAN, 1.0)), None); + assert_eq!(rc.update((1.0, f64::INFINITY)), None); + // First finite tick seeds prev; two more returns fill the window. + assert_eq!(rc.update((1.0, 1.0)), None); + assert_eq!(rc.update((2.0, 3.0)), None); + assert!(rc.update((3.0, 5.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/rolling_covariance.rs b/crates/wickra-core/src/indicators/rolling_covariance.rs index 42e67c96..80b8b5cc 100644 --- a/crates/wickra-core/src/indicators/rolling_covariance.rs +++ b/crates/wickra-core/src/indicators/rolling_covariance.rs @@ -86,6 +86,9 @@ impl Indicator for RollingCovariance { fn update(&mut self, input: (f64, f64)) -> Option { let (x, y) = input; + if !x.is_finite() || !y.is_finite() { + return None; + } let Some((px, py)) = self.prev else { self.prev = Some((x, y)); return None; @@ -240,4 +243,15 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut rc = RollingCovariance::new(2).unwrap(); + assert_eq!(rc.update((f64::NAN, 1.0)), None); + assert_eq!(rc.update((1.0, f64::INFINITY)), None); + // First finite tick seeds prev; two more returns fill the window. + assert_eq!(rc.update((1.0, 1.0)), None); + assert_eq!(rc.update((2.0, 3.0)), None); + assert!(rc.update((3.0, 5.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/spearman_correlation.rs b/crates/wickra-core/src/indicators/spearman_correlation.rs index 4eee492c..688c391f 100644 --- a/crates/wickra-core/src/indicators/spearman_correlation.rs +++ b/crates/wickra-core/src/indicators/spearman_correlation.rs @@ -123,6 +123,9 @@ impl Indicator for SpearmanCorrelation { type Output = f64; fn update(&mut self, input: (f64, f64)) -> Option { + if !input.0.is_finite() || !input.1.is_finite() { + return None; + } if self.window.len() == self.period { self.window.pop_front(); } @@ -311,4 +314,14 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut s = SpearmanCorrelation::new(2).unwrap(); + assert_eq!(s.update((f64::NAN, 1.0)), None); + assert_eq!(s.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(s.update((1.0, 2.0)), None); + assert!(s.update((2.0, 5.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/spread_ar1_coefficient.rs b/crates/wickra-core/src/indicators/spread_ar1_coefficient.rs index 3f2d89ea..fbfbfca2 100644 --- a/crates/wickra-core/src/indicators/spread_ar1_coefficient.rs +++ b/crates/wickra-core/src/indicators/spread_ar1_coefficient.rs @@ -89,6 +89,9 @@ impl Indicator for SpreadAr1Coefficient { fn update(&mut self, input: (f64, f64)) -> Option { let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } if self.window.len() == self.period { self.window.pop_front(); } @@ -248,4 +251,16 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| ar1.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut ar1 = SpreadAr1Coefficient::new(4).unwrap(); + assert_eq!(ar1.update((f64::NAN, 1.0)), None); + assert_eq!(ar1.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(ar1.update((1.0, 0.0)), None); + assert_eq!(ar1.update((2.0, 0.0)), None); + assert_eq!(ar1.update((3.0, 0.0)), None); + assert!(ar1.update((4.0, 0.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/spread_hurst.rs b/crates/wickra-core/src/indicators/spread_hurst.rs index 8182fe97..ef5784ac 100644 --- a/crates/wickra-core/src/indicators/spread_hurst.rs +++ b/crates/wickra-core/src/indicators/spread_hurst.rs @@ -84,6 +84,9 @@ impl Indicator for SpreadHurst { fn update(&mut self, input: (f64, f64)) -> Option { let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } if self.window.len() == self.period { self.window.pop_front(); } @@ -268,4 +271,16 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut h = SpreadHurst::new(8).unwrap(); + assert_eq!(h.update((f64::NAN, 1.0)), None); + assert_eq!(h.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + for t in 0..7 { + assert_eq!(h.update((f64::from(t), 0.0)), None); + } + assert!(h.update((7.0, 0.0)).is_some()); + } } diff --git a/crates/wickra-core/src/indicators/variance_ratio.rs b/crates/wickra-core/src/indicators/variance_ratio.rs index e4731c63..5be115ea 100644 --- a/crates/wickra-core/src/indicators/variance_ratio.rs +++ b/crates/wickra-core/src/indicators/variance_ratio.rs @@ -98,6 +98,9 @@ impl Indicator for VarianceRatio { fn update(&mut self, input: (f64, f64)) -> Option { let (a, b) = input; + if !a.is_finite() || !b.is_finite() { + return None; + } if self.window.len() == self.period { self.window.pop_front(); } @@ -264,4 +267,16 @@ mod tests { let streamed: Vec<_> = pairs.iter().map(|p| vr.update(*p)).collect(); assert_eq!(batch, streamed); } + + #[test] + fn non_finite_input_returns_none() { + let mut vr = VarianceRatio::new(4, 2).unwrap(); + assert_eq!(vr.update((f64::NAN, 1.0)), None); + assert_eq!(vr.update((1.0, f64::INFINITY)), None); + // The rejected ticks leave no trace: a fresh window still warms up. + assert_eq!(vr.update((1.0, 0.0)), None); + assert_eq!(vr.update((2.0, 0.0)), None); + assert_eq!(vr.update((3.0, 0.0)), None); + assert!(vr.update((4.0, 0.0)).is_some()); + } }