test(core): catalogue-wide invariant harness + guard non-finite input in 38 indicators (#254)

Adds `crates/wickra-core/tests/invariants.rs` — a property-based (`proptest`) harness asserting three invariants for **every** indicator and bar-builder in the catalogue (every module entry implementing `Indicator` or `BarBuilder`; the lone non-indicator helper `pattern_swing`/`SwingTracker` is excluded by design) — and fixes the non-finite bugs the harness surfaced.

## Invariants
1. **batch == streaming** — `batch()` must replay `update()` exactly.
2. **reset == fresh** — after `reset()`, re-feeding the same data matches a fresh instance.
3. **non-finite rejected without poisoning** (`f64` / `(f64, f64)` families) — a NaN/inf tick returns `None` and leaves state identical to never having seen it.

## How it works
- A single generic `check_seq<I: Indicator>` covers **all** input families — `f64`, `Candle`, `(f64, f64)`, and the exotic `CrossSection`, `Trade`, `DerivativesTick`, `OrderBook`, `TradeQuote` — via per-family `proptest` generators that produce *valid* inputs (e.g. order books are strictly monotonic and uncrossed).
- `check_bars<B: BarBuilder>` covers the bar-builder trait.
- Outputs are compared by `Debug` string so bit-identical `NaN` outputs count as equal — these properties test **determinism**, not NaN-freeness.

## The 38 non-finite fixes
The harness surfaced **38 more** scalar/pairwise indicators that let a NaN/inf tick poison their state — the same class as the 16 pairwise indicators fixed earlier (#251), but missed by the grep-based audit (`kalman_hedge_ratio` and `spread_bollinger_bands` carried `is_finite` on their **constructor** params, not the update input). All 38 now reject non-finite input via the established first-statement guard; signatures unchanged, so every binding inherits the fix. With these in, the non-finite invariant is enforced for every `f64`/`(f64,f64)` indicator going forward — the permanent regression net that would have caught #251.

Warmup-exactness was evaluated and **left out** (not universal — many multi-component/candlestick indicators emit before `warmup_period` by design).

## Verification
- `cargo test -p wickra-core`: 4225 unit + harness + 464 doc/integration, all green.
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: clean.
- Coverage runs integration tests, so the new guard branches are exercised by the harness's NaN feed.

Also documents the harness in README's Testing section and adds the pending 0.8.4 entries to CHANGELOG `[Unreleased]`.
This commit is contained in:
kingchenc
2026-06-11 14:51:46 +02:00
committed by GitHub
parent cb216668ee
commit 9973d1a6bf
41 changed files with 1222 additions and 1 deletions
+29
View File
@@ -5,6 +5,35 @@ All notable changes to Wickra are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- A single non-finite (NaN/inf) tick no longer poisons indicator state.
The 16 pairwise running-sum/buffer indicators fixed first (`Beta`,
`BetaNeutralSpread`, `Cointegration`, `HasbrouckInformationShare`,
`PearsonCorrelation`, `RollingCorrelation`, `RollingCovariance`,
`DistanceSsd`, `GrangerCausality`, `KendallTau`, `LeadLagCrossCorrelation`,
`OuHalfLife`, `SpearmanCorrelation`, `SpreadAr1Coefficient`, `SpreadHurst`,
`VarianceRatio`) were joined by 38 more scalar/pairwise indicators the new
property harness surfaced (the linear-regression family, rolling quantiles
and IQR, `Variance`/`StdDev`-derived stats, `Kurtosis`/`Skewness`, the
trailing stops, `KalmanHedgeRatio`, `SpreadBollingerBands`, and more). Every
`f64` / `(f64, f64)` indicator now rejects non-finite input and returns
`None`, matching the streaming-robustness guarantee — and the harness enforces
it going forward.
### Added
- Catalogue-wide property-based invariant harness
(`crates/wickra-core/tests/invariants.rs`) asserting `batch == streaming`,
`reset == fresh`, and non-finite-input rejection for every indicator and
bar-builder.
### Changed
- CI: every job now has a runtime cap and the historically flaky Node test step
auto-retries, so a wedged runner fails fast instead of hanging for hours.
- Documentation accuracy fixes in `SECURITY.md`, `ARCHITECTURE.md`, and
`THREAT_MODEL.md` (supported version, indicator count, WASM test coverage,
numerical-stability notes, and the C-ABI panic strategy).
## [0.8.3] - 2026-06-10
### Added
- **Per-binding throughput benchmarks** — every target now ships a `throughput`
+4 -1
View File
@@ -334,7 +334,10 @@ Every layer is covered; run the suites with the commands in
- `wickra-core`: unit tests per indicator — textbook reference values
(Wilder RSI, Bollinger Bands, MACD, ATR, Stochastic), `batch == streaming`
equivalence, `reset` semantics, NaN/Inf handling, and property tests.
equivalence, `reset` semantics, NaN/Inf handling, and property tests. A
catalogue-wide property harness (`tests/invariants.rs`) additionally asserts
`batch == streaming`, `reset == fresh`, and non-finite-input rejection for
**every** indicator and bar-builder.
- `wickra-data`: unit tests for CSV decoding, the tick aggregator, the
resampler, and the Binance payload parser.
- `bindings/python`: pytest covering smoke checks, streaming/batch
@@ -84,6 +84,9 @@ impl Indicator for Autocorrelation {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -106,6 +106,9 @@ impl Indicator for BomarBands {
type Output = BomarBandsOutput;
fn update(&mut self, value: f64) -> Option<BomarBandsOutput> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
+3
View File
@@ -60,6 +60,9 @@ impl Indicator for Cfo {
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
let forecast = self.linreg.update(input)?;
// Hold the previous value if the close is zero — the percentage form
// is undefined and a return of inf would propagate badly.
@@ -71,6 +71,9 @@ impl Indicator for CoefficientOfVariation {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
@@ -96,6 +96,9 @@ impl Indicator for DetrendedStdDev {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
@@ -77,6 +77,9 @@ impl Indicator for Expectancy {
type Output = f64;
fn update(&mut self, ret: f64) -> Option<f64> {
if !ret.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
@@ -138,6 +138,9 @@ impl Indicator for HurstExponent {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -116,6 +116,9 @@ impl Indicator for KalmanHedgeRatio {
fn update(&mut self, input: (f64, f64)) -> Option<KalmanHedgeRatioOutput> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
// Predicted state covariance: add the transition noise to the diagonal
// (the very first observation starts from a zero prior).
let mut cov_pred = self.cov;
@@ -80,6 +80,9 @@ impl Indicator for Kurtosis {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
let sq = old * old;
@@ -99,6 +99,9 @@ impl Indicator for LinearRegression {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
// Sliding phase: pop the oldest, then shift every remaining index
// down by 1 in the running `sum_xy`. The identity
@@ -57,6 +57,9 @@ impl Indicator for LinRegAngle {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
self.slope.update(value).map(|s| s.atan().to_degrees())
}
@@ -98,6 +98,9 @@ impl Indicator for LinRegChannel {
type Output = LinRegChannelOutput;
fn update(&mut self, value: f64) -> Option<LinRegChannelOutput> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -77,6 +77,9 @@ impl Indicator for LinRegIntercept {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
@@ -87,6 +87,9 @@ impl Indicator for LinRegSlope {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
// Sliding-window identity: when the window slides one step forward
// the indices `x` for every kept entry shift down by 1, so
@@ -89,6 +89,9 @@ impl Indicator for MedianAbsoluteDeviation {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -96,6 +96,9 @@ impl Indicator for MedianChannel {
type Output = MedianChannelOutput;
fn update(&mut self, value: f64) -> Option<MedianChannelOutput> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -59,6 +59,9 @@ impl Indicator for MidPoint {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -74,6 +74,9 @@ impl Indicator for PercentageTrailingStop {
type Output = f64;
fn update(&mut self, close: f64) -> Option<f64> {
if !close.is_finite() {
return None;
}
let step = close.abs() * self.percent / 100.0;
let stop = match self.prev_stop {
Some(prev) => {
@@ -82,6 +82,9 @@ impl Indicator for PolarizedFractalEfficiency {
type Output = f64;
fn update(&mut self, close: f64) -> Option<f64> {
if !close.is_finite() {
return None;
}
if let Some(prev) = self.prev_close {
let diff = close - prev;
let segment = diff.mul_add(diff, 1.0).sqrt();
@@ -80,6 +80,9 @@ impl Indicator for QuartileBands {
type Output = QuartileBandsOutput;
fn update(&mut self, value: f64) -> Option<QuartileBandsOutput> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -95,6 +95,9 @@ impl Indicator for RSquared {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
@@ -74,6 +74,9 @@ impl Indicator for RenkoTrailingStop {
type Output = f64;
fn update(&mut self, close: f64) -> Option<f64> {
if !close.is_finite() {
return None;
}
let anchor = match self.anchor {
Some(prev) => {
if self.long {
@@ -71,6 +71,9 @@ impl Indicator for RollingIqr {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -70,6 +70,9 @@ impl Indicator for RollingPercentileRank {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -104,6 +104,9 @@ impl Indicator for RollingQuantile {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -79,6 +79,9 @@ impl Indicator for Skewness {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
@@ -116,6 +116,9 @@ impl Indicator for SpreadBollingerBands {
fn update(&mut self, input: (f64, f64)) -> Option<SpreadBollingerBandsOutput> {
let (a, b) = input;
if !a.is_finite() || !b.is_finite() {
return None;
}
let spread = a - b;
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
@@ -93,6 +93,9 @@ impl Indicator for StandardError {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
// Slide: pop oldest, shift indices, then push the new value at index n 1.
let y0 = self.window.pop_front().expect("non-empty");
@@ -106,6 +106,9 @@ impl Indicator for StandardErrorBands {
type Output = StandardErrorBandsOutput;
fn update(&mut self, value: f64) -> Option<StandardErrorBandsOutput> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -84,6 +84,9 @@ impl Indicator for StepTrailingStop {
type Output = f64;
fn update(&mut self, close: f64) -> Option<f64> {
if !close.is_finite() {
return None;
}
let stop = match self.prev_stop {
Some(prev) => {
if self.long {
@@ -72,6 +72,9 @@ impl Indicator for TrendLabel {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
self.window.pop_front();
}
@@ -75,6 +75,9 @@ impl Indicator for TrendStrengthIndex {
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return None;
}
self.buf.push_back(price);
if self.buf.len() > self.period {
self.buf.pop_front();
+3
View File
@@ -80,6 +80,9 @@ impl Indicator for Tsf {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
@@ -73,6 +73,9 @@ impl Indicator for TsfOscillator {
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return None;
}
let forecast = self.tsf.update(input)?;
// Hold the previous value if the close is zero — the percentage form
// is undefined and a return of inf would propagate badly.
@@ -70,6 +70,9 @@ impl Indicator for Variance {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
@@ -68,6 +68,9 @@ impl Indicator for VerticalHorizontalFilter {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.closes.len() == self.period {
self.closes.pop_front();
}
@@ -66,6 +66,9 @@ impl Indicator for WinRate {
type Output = f64;
fn update(&mut self, ret: f64) -> Option<f64> {
if !ret.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
if old > 0.0 {
@@ -66,6 +66,9 @@ impl Indicator for ZScore {
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
File diff suppressed because it is too large Load Diff