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:
kingchenc
2026-06-11 03:01:24 +02:00
committed by GitHub
parent 7e51f31f02
commit 99497eb062
16 changed files with 230 additions and 0 deletions
+14
View File
@@ -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());
}
}