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.
This commit is contained in:
kingchenc
2026-05-25 19:00:13 +02:00
committed by GitHub
parent 54194a4ff8
commit 6287bd48c1
18 changed files with 2141 additions and 85 deletions
@@ -50,6 +50,7 @@ const scalarFactories = {
CMO: () => new wickra.CMO(14),
TSI: () => new wickra.TSI(25, 13),
PMO: () => new wickra.PMO(35, 20),
TII: () => new wickra.TII(20, 10),
StochRSI: () => new wickra.StochRSI(14, 14),
PPO: () => new wickra.PPO(12, 26),
APO: () => new wickra.APO(12, 26),
@@ -123,6 +124,7 @@ const candleScalar = {
ChoppinessIndex: { make: () => new wickra.ChoppinessIndex(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TrueRange: { make: () => new wickra.TrueRange(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ChaikinVolatility: { make: () => new wickra.ChaikinVolatility(10, 10), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ADXR: { make: () => new wickra.ADXR(7), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ParkinsonVolatility: { make: () => new wickra.ParkinsonVolatility(20, 252), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
GarmanKlassVolatility: { make: () => new wickra.GarmanKlassVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
RogersSatchellVolatility: { make: () => new wickra.RogersSatchellVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
@@ -148,6 +150,7 @@ const multi = {
Alligator: { make: () => new wickra.Alligator(13, 8, 5), fields: ['jaw', 'teeth', 'lips'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ZeroLagMACD: { make: () => new wickra.ZeroLagMACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
KST: { make: () => wickra.KST.classic(), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
BollingerBands: { make: () => new wickra.BollingerBands(20, 2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
Stochastic: { make: () => new wickra.Stochastic(14, 3), fields: ['k', 'd'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ADX: { make: () => new wickra.ADX(14), fields: ['plusDi', 'minusDi', 'adx'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
@@ -155,6 +158,8 @@ const multi = {
Donchian: { make: () => new wickra.Donchian(20), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
Aroon: { make: () => new wickra.Aroon(14), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
Vortex: { make: () => new wickra.Vortex(14), fields: ['plus', 'minus'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
RWI: { make: () => new wickra.RWI(14), fields: ['high', 'low'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
WaveTrend: { make: () => wickra.WaveTrend.classic(), fields: ['wt1', 'wt2'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
SuperTrend: { make: () => new wickra.SuperTrend(10, 3), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ChandelierExit: { make: () => new wickra.ChandelierExit(22, 3), fields: ['longStop', 'shortStop'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ChandeKrollStop: { make: () => new wickra.ChandeKrollStop(10, 1, 9), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },