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
+347
View File
@@ -0,0 +1,347 @@
//! Random Walk Index (RWI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Random Walk Index output: the bullish (high) and bearish (low) lines.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RwiOutput {
/// `RWI_High` — strength of the trend up vs. a random walk.
pub high: f64,
/// `RWI_Low` — strength of the trend down vs. a random walk.
pub low: f64,
}
/// Mike Poulos' Random Walk Index — a trend-vs.-random-walk indicator that
/// asks "how many standard deviations away from a random walk is the current
/// move?".
///
/// For each lookback `i ∈ [2, period]`, RWI computes the ratio of the actual
/// price displacement over `i` bars to the expected displacement of a random
/// walk of the same length:
///
/// ```text
/// 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))
/// ```
///
/// where `ATR_i(t)` is the simple average of true-range over the most recent
/// `i` bars. The reported `RWI_High_t` / `RWI_Low_t` are the maxima of these
/// ratios across all lookbacks `i ∈ [2, period]`.
///
/// `RWI_High` crossing above `RWI_Low` and exceeding 1 (`> 2` is the typical
/// strong-trend threshold) signals an uptrend dominating random-walk; the
/// mirror situation flags a downtrend. When both lines are below 1, neither
/// direction beats a random walk and the market is read as ranging.
///
/// The first output is emitted after `period` candles (the second one provides
/// the first `period = 2` lookback, so the indicator emits at index
/// `period - 1`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Rwi};
///
/// let mut indicator = Rwi::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rwi {
period: usize,
/// Rolling window of the most recent `period` candles (oldest at the front).
candles: VecDeque<Candle>,
/// Rolling window of `period` true-range values aligned with `candles`
/// after the first bar (so `tr[0]` corresponds to `candles[1]`).
trs: VecDeque<f64>,
last: Option<RwiOutput>,
}
impl Rwi {
/// Construct a new RWI with the given lookback period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
/// Returns [`Error::InvalidPeriod`] if `period < 2` — RWI's shortest
/// lookback is `i = 2`, so a one-bar window would emit nothing.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if period < 2 {
return Err(Error::InvalidPeriod {
message: "RWI requires period >= 2",
});
}
Ok(Self {
period,
candles: VecDeque::with_capacity(period),
trs: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<RwiOutput> {
self.last
}
}
impl Indicator for Rwi {
type Input = Candle;
type Output = RwiOutput;
fn update(&mut self, candle: Candle) -> Option<RwiOutput> {
// Compute the true range of this candle vs. the previous close (if any),
// then slide the windows.
let tr = if let Some(prev) = self.candles.back() {
candle.true_range(Some(prev.close))
} else {
candle.high - candle.low
};
if self.candles.len() == self.period {
self.candles.pop_front();
}
self.candles.push_back(candle);
// `trs` aligns with `candles` from index 1 onward; only push once we
// have at least one previous candle (the bar's TR-vs-prev is what we
// store). With the first bar in `candles`, no TR is recorded yet.
if self.candles.len() >= 2 {
if self.trs.len() == self.period - 1 {
self.trs.pop_front();
}
self.trs.push_back(tr);
}
// Need a full `period` candles before we can scan lookbacks i ∈ [2,period].
if self.candles.len() < self.period {
return None;
}
// Slice access for indexed maths.
let candles: Vec<&Candle> = self.candles.iter().collect();
let trs: Vec<f64> = self.trs.iter().copied().collect();
let n = candles.len(); // == self.period
let last_high = candles[n - 1].high;
let last_low = candles[n - 1].low;
let mut rwi_high = 0.0_f64;
let mut rwi_low = 0.0_f64;
// For lookback i in [2, period]: compare bar `n - 1` to bar `n - i`.
// The TRs covered are those at trs indices [n - i .. n - 1], which is
// `i - 1` TR values (TR at index n - i is the TR of candle n - i + 1
// vs. candle n - i, the first TR contributing to the i-bar ATR... or
// strictly the ATR over the i-bar window is the mean of the i-1 TRs
// _between_ those bars). We use the i-1-TR mean to keep the indicator
// strictly causal.
for i in 2..=self.period {
// Trs slice indices (within trs Vec): start = n - i, end = n - 1 (excl.).
// trs has length n - 1; trs[k] = TR of candle k+1 vs candle k.
// count = i - 1, which is >= 1 for i >= 2.
let tr_start = n - i;
let tr_end = n - 1;
let count = tr_end - tr_start;
let atr_i: f64 = trs[tr_start..tr_end].iter().sum::<f64>() / (count as f64);
let denom = atr_i * (i as f64).sqrt();
if denom == 0.0 {
continue;
}
let old_low = candles[n - i].low;
let old_high = candles[n - i].high;
let h = (last_high - old_low) / denom;
let l = (old_high - last_low) / denom;
if h > rwi_high {
rwi_high = h;
}
if l > rwi_low {
rwi_low = l;
}
}
let out = RwiOutput {
high: rwi_high,
low: rwi_low,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.candles.clear();
self.trs.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
// First emission once the rolling window holds `period` candles.
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"RWI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle {
Candle::new(c, h, l, c, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(Rwi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn rejects_period_one() {
assert!(matches!(Rwi::new(1), Err(Error::InvalidPeriod { .. })));
}
#[test]
fn accessors_and_metadata() {
let mut r = Rwi::new(14).unwrap();
assert_eq!(r.period(), 14);
assert_eq!(r.warmup_period(), 14);
assert_eq!(r.name(), "RWI");
assert!(r.value().is_none());
for i in 0..30_i64 {
let p = 100.0 + (i as f64);
r.update(candle(p + 1.0, p - 1.0, p, i));
}
assert!(r.value().is_some());
}
#[test]
fn first_emission_at_warmup_period() {
let candles: Vec<Candle> = (0..40_i64)
.map(|i| {
let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0;
candle(p + 1.0, p - 1.0, p, i)
})
.collect();
let mut r = Rwi::new(5).unwrap();
let out = r.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn constant_series_yields_zero_outputs() {
// Flat market: ATR is zero, so all lookbacks short-circuit on the
// denom-zero guard and both lines stay at 0.
let candles: Vec<Candle> = (0..30_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
let mut r = Rwi::new(5).unwrap();
let last = r.batch(&candles).into_iter().flatten().last().unwrap();
assert_eq!(last.high, 0.0);
assert_eq!(last.low, 0.0);
}
#[test]
fn pure_uptrend_high_dominates_low() {
// A monotone uptrend should produce RWI_High >> RWI_Low.
let candles: Vec<Candle> = (0..40_i64)
.map(|i| {
let base = 100.0 + (i as f64) * 2.0;
candle(base + 1.0, base - 0.5, base + 0.5, i)
})
.collect();
let mut r = Rwi::new(14).unwrap();
let last = r.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last.high > last.low,
"RWI_High {} should exceed RWI_Low {}",
last.high,
last.low
);
assert!(
last.high > 1.0,
"strong uptrend should exceed 1, got {}",
last.high
);
}
#[test]
fn pure_downtrend_low_dominates_high() {
let candles: Vec<Candle> = (0..40_i64)
.rev()
.map(|i| {
let base = 100.0 + (i as f64) * 2.0;
candle(base + 0.5, base - 1.0, base - 0.5, 40 - i)
})
.collect();
let mut r = Rwi::new(14).unwrap();
let last = r.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last.low > last.high);
assert!(last.low > 1.0);
}
#[test]
fn outputs_non_negative() {
let candles: Vec<Candle> = (0..120_i64)
.map(|i| {
let p = 100.0 + ((i as f64) * 0.25).sin() * 6.0;
candle(p + 1.5, p - 1.5, p, i)
})
.collect();
let mut r = Rwi::new(10).unwrap();
for v in r.batch(&candles).into_iter().flatten() {
assert!(v.high >= 0.0 && v.low >= 0.0);
assert!(v.high.is_finite() && v.low.is_finite());
}
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80_i64)
.map(|i| {
let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0;
candle(p + 1.0, p - 1.0, p, i)
})
.collect();
let mut a = Rwi::new(7).unwrap();
let mut b = Rwi::new(7).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..30_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
let mut r = Rwi::new(5).unwrap();
r.batch(&candles);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(candles[0]), None);
}
}