Files
wickra/fuzz/fuzz_targets/indicator_update_candle.rs
T
kingchencandGitHub 6287bd48c1 feat: Family 06 Trend-Strength - 5 new directional/random-walk indicators (#44)
* feat(adxr): add Wilder Average Directional Movement Index Rating

ADXR is the trend-strength smoother Wilder published alongside ADX in
*New Concepts in Technical Trading Systems* (1978):

    ADXR_t = (ADX_t + ADX_{t - (period - 1)}) / 2

The lookback length is the same period that feeds the underlying ADX.
Because the older ADX is period - 1 bars stale, ADXR responds more
slowly than ADX and is the canonical metric for comparing
trend-strength across instruments.

Implementation reuses the existing wickra_core::Adx engine plus a
period-length ring of past ADX values; warmup is 3 * period - 1
(41 for period = 14). Bindings: Python PyAdxr (PyArray1 batch),
Node AdxrNode (number scalar), WASM WasmAdxr. Fuzz target covers
the candle-input path. Python + Node streaming-vs-batch tests
parametrised, plus a pure-uptrend reference value (ADXR == 100
when ADX saturates at 100). Criterion bench added under crates/
wickra/benches/indicators.rs.

README family table and indicator counter updated (71 -> 72).

* feat(rwi): add Mike Poulos Random Walk Index

RWI compares actual price displacement to what a random walk would
produce over the same horizon: for each lookback i in [2, period],

    RWI_High_t(i) = (high_t - low_{t-i+1}) / (ATR_i(t) * sqrt(i))
    RWI_Low_t(i)  = (high_{t-i+1} - low_t) / (ATR_i(t) * sqrt(i))

Per-bar output is the maximum across lookbacks for each direction;
a reading > 1 means the trend beats random-walk noise, > 2 is the
typical strong-trend threshold. Multi-output (high, low). period
must be >= 2 (the shortest meaningful lookback); period < 2 returns
InvalidPeriod. Warmup = period (e.g. 14 for the standard default).

Bindings: Python PyRwi (PyArray2 shape (n, 2)), Node RwiNode +
RwiValue struct, WASM WasmRwi (Object/Reflect for update,
Float64Array interleaved for batch). Fuzz target adds the candle
input case. Python parametric streaming-vs-batch test and pure
uptrend reference test (RWI_High dominates RWI_Low and exceeds 1).
Node parametric streaming-vs-interleaved-batch test. Criterion
bench under crates/wickra/benches/indicators.rs.

README family table and indicator counter updated (72 -> 73).

* feat(tii): add M.H. Pee Trend Intensity Index

TII is a [0, 100] oscillator that asks 'what fraction of the recent
SMA deviations are positive?'. The construction is

    dev_t  = close_t - SMA(close, sma_period)_t
    SD_pos = sum of positive dev_t over the last dev_period bars
    SD_neg = sum of |negative dev_t| over the last dev_period bars
    TII    = 100 * SD_pos / (SD_pos + SD_neg)

Saturates at 100 on a pure uptrend (every close above the lagging
SMA), at 0 on a pure downtrend, and returns the neutral mid-point 50
on a perfectly flat window. The output is clamped to [0, 100] as
the rolling-sum subtraction loop can accumulate a few ULP of error
on long histories. Canonical Pee parameters (sma_period=60,
dev_period=30) wired as Python defaults; warmup is
sma_period + dev_period - 1 (89 for the defaults).

Bindings: Python PyTii (PyArray1 batch), Node TiiNode (scalar
update + batch), WASM WasmTii via the two-arg wasm_scalar_indicator!
macro. Fuzz target adds the scalar path. Python parametric
streaming-vs-batch test plus pure-uptrend (TII == 100) and
flat-market (TII == 50) reference tests. Node parametric
streaming-vs-batch test. Criterion bench under crates/wickra/
benches/indicators.rs.

README family table and indicator counter updated (73 -> 74).

* feat(kst): add Pring Know Sure Thing oscillator

KST is Martin Pring's long-horizon momentum gauge: four smoothed
rate-of-change components combined with fixed weights (1, 2, 3, 4),
plus an SMA signal line.

    RCMA_i = SMA(ROC(close, roc_i), sma_i)        for i in 1..=4
    KST    = 1*RCMA_1 + 2*RCMA_2 + 3*RCMA_3 + 4*RCMA_4
    Signal = SMA(KST, signal_period)

Kst::classic() exposes Pring's recommended parameter set
(roc = (10, 15, 20, 30), sma = (10, 10, 10, 15), signal = 9);
warmup = max(roc_i + sma_i) + signal_period - 1 (53 for the classic
parameters). All four parallel branches are fed unconditionally so
they warm in lock-step.

Bindings: Python PyKst (PyArray2 shape (n, 2)) with a KST.classic()
staticmethod, Node KstNode + KstValue with a KST.classic() factory,
WASM WasmKst with both new(...) and classic() constructors plus
Object/Reflect for update and Float64Array for batch. Fuzz target
adds the scalar multi-output path. Python tests gain a new
MULTI_SCALAR section parametric over scalar-input/multi-output
indicators, plus a classic-on-constant-series reference test. Node
tests gain a KST entry in the multi-output section. Criterion
benchmark added under crates/wickra/benches/indicators.rs.

README family table and indicator counter updated (74 -> 75).

* feat(wave-trend): add LazyBear Wave Trend Oscillator

Two-line mean-reverting momentum gauge built from the typical price
and three cascaded EMAs:

    ap   = (high + low + close) / 3
    esa  = EMA(ap, channel_period)
    d    = EMA(|ap - esa|, channel_period)
    ci   = (ap - esa) / (0.015 * d)
    wt1  = EMA(ci, average_period)
    wt2  = SMA(wt1, signal_period)

WaveTrend::classic() exposes LazyBear's defaults
(channel = 10, average = 21, signal = 4); warmup is
2 * channel_period + average_period + signal_period - 3 (42 for the
classic defaults). On a perfectly flat market the SMA-seeded EMA
introduces a single-ULP drift between ap and esa, which on a tiny d
would make the ratio explode to -1/0.015 = -66.67; a price-scaled
flat-tolerance guard (d <= 16 * EPSILON * max(|esa|, 1)) collapses
the channel index to 0 in that regime so both lines remain at zero.

Bindings: Python PyWaveTrend (PyArray2 shape (n, 2)) with a
WaveTrend.classic() staticmethod, Node WaveTrendNode + WaveTrendValue
with a WaveTrend.classic() factory, WASM WasmWaveTrend with both
new(...) and classic() constructors. Fuzz target adds the candle
multi-output path (sorted alphabetically). Python parametric
streaming-vs-batch test plus a flat-market reference test. Node
parametric streaming-vs-interleaved-batch test. Criterion bench
under crates/wickra/benches/indicators.rs.

README family table and indicator counter updated (75 -> 76).

* fix(family-06): re-add KST::classic() factory + drop dup fuzz block

Family-06 PR's tests call ta.KST.classic() / wickra.KST.classic() — main's
KST binding shipped without the static factory. Add classic() in Python
(staticmethod) and Node (napi factory); WASM already had it. Also drop the
duplicate Kst::classic().unwrap() block in fuzz/indicator_update.rs that
the merge left behind (main's API no longer returns Result).

* test(rwi): drop dead count==0 guard

The loop `for i in 2..=period` makes `count = tr_end - tr_start = i - 1`
which is always >= 1, so the `if count == 0 { continue; }` branch was
unreachable defensive code that codecov flagged on the family-06 PR.
2026-05-25 19:00:13 +02:00

202 lines
8.2 KiB
Rust

#![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::{
AccelerationBands, AcceleratorOscillator, Adl, Adx, Adxr, Alligator, Aroon, AroonOscillator,
Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower,
BatchExt, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
ChandelierExit, ChoppinessIndex, Donchian, EaseOfMovement, Evwma, ForceIndex, FractalChaosBands,
GarmanKlassVolatility, HurstChannel, Indicator, Inertia, Keltner, MassIndex, MedianPrice, Mfi,
Natr, Obv, ParkinsonVolatility, Pgo, Psar, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, Smi,
StarcBands, Stochastic, SuperTrend, TrueRange, TtmSqueeze, TypicalPrice, UltimateOscillator,
VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, WaveTrend, WeightedClose, WilliamsR,
YangZhangVolatility,
};
/// Convert a flat `f64` stream into a `Vec<Candle>` 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<Candle> {
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<I, O>(make: impl Fn() -> I, candles: &[Candle])
where
I: Indicator<Input = Candle, Output = O> + BatchExt,
{
let mut streaming = make();
for c in candles {
let _ = streaming.update(*c);
}
let _ = make().batch(candles);
}
fuzz_target!(|data: Vec<f64>| {
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);
drive(|| ParkinsonVolatility::new(20, 252).unwrap(), &candles);
drive(|| GarmanKlassVolatility::new(20, 252).unwrap(), &candles);
drive(|| RogersSatchellVolatility::new(20, 252).unwrap(), &candles);
drive(|| YangZhangVolatility::new(20, 252).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(|| Adxr::new(14).unwrap(), &candles);
drive(|| Aroon::new(14).unwrap(), &candles);
drive(|| Alligator::new(13, 8, 5).unwrap(), &candles);
drive(|| AroonOscillator::new(14).unwrap(), &candles);
drive(|| Vortex::new(14).unwrap(), &candles);
drive(|| Rwi::new(14).unwrap(), &candles);
drive(|| WaveTrend::classic().unwrap(), &candles);
drive(|| MassIndex::new(9, 25).unwrap(), &candles);
drive(|| ChoppinessIndex::new(14).unwrap(), &candles);
// --- Momentum & Oscillators ---
drive(|| Cci::new(20).unwrap(), &candles);
drive(|| Rvi::new(10).unwrap(), &candles);
drive(|| Inertia::new(14, 20).unwrap(), &candles);
drive(|| Pgo::new(14).unwrap(), &candles);
drive(|| Smi::classic(), &candles);
drive(|| WilliamsR::new(14).unwrap(), &candles);
drive(|| AwesomeOscillator::new(5, 34).unwrap(), &candles);
drive(
|| AwesomeOscillatorHistogram::new(5, 34, 5).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(|| Evwma::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);
}
// --- Family 05: candle-input band/channel indicators (multi-output) ---
{
let mut ab = AccelerationBands::new(20, 0.001).unwrap();
for c in &candles {
let _ = ab.update(*c);
}
let _ = AccelerationBands::new(20, 0.001).unwrap().batch(&candles);
}
{
let mut sb = StarcBands::new(6, 15, 2.0).unwrap();
for c in &candles {
let _ = sb.update(*c);
}
let _ = StarcBands::new(6, 15, 2.0).unwrap().batch(&candles);
}
{
let mut atrb = AtrBands::new(14, 3.0).unwrap();
for c in &candles {
let _ = atrb.update(*c);
}
let _ = AtrBands::new(14, 3.0).unwrap().batch(&candles);
}
{
let mut hc = HurstChannel::new(10, 0.5).unwrap();
for c in &candles {
let _ = hc.update(*c);
}
let _ = HurstChannel::new(10, 0.5).unwrap().batch(&candles);
}
{
let mut ts = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
for c in &candles {
let _ = ts.update(*c);
}
let _ = TtmSqueeze::new(20, 2.0, 1.5).unwrap().batch(&candles);
}
{
let mut fc = FractalChaosBands::new(2).unwrap();
for c in &candles {
let _ = fc.update(*c);
}
let _ = FractalChaosBands::new(2).unwrap().batch(&candles);
}
{
let mut vb = VwapStdDevBands::new(2.0).unwrap();
for c in &candles {
let _ = vb.update(*c);
}
let _ = VwapStdDevBands::new(2.0).unwrap().batch(&candles);
}
});