From b003321562bb31bdd0f73c744d49c760555ae71e Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 23 May 2026 10:33:05 +0200 Subject: [PATCH] test(fuzz): cover every indicator, scalar and candle inputs (R9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fuzz suite previously covered only `Rsi(14)` and `Ema(20)` — 2 of 71 indicators, no OHLCV coverage at all. Audit finding R9 asked for ATR/ADX/Stochastic/PSAR as a minimum; this commit goes further and brings every indicator under fuzz. - `indicator_update` (rewritten): drives every scalar-input indicator through one streaming pass + one batch call per iteration. Covers SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, KAMA, T3, MOM, CMO, TSI, PMO, StochRSI, DPO, PPO, Coppock, StdDev, UlcerIndex, HistoricalVolatility, LinearRegression, LinRegSlope, LinRegAngle, VHF, ZScore, MACD, BollingerBands. A `drive` helper marked `#[inline(never)]` keeps each indicator on its own panic backtrace frame. - `indicator_update_candle` (new): chunks the fuzz `f64` stream into `[open, high, low, close, volume]` tuples, builds candles via `Candle::new` (skipping ones that fail OHLCV validation — that path is fuzz-tested separately), then drives every candle-input indicator through streaming + batch. Covers ATR, NATR, TrueRange, ChaikinVolatility, Keltner, Donchian, PSAR, SuperTrend, ChandelierExit, ChandeKrollStop, ATRTrailingStop, ADX, Aroon, AroonOscillator, Vortex, MassIndex, ChoppinessIndex, CCI, WilliamsR, AwesomeOscillator, AcceleratorOscillator, UltimateOscillator, BalanceOfPower, OBV, MFI, VWAP, RollingVWAP, VWMA, ADL, VPT, CMF, ChaikinOscillator, ForceIndex, EaseOfMovement, TypicalPrice, MedianPrice, WeightedClose, Stochastic. - `fuzz/Cargo.toml` registers the new target; `fuzz/README.md` describes both expanded targets. - A `fuzz-smoke` CI job runs each of the five targets for 30 s on every push and pull-request — enough to catch a regression in the harness without slowing CI to a crawl. Long fuzz campaigns belong on dedicated infrastructure with persistent corpora. --- .github/workflows/ci.yml | 44 +++++++ CHANGELOG.md | 11 ++ fuzz/Cargo.toml | 7 + fuzz/README.md | 4 +- fuzz/fuzz_targets/indicator_update.rs | 96 ++++++++++++-- fuzz/fuzz_targets/indicator_update_candle.rs | 130 +++++++++++++++++++ 6 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 fuzz/fuzz_targets/indicator_update_candle.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2abeb635..f906c8ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,6 +134,50 @@ jobs: with: command: check + # Time-boxed fuzz smoke. Each target runs for ~30 s with libfuzzer; any panic + # fails the job. The goal is to catch a regression in the harness (e.g. a + # newly added indicator that panics on a particular input shape), not to + # discover novel bugs — long fuzz campaigns should be run on dedicated + # infrastructure with persistent corpora. + fuzz-smoke: + name: Fuzz (smoke) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Install nightly Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27 + with: + toolchain: nightly + + - name: Cache cargo + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: fuzz + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + + - name: Fuzz csv_reader (30 s) + run: cargo +nightly fuzz run csv_reader -- -max_total_time=30 + working-directory: fuzz + + - name: Fuzz binance_envelope (30 s) + run: cargo +nightly fuzz run binance_envelope -- -max_total_time=30 + working-directory: fuzz + + - name: Fuzz indicator_update (30 s) + run: cargo +nightly fuzz run indicator_update -- -max_total_time=30 + working-directory: fuzz + + - name: Fuzz indicator_update_candle (30 s) + run: cargo +nightly fuzz run indicator_update_candle -- -max_total_time=30 + working-directory: fuzz + + - name: Fuzz tick_aggregator (30 s) + run: cargo +nightly fuzz run tick_aggregator -- -max_total_time=30 + working-directory: fuzz + python: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e1be9277..20217a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 site makes the invariant explicit. ### Changed +- Fuzz suite expanded from 2 indicators to the full catalogue (audit finding + R9). The existing `indicator_update` target now exercises every scalar-input + indicator (~33 classes including MACD and Bollinger Bands); a new + `indicator_update_candle` target exercises every candle-input indicator (~37 + classes, including ATR, ADX, Stochastic, PSAR, Keltner, SuperTrend, + ChandelierExit, AwesomeOscillator, OBV, MFI, VWAP, RollingVWAP, and the rest + of the volume / volatility / trailing-stop / price-statistics families). Each + iteration sweeps every indicator through both the streaming `update` loop + and a full `batch` call so any state-mutation bug surfaces on either path. + CI gains a `fuzz-smoke` job that runs each of the five targets for 30 s on + every push and pull-request. - `UlcerIndex::update` now tracks the trailing maximum with a monotonically- decreasing deque of `(index, price)` pairs instead of scanning the whole trailing window on every tick. The indicator now honours the `Indicator` diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index dab3fbba..9c36c6af 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -38,6 +38,13 @@ test = false doc = false bench = false +[[bin]] +name = "indicator_update_candle" +path = "fuzz_targets/indicator_update_candle.rs" +test = false +doc = false +bench = false + [[bin]] name = "tick_aggregator" path = "fuzz_targets/tick_aggregator.rs" diff --git a/fuzz/README.md b/fuzz/README.md index 5ac4d323..28fff929 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -17,7 +17,8 @@ rustup toolchain install nightly | --- | --- | | `csv_reader` | `CandleReader` over arbitrary bytes — headers, cells, BOM, binary noise. | | `binance_envelope` | `RawWsEnvelope` deserialization from arbitrary strings. | -| `indicator_update` | RSI / EMA streaming + batch over arbitrary `f64` sequences (NaN, ±inf, jumps). | +| `indicator_update` | Every scalar-input indicator (SMA / EMA / WMA / RSI / DEMA / TEMA / HMA / ROC / TRIX / SMMA / TRIMA / ZLEMA / KAMA / T3 / MOM / CMO / TSI / PMO / StochRSI / DPO / PPO / Coppock / StdDev / UlcerIndex / HistoricalVolatility / LinearRegression / LinRegSlope / LinRegAngle / VHF / ZScore / MACD / Bollinger) streamed + batched over arbitrary `f64` sequences (NaN, ±inf, jumps). | +| `indicator_update_candle` | Every candle-input indicator (ATR, NATR, TrueRange, ChaikinVolatility, Keltner, Donchian, PSAR, SuperTrend, ChandelierExit, ChandeKrollStop, ATRTrailingStop, ADX, Aroon, AroonOscillator, Vortex, MassIndex, ChoppinessIndex, CCI, WilliamsR, AwesomeOscillator, AcceleratorOscillator, UltimateOscillator, BalanceOfPower, OBV, MFI, VWAP, RollingVWAP, VWMA, ADL, VPT, CMF, ChaikinOscillator, ForceIndex, EaseOfMovement, TypicalPrice, MedianPrice, WeightedClose, Stochastic) streamed + batched over fuzz-derived OHLCV candles. | | `tick_aggregator` | `TickAggregator` over arbitrary `(price, volume, timestamp)` triples. | ## Run @@ -27,6 +28,7 @@ rustup toolchain install nightly cargo +nightly fuzz run csv_reader cargo +nightly fuzz run binance_envelope cargo +nightly fuzz run indicator_update +cargo +nightly fuzz run indicator_update_candle cargo +nightly fuzz run tick_aggregator ``` diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index e3b640c0..7a4d3113 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -1,22 +1,90 @@ #![no_main] -//! Fuzz indicator updates with arbitrary `f64` sequences. +//! Fuzz scalar-input indicator updates with arbitrary `f64` sequences. //! -//! Every indicator must tolerate any finite-or-not input stream — NaN, ±inf, -//! subnormals, abrupt jumps — without panicking, and `batch` must agree with -//! the streaming `update` path. +//! Every scalar indicator must tolerate any finite-or-not input stream — NaN, +//! ±inf, subnormals, abrupt jumps — without panicking. Each fuzz iteration +//! runs the **same** input sequence through every scalar indicator twice: +//! once as a streaming `update` loop and once as a full `batch` call. Neither +//! path may panic; `batch` is also expected to agree with the streaming path +//! (the `BatchExt` blanket implementation replays `update` internally, so the +//! agreement is structural — but exercising both paths surfaces any +//! state-mutation bugs in `update` that would only manifest mid-batch). +//! +//! Audit finding R9: the previous version covered only `Rsi(14)` and +//! `Ema(20)`. This target now covers every scalar indicator in the catalogue. use libfuzzer_sys::fuzz_target; -use wickra_core::{BatchExt, Ema, Indicator, Rsi}; +use wickra_core::{ + BatchExt, BollingerBands, Cmo, Coppock, Dema, Dpo, Ema, HistoricalVolatility, Hma, Indicator, + Kama, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator, Mom, Pmo, Ppo, Roc, Rsi, Sma, + Smma, StdDev, StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Wma, + ZScore, Zlema, +}; + +/// Drive a single streaming + batch run through one scalar indicator. Marked +/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator. +#[inline(never)] +fn drive(make: impl Fn() -> I, data: &[f64]) +where + I: Indicator + BatchExt, +{ + let mut streaming = make(); + for &x in data { + let _ = streaming.update(x); + } + let _ = make().batch(data); +} fuzz_target!(|data: Vec| { - let mut rsi = Rsi::new(14).unwrap(); - let mut ema = Ema::new(20).unwrap(); - for &x in &data { - let _ = rsi.update(x); - let _ = ema.update(x); - } + // Bounded periods keep each iteration cheap and bias the fuzzer toward + // adversarial input patterns rather than enormous windows. The constants + // mirror the README's "common defaults" so we cover the parameterisations + // most users actually instantiate. + drive(|| Sma::new(14).unwrap(), &data); + drive(|| Ema::new(20).unwrap(), &data); + drive(|| Wma::new(14).unwrap(), &data); + drive(|| Rsi::new(14).unwrap(), &data); + drive(|| Dema::new(14).unwrap(), &data); + drive(|| Tema::new(14).unwrap(), &data); + drive(|| Hma::new(14).unwrap(), &data); + drive(|| Roc::new(14).unwrap(), &data); + drive(|| Trix::new(14).unwrap(), &data); + drive(|| Smma::new(14).unwrap(), &data); + drive(|| Trima::new(14).unwrap(), &data); + drive(|| Zlema::new(14).unwrap(), &data); + drive(|| Kama::new(10, 2, 30).unwrap(), &data); + drive(|| T3::new(14, 0.7).unwrap(), &data); + drive(|| Mom::new(14).unwrap(), &data); + drive(|| Cmo::new(14).unwrap(), &data); + drive(|| Tsi::new(25, 13).unwrap(), &data); + drive(|| Pmo::new(35, 20).unwrap(), &data); + drive(|| StochRsi::new(14, 14).unwrap(), &data); + drive(|| Dpo::new(14).unwrap(), &data); + drive(|| Ppo::new(12, 26).unwrap(), &data); + drive(|| Coppock::new(14, 11, 10).unwrap(), &data); + drive(|| StdDev::new(14).unwrap(), &data); + drive(|| UlcerIndex::new(14).unwrap(), &data); + drive(|| HistoricalVolatility::new(14, 252).unwrap(), &data); + drive(|| LinearRegression::new(14).unwrap(), &data); + drive(|| LinRegSlope::new(14).unwrap(), &data); + drive(|| LinRegAngle::new(14).unwrap(), &data); + drive(|| VerticalHorizontalFilter::new(14).unwrap(), &data); + drive(|| ZScore::new(14).unwrap(), &data); - // batch over the same data must not panic either. - let _ = Rsi::new(14).unwrap().batch(&data); - let _ = Ema::new(20).unwrap().batch(&data); + // MACD and Bollinger Bands have non-`f64` outputs, so they cannot use the + // generic `drive` helper above. Streaming + batch are still both exercised. + { + let mut macd = MacdIndicator::new(12, 26, 9).unwrap(); + for &x in &data { + let _ = macd.update(x); + } + let _ = MacdIndicator::new(12, 26, 9).unwrap().batch(&data); + } + { + let mut bb = BollingerBands::new(20, 2.0).unwrap(); + for &x in &data { + let _ = bb.update(x); + } + let _ = BollingerBands::new(20, 2.0).unwrap().batch(&data); + } }); diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs new file mode 100644 index 00000000..4d36dc80 --- /dev/null +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -0,0 +1,130 @@ +#![no_main] +//! Fuzz OHLCV-input indicator updates with arbitrary candle sequences. +//! +//! Every candle-input indicator must tolerate any sequence of validated OHLCV +//! candles — extreme magnitudes, micro-spreads, zero-volume bars, abrupt +//! reversals — without panicking. The fuzzer chunks the raw `f64` stream into +//! `[open, high, low, close, volume]` tuples and constructs each candle via +//! `Candle::new`; entries that fail OHLCV-invariant validation are skipped so +//! the indicator only ever sees structurally-valid candles. Each iteration +//! then drives that candle stream through every candle-input indicator twice +//! (streaming `update` + batch). +//! +//! Audit finding R9: the previous fuzz suite had no candle-input coverage at +//! all. This target now covers every candle-input indicator including the +//! ones the audit named explicitly (ATR, ADX, Stochastic, PSAR) plus the +//! complete catalogue: Keltner, Donchian, SuperTrend, Chandelier Exit, ATR +//! Trailing Stop, Aroon, AwesomeOscillator, CCI, WilliamsR, MFI, OBV, VWAP, +//! RollingVWAP, ADL, VPT, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, +//! EaseOfMovement, NATR, AroonOscillator, ChandeKrollStop, Vortex, MassIndex, +//! ChoppinessIndex, TrueRange, ChaikinVolatility, AcceleratorOscillator, +//! BalanceOfPower, UltimateOscillator, VWMA, TypicalPrice, MedianPrice, +//! WeightedClose. + +use libfuzzer_sys::fuzz_target; +use wickra_core::{ + AcceleratorOscillator, Adl, Adx, Aroon, AroonOscillator, Atr, AtrTrailingStop, + AwesomeOscillator, BalanceOfPower, BatchExt, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, + ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, Donchian, EaseOfMovement, + ForceIndex, Indicator, Keltner, MassIndex, MedianPrice, Mfi, Natr, Obv, Psar, RollingVwap, + Stochastic, SuperTrend, TrueRange, TypicalPrice, UltimateOscillator, VolumePriceTrend, Vortex, + Vwap, Vwma, WeightedClose, WilliamsR, +}; + +/// Convert a flat `f64` stream into a `Vec` by chunking it into +/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV +/// validation are dropped so the indicator under test only ever sees a +/// structurally-valid candle stream (the *parser* is fuzz-tested elsewhere; +/// this target focuses on indicator robustness). +fn candles_from(data: &[f64]) -> Vec { + data.chunks_exact(5) + .enumerate() + .filter_map(|(i, ch)| { + // A monotonic timestamp avoids surprising any indicator that might + // care about ordering. The fuzz input drives OHLCV; time is just a + // tie-breaker. + Candle::new(ch[0], ch[1], ch[2], ch[3], ch[4], i as i64).ok() + }) + .collect() +} + +/// Streaming + batch sweep through one candle-input indicator. `#[inline(never)]` +/// keeps each indicator on its own frame in any panic backtrace. +#[inline(never)] +fn drive(make: impl Fn() -> I, candles: &[Candle]) +where + I: Indicator + BatchExt, +{ + let mut streaming = make(); + for c in candles { + let _ = streaming.update(*c); + } + let _ = make().batch(candles); +} + +fuzz_target!(|data: Vec| { + let candles = candles_from(&data); + if candles.is_empty() { + return; + } + + // --- Volatility & ATR family --- + drive(|| Atr::new(14).unwrap(), &candles); + drive(|| Natr::new(14).unwrap(), &candles); + drive(TrueRange::new, &candles); + drive(|| ChaikinVolatility::new(10, 10).unwrap(), &candles); + + // --- Bands & Channels --- + drive(|| Keltner::new(20, 10, 2.0).unwrap(), &candles); + drive(|| Donchian::new(20).unwrap(), &candles); + + // --- Trailing Stops --- + drive(|| Psar::new(0.02, 0.02, 0.20).unwrap(), &candles); + drive(|| SuperTrend::new(14, 3.0).unwrap(), &candles); + drive(|| ChandelierExit::new(22, 3.0).unwrap(), &candles); + drive(|| ChandeKrollStop::new(10, 1.0, 9).unwrap(), &candles); + drive(|| AtrTrailingStop::new(14, 3.0).unwrap(), &candles); + + // --- Trend & Directional --- + drive(|| Adx::new(14).unwrap(), &candles); + drive(|| Aroon::new(14).unwrap(), &candles); + drive(|| AroonOscillator::new(14).unwrap(), &candles); + drive(|| Vortex::new(14).unwrap(), &candles); + drive(|| MassIndex::new(9, 25).unwrap(), &candles); + drive(|| ChoppinessIndex::new(14).unwrap(), &candles); + + // --- Momentum & Oscillators --- + drive(|| Cci::new(20).unwrap(), &candles); + drive(|| WilliamsR::new(14).unwrap(), &candles); + drive(|| AwesomeOscillator::new(5, 34).unwrap(), &candles); + drive(|| AcceleratorOscillator::new(5, 34, 5).unwrap(), &candles); + drive(|| UltimateOscillator::new(7, 14, 28).unwrap(), &candles); + drive(BalanceOfPower::new, &candles); + + // --- Volume --- + drive(Obv::new, &candles); + drive(|| Mfi::new(14).unwrap(), &candles); + drive(Vwap::new, &candles); + drive(|| RollingVwap::new(20).unwrap(), &candles); + drive(|| Vwma::new(20).unwrap(), &candles); + drive(Adl::new, &candles); + drive(VolumePriceTrend::new, &candles); + drive(|| ChaikinMoneyFlow::new(20).unwrap(), &candles); + drive(|| ChaikinOscillator::new(3, 10).unwrap(), &candles); + drive(|| ForceIndex::new(13).unwrap(), &candles); + drive(|| EaseOfMovement::with_divisor(14, 1e8).unwrap(), &candles); + + // --- Price transformations --- + drive(TypicalPrice::new, &candles); + drive(MedianPrice::new, &candles); + drive(WeightedClose::new, &candles); + + // --- Stochastic (multi-output) --- + { + let mut s = Stochastic::new(14, 3).unwrap(); + for c in &candles { + let _ = s.update(*c); + } + let _ = Stochastic::new(14, 3).unwrap().batch(&candles); + } +});