fix(core): reject non-finite input in 16 pairwise indicators (#251)
## 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
This commit is contained in:
@@ -92,6 +92,9 @@ impl Indicator for Beta {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ impl Indicator for BetaNeutralSpread {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,9 @@ impl Indicator for Cointegration {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<CointegrationOutput> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ impl Indicator for DistanceSsd {
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,9 @@ impl Indicator for GrangerCausality {
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ impl Indicator for HasbrouckInformationShare {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,9 @@ impl Indicator for KendallTau {
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,9 @@ impl Indicator for LeadLagCrossCorrelation {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<LeadLagCrossCorrelationOutput> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ impl Indicator for OuHalfLife {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@ impl Indicator for PearsonCorrelation {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@ impl Indicator for RollingCorrelation {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ impl Indicator for RollingCovariance {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,9 @@ impl Indicator for SpearmanCorrelation {
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@ impl Indicator for SpreadAr1Coefficient {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ impl Indicator for SpreadHurst {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ impl Indicator for VarianceRatio {
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user