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:
@@ -0,0 +1,246 @@
|
||||
//! Average Directional Movement Index Rating (ADXR).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::adx::Adx;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Wilder's Average Directional Movement Index Rating.
|
||||
///
|
||||
/// `ADXR` smooths the [`Adx`] line by averaging its current value with the value
|
||||
/// it had `period` bars ago:
|
||||
///
|
||||
/// ```text
|
||||
/// ADXR_t = (ADX_t + ADX_{t - (period - 1)}) / 2
|
||||
/// ```
|
||||
///
|
||||
/// The lookback length is the same `period` that feeds the underlying ADX.
|
||||
/// Wilder introduced ADXR alongside ADX in *New Concepts in Technical Trading
|
||||
/// Systems* (1978) as a more stable directional-strength reading: because the
|
||||
/// older `ADX` is `period - 1` bars stale, ADXR responds more slowly than ADX
|
||||
/// and is used to compare trend-strength between different instruments.
|
||||
///
|
||||
/// The first complete `ADXR` is emitted after `3 * period - 1` candles
|
||||
/// (`2 * period` to seed the ADX plus another `period - 1` to fill the
|
||||
/// lookback ring).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Adxr, Candle, Indicator};
|
||||
///
|
||||
/// let mut indicator = Adxr::new(5).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 Adxr {
|
||||
period: usize,
|
||||
adx: Adx,
|
||||
/// Ring buffer of the most recent `period` `ADX` values; the front is the
|
||||
/// oldest, the back is the newest. ADXR is `(back + front) / 2` once the
|
||||
/// ring is full.
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Adxr {
|
||||
/// Construct a new ADXR with the given Wilder smoothing period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
adx: Adx::new(period)?,
|
||||
window: 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<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Adxr {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let adx_value = self.adx.update(candle)?.adx;
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(adx_value);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let oldest = *self.window.front().expect("ring is full");
|
||||
let adxr = f64::midpoint(adx_value, oldest);
|
||||
self.last = Some(adxr);
|
||||
Some(adxr)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.adx.reset();
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// ADX warmup is `2 * period` and emits one `ADX` per subsequent candle;
|
||||
// the ADXR ring then needs `period - 1` more candles to fill, so the
|
||||
// first ADXR lands at `2 * period + (period - 1) = 3 * period - 1`.
|
||||
3 * self.period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ADXR"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
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!(Adxr::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut a = Adxr::new(14).unwrap();
|
||||
assert_eq!(a.period(), 14);
|
||||
assert_eq!(a.warmup_period(), 41);
|
||||
assert_eq!(a.name(), "ADXR");
|
||||
assert!(a.value().is_none());
|
||||
// Drive past warmup.
|
||||
for i in 0..50_i64 {
|
||||
let base = 100.0 + (i as f64) * 2.0;
|
||||
a.update(candle(base + 1.0, base - 0.5, base + 0.5, i));
|
||||
}
|
||||
assert!(a.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_yields_finite_positive_adxr() {
|
||||
let candles: Vec<Candle> = (0..80_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 a = Adxr::new(14).unwrap();
|
||||
let last = a.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last > 0.0 && last <= 100.0 + 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero_adxr() {
|
||||
let candles: Vec<Candle> = (0..50_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
|
||||
let mut a = Adxr::new(5).unwrap();
|
||||
let last = a.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
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 = Adxr::new(5).unwrap();
|
||||
let out = a.batch(&candles);
|
||||
let warmup = 3 * 5 - 1; // 14
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value_against_explicit_adx_average() {
|
||||
// The first ADXR(p) emits at index `3p - 2` (0-based), and equals
|
||||
// (ADX[index] + ADX[index - (p - 1)]) / 2. Verify against a separate
|
||||
// ADX run.
|
||||
let candles: Vec<Candle> = (0..60_i64)
|
||||
.map(|i| {
|
||||
let p = 100.0 + ((i as f64) * 0.2).sin() * 6.0;
|
||||
candle(p + 1.5, p - 1.5, p, i)
|
||||
})
|
||||
.collect();
|
||||
let period = 5;
|
||||
let mut adx = Adx::new(period).unwrap();
|
||||
let adx_out: Vec<_> = adx
|
||||
.batch(&candles)
|
||||
.into_iter()
|
||||
.map(|o| o.map(|x| x.adx))
|
||||
.collect();
|
||||
let mut adxr = Adxr::new(period).unwrap();
|
||||
let adxr_out = adxr.batch(&candles);
|
||||
// First ADXR index (0-based) = 3 * period - 2 = 13.
|
||||
let first = 3 * period - 2;
|
||||
let prev = first - (period - 1);
|
||||
let expected = f64::midpoint(adx_out[first].unwrap(), adx_out[prev].unwrap());
|
||||
assert_relative_eq!(adxr_out[first].unwrap(), expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60_i64)
|
||||
.map(|i| {
|
||||
let p = 100.0 + ((i as f64) * 0.25).sin() * 5.0;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Adxr::new(7).unwrap();
|
||||
let mut b = Adxr::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..60_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut a = Adxr::new(5).unwrap();
|
||||
a.batch(&candles);
|
||||
assert!(a.is_ready());
|
||||
a.reset();
|
||||
assert!(!a.is_ready());
|
||||
assert_eq!(a.update(candles[0]), None);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod acceleration_bands;
|
||||
mod accelerator_oscillator;
|
||||
mod adl;
|
||||
mod adx;
|
||||
mod adxr;
|
||||
mod alligator;
|
||||
mod alma;
|
||||
mod apo;
|
||||
@@ -77,6 +78,7 @@ mod rogers_satchell;
|
||||
mod rsi;
|
||||
mod rvi;
|
||||
mod rvi_volatility;
|
||||
mod rwi;
|
||||
mod sma;
|
||||
mod smi;
|
||||
mod smma;
|
||||
@@ -89,6 +91,7 @@ mod stochastic;
|
||||
mod super_trend;
|
||||
mod t3;
|
||||
mod tema;
|
||||
mod tii;
|
||||
mod trima;
|
||||
mod trix;
|
||||
mod true_range;
|
||||
@@ -104,6 +107,7 @@ mod vpt;
|
||||
mod vwap;
|
||||
mod vwap_stddev_bands;
|
||||
mod vwma;
|
||||
mod wave_trend;
|
||||
mod weighted_close;
|
||||
mod williams_r;
|
||||
mod wma;
|
||||
@@ -116,6 +120,7 @@ pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
|
||||
pub use accelerator_oscillator::AcceleratorOscillator;
|
||||
pub use adl::Adl;
|
||||
pub use adx::{Adx, AdxOutput};
|
||||
pub use adxr::Adxr;
|
||||
pub use alligator::{Alligator, AlligatorOutput};
|
||||
pub use alma::Alma;
|
||||
pub use apo::Apo;
|
||||
@@ -185,6 +190,7 @@ pub use rogers_satchell::RogersSatchellVolatility;
|
||||
pub use rsi::Rsi;
|
||||
pub use rvi::Rvi;
|
||||
pub use rvi_volatility::RviVolatility;
|
||||
pub use rwi::{Rwi, RwiOutput};
|
||||
pub use sma::Sma;
|
||||
pub use smi::Smi;
|
||||
pub use smma::Smma;
|
||||
@@ -197,6 +203,7 @@ pub use stochastic::{Stochastic, StochasticOutput};
|
||||
pub use super_trend::{SuperTrend, SuperTrendOutput};
|
||||
pub use t3::T3;
|
||||
pub use tema::Tema;
|
||||
pub use tii::Tii;
|
||||
pub use trima::Trima;
|
||||
pub use trix::Trix;
|
||||
pub use true_range::TrueRange;
|
||||
@@ -212,6 +219,7 @@ pub use vpt::VolumePriceTrend;
|
||||
pub use vwap::{RollingVwap, Vwap};
|
||||
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
|
||||
pub use vwma::Vwma;
|
||||
pub use wave_trend::{WaveTrend, WaveTrendOutput};
|
||||
pub use weighted_close::WeightedClose;
|
||||
pub use williams_r::WilliamsR;
|
||||
pub use wma::Wma;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Trend Intensity Index (TII).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// M.H. Pee's Trend Intensity Index — a `[0, 100]` oscillator that measures
|
||||
/// what fraction of the recent SMA deviations are positive.
|
||||
///
|
||||
/// First, compute an `SMA(close, sma_period)` (canonical `sma_period = 60`).
|
||||
/// On each bar `t` that the SMA is defined, compute the deviation
|
||||
/// `dev_t = close_t − SMA_t`. Then, over the most recent `dev_period`
|
||||
/// deviations (canonical `dev_period = 30`, i.e. `sma_period / 2`), sum the
|
||||
/// positive and negative magnitudes separately:
|
||||
///
|
||||
/// ```text
|
||||
/// SD_pos = Σ_{i ∈ window, dev_i > 0} dev_i
|
||||
/// SD_neg = Σ_{i ∈ window, dev_i < 0} |dev_i|
|
||||
/// TII = 100 · SD_pos / (SD_pos + SD_neg)
|
||||
/// ```
|
||||
///
|
||||
/// `TII` is bounded in `[0, 100]`: high readings (`> 80`) signal a sustained
|
||||
/// uptrend (most recent closes above the SMA), low readings (`< 20`) a
|
||||
/// sustained downtrend. A perfectly flat window produces `50` (every deviation
|
||||
/// is zero, so the indicator falls back to its neutral mid-point).
|
||||
///
|
||||
/// The first output is emitted once both the SMA is ready (`sma_period`
|
||||
/// inputs) and the deviation ring is full (`dev_period − 1` more inputs):
|
||||
/// warmup = `sma_period + dev_period − 1`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Tii};
|
||||
///
|
||||
/// let mut indicator = Tii::new(20, 10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tii {
|
||||
sma_period: usize,
|
||||
dev_period: usize,
|
||||
sma: Sma,
|
||||
/// Rolling window of the most recent `dev_period` deviations.
|
||||
window: VecDeque<f64>,
|
||||
sum_pos: f64,
|
||||
sum_neg: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Tii {
|
||||
/// Construct a new TII with the SMA period and the deviation window length.
|
||||
///
|
||||
/// The canonical Pee parameters are `(sma_period = 60, dev_period = 30)`;
|
||||
/// expose them as the Python defaults.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is `0`.
|
||||
pub fn new(sma_period: usize, dev_period: usize) -> Result<Self> {
|
||||
if sma_period == 0 || dev_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
sma_period,
|
||||
dev_period,
|
||||
sma: Sma::new(sma_period)?,
|
||||
window: VecDeque::with_capacity(dev_period),
|
||||
sum_pos: 0.0,
|
||||
sum_neg: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(sma_period, dev_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.sma_period, self.dev_period)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Tii {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let sma_value = self.sma.update(input)?;
|
||||
let dev = input - sma_value;
|
||||
|
||||
if self.window.len() == self.dev_period {
|
||||
let old = self.window.pop_front().expect("ring is non-empty");
|
||||
if old > 0.0 {
|
||||
self.sum_pos -= old;
|
||||
} else if old < 0.0 {
|
||||
self.sum_neg -= -old;
|
||||
}
|
||||
}
|
||||
self.window.push_back(dev);
|
||||
if dev > 0.0 {
|
||||
self.sum_pos += dev;
|
||||
} else if dev < 0.0 {
|
||||
self.sum_neg += -dev;
|
||||
}
|
||||
|
||||
if self.window.len() < self.dev_period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let denom = self.sum_pos + self.sum_neg;
|
||||
let tii = if denom <= 0.0 {
|
||||
// A perfectly flat window — every deviation is zero. By
|
||||
// convention we return the neutral mid-point, matching
|
||||
// pandas-ta's implementation. The `<=` also catches the rare
|
||||
// case where rolling-subtraction rounding leaves the
|
||||
// accumulator slightly negative; the indicator is then
|
||||
// mathematically undefined and we again fall back to the
|
||||
// neutral mid-point.
|
||||
50.0
|
||||
} else {
|
||||
// Clamp to [0, 100]: by construction the ratio lives in this
|
||||
// interval, but the rolling sum_pos / sum_neg subtractions
|
||||
// accumulate floating-point error and can produce a result
|
||||
// a few ULP outside the bound on long histories.
|
||||
(100.0 * self.sum_pos / denom).clamp(0.0, 100.0)
|
||||
};
|
||||
self.last = Some(tii);
|
||||
Some(tii)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
self.window.clear();
|
||||
self.sum_pos = 0.0;
|
||||
self.sum_neg = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// SMA emits its first value at input `sma_period`; the deviation ring
|
||||
// then needs `dev_period − 1` more inputs to fill, so first TII lands
|
||||
// at `sma_period + dev_period − 1`.
|
||||
self.sma_period + self.dev_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TII"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Tii::new(0, 10), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Tii::new(10, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut t = Tii::new(60, 30).unwrap();
|
||||
assert_eq!(t.periods(), (60, 30));
|
||||
assert_eq!(t.warmup_period(), 89);
|
||||
assert_eq!(t.name(), "TII");
|
||||
assert!(t.value().is_none());
|
||||
let prices: Vec<f64> = (1..=100).map(|i| 100.0 + f64::from(i)).collect();
|
||||
for &p in &prices {
|
||||
t.update(p);
|
||||
}
|
||||
assert!(t.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let prices: Vec<f64> = (1..=30)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut t = Tii::new(5, 4).unwrap();
|
||||
let out = t.batch(&prices);
|
||||
let warmup = 5 + 4 - 1; // 8
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_saturates_at_100() {
|
||||
// Strictly increasing series: the SMA always lags, so every close
|
||||
// sits above the SMA → every deviation positive → TII = 100.
|
||||
let prices: Vec<f64> = (1..=80).map(|i| 100.0 + f64::from(i)).collect();
|
||||
let mut t = Tii::new(10, 5).unwrap();
|
||||
let last = t.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_downtrend_falls_to_zero() {
|
||||
let prices: Vec<f64> = (1..=80).rev().map(|i| 100.0 + f64::from(i)).collect();
|
||||
let mut t = Tii::new(10, 5).unwrap();
|
||||
let last = t.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_neutral_50() {
|
||||
// Every deviation is zero; the `denom == 0` guard returns the
|
||||
// neutral mid-point.
|
||||
let mut t = Tii::new(5, 4).unwrap();
|
||||
let last = t
|
||||
.batch(&[10.0_f64; 30])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_bounded_in_unit_interval() {
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0 + (f64::from(i) * 0.07).cos() * 3.0)
|
||||
.collect();
|
||||
let mut t = Tii::new(20, 10).unwrap();
|
||||
for v in t.batch(&prices).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = Tii::new(20, 10).unwrap();
|
||||
let mut b = Tii::new(20, 10).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = Tii::new(5, 4).unwrap();
|
||||
t.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.update(1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Wave Trend Oscillator (`LazyBear`).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Wave Trend Oscillator output: the two lines `wt1` (the oscillator) and
|
||||
/// `wt2` (the signal SMA).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct WaveTrendOutput {
|
||||
/// `wt1` — the smoothed channel index.
|
||||
pub wt1: f64,
|
||||
/// `wt2` — the SMA-smoothed signal line.
|
||||
pub wt2: f64,
|
||||
}
|
||||
|
||||
/// `LazyBear`'s Wave Trend Oscillator — a two-line momentum gauge built from
|
||||
/// the typical price and three cascaded EMAs.
|
||||
///
|
||||
/// For each candle let `ap_t = (high + low + close) / 3`:
|
||||
///
|
||||
/// ```text
|
||||
/// esa_t = EMA(ap, channel_period)
|
||||
/// d_t = EMA(|ap − esa|, channel_period)
|
||||
/// ci_t = (ap_t − esa_t) / (0.015 * d_t)
|
||||
/// wt1_t = EMA(ci, average_period)
|
||||
/// wt2_t = SMA(wt1, signal_period)
|
||||
/// ```
|
||||
///
|
||||
/// Bullish trigger: `wt1` crossing above `wt2` from an oversold region
|
||||
/// (typically `wt1 < -60`); bearish trigger: the mirror crossover above
|
||||
/// `+60`. The indicator is mean-reverting around zero, so it is most useful
|
||||
/// at extremes.
|
||||
///
|
||||
/// The canonical `LazyBear` defaults are
|
||||
/// `(channel_period = 10, average_period = 21, signal_period = 4)`; warmup is
|
||||
/// `channel_period + average_period + signal_period − 2`.
|
||||
///
|
||||
/// Non-finite `d` (a zero-volatility seed where the absolute-deviation EMA
|
||||
/// has not yet recorded any movement) collapses the channel index to zero.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, WaveTrend};
|
||||
///
|
||||
/// let mut indicator = WaveTrend::classic().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 WaveTrend {
|
||||
channel_period: usize,
|
||||
average_period: usize,
|
||||
signal_period: usize,
|
||||
esa: Ema,
|
||||
dev_ema: Ema,
|
||||
tci: Ema,
|
||||
signal: Sma,
|
||||
last: Option<WaveTrendOutput>,
|
||||
}
|
||||
|
||||
impl WaveTrend {
|
||||
/// Construct a new Wave Trend Oscillator with explicit periods.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if any period is `0`.
|
||||
pub fn new(channel_period: usize, average_period: usize, signal_period: usize) -> Result<Self> {
|
||||
if channel_period == 0 || average_period == 0 || signal_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
channel_period,
|
||||
average_period,
|
||||
signal_period,
|
||||
esa: Ema::new(channel_period)?,
|
||||
dev_ema: Ema::new(channel_period)?,
|
||||
tci: Ema::new(average_period)?,
|
||||
signal: Sma::new(signal_period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `LazyBear`'s classic Wave Trend: `(channel = 10, average = 21, signal = 4)`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// None in practice — all periods are non-zero.
|
||||
pub fn classic() -> Result<Self> {
|
||||
Self::new(10, 21, 4)
|
||||
}
|
||||
|
||||
/// Configured `(channel_period, average_period, signal_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
(self.channel_period, self.average_period, self.signal_period)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<WaveTrendOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for WaveTrend {
|
||||
type Input = Candle;
|
||||
type Output = WaveTrendOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<WaveTrendOutput> {
|
||||
let ap = (candle.high + candle.low + candle.close) / 3.0;
|
||||
|
||||
// Stage 1: ESA = EMA(ap, channel_period). Must be ready before we
|
||||
// can compute the absolute deviation EMA against it.
|
||||
let esa = self.esa.update(ap)?;
|
||||
|
||||
// Stage 2: deviation EMA tracks |ap - esa|.
|
||||
let d = self.dev_ema.update((ap - esa).abs())?;
|
||||
|
||||
// Stage 3: channel index. On a perfectly flat market `(ap - esa)`
|
||||
// and `d` are both within an ULP or two of zero; their ratio is
|
||||
// mathematically indeterminate and would otherwise produce garbage
|
||||
// like `-66.67 = -1 / 0.015`. Treat any sub-ULP deviation as zero,
|
||||
// matching pandas-ta's flat-market behaviour. The threshold scales
|
||||
// with `esa` so it adapts to any price magnitude.
|
||||
let flat_tol = esa.abs().max(1.0) * 16.0 * f64::EPSILON;
|
||||
let ci = if d <= flat_tol {
|
||||
0.0
|
||||
} else {
|
||||
(ap - esa) / (0.015 * d)
|
||||
};
|
||||
|
||||
// Stage 4: wt1 = EMA(ci, average_period).
|
||||
let wt1 = self.tci.update(ci)?;
|
||||
|
||||
// Stage 5: wt2 = SMA(wt1, signal_period).
|
||||
let wt2 = self.signal.update(wt1)?;
|
||||
|
||||
let out = WaveTrendOutput { wt1, wt2 };
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.esa.reset();
|
||||
self.dev_ema.reset();
|
||||
self.tci.reset();
|
||||
self.signal.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// EMA(esa) first emits at input `channel_period`; the second EMA
|
||||
// (deviation) takes its input from the same bar and emits at the
|
||||
// same `channel_period`-th input (it can already start computing
|
||||
// |ap - esa| as soon as esa is ready, and the EMA-of-EMA construction
|
||||
// uses the inner EMA's first valid output as its first input —
|
||||
// however because we gate via `?` on both stages, the second EMA's
|
||||
// first valid input is at the channel_period-th input, then itself
|
||||
// needs channel_period - 1 more inputs to warm... but our Ema
|
||||
// implementation seeds via SMA on the first `period` inputs, so the
|
||||
// dev_ema needs channel_period inputs of |ap - esa| values.
|
||||
//
|
||||
// Actually: esa emits at input `channel_period` (1-based). dev_ema
|
||||
// gets fed starting at that input, and needs `channel_period` inputs
|
||||
// of its own to first emit: at the `2 * channel_period - 1`-th input
|
||||
// dev_ema is ready (it has consumed channel_period inputs starting
|
||||
// from the channel_period-th). tci then needs `average_period`
|
||||
// inputs of `ci`, so it's ready at `2 * channel_period - 1 +
|
||||
// average_period - 1`. Signal needs `signal_period` inputs of wt1
|
||||
// → ready at `2 * channel_period - 1 + average_period - 1 +
|
||||
// signal_period - 1` = `2 * channel_period + average_period +
|
||||
// signal_period - 3`.
|
||||
2 * self.channel_period + self.average_period + self.signal_period - 3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"WaveTrend"
|
||||
}
|
||||
}
|
||||
|
||||
#[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!(WaveTrend::new(0, 21, 4), Err(Error::PeriodZero)));
|
||||
assert!(matches!(WaveTrend::new(10, 0, 4), Err(Error::PeriodZero)));
|
||||
assert!(matches!(WaveTrend::new(10, 21, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mut w = WaveTrend::classic().unwrap();
|
||||
assert_eq!(w.periods(), (10, 21, 4));
|
||||
assert_eq!(w.name(), "WaveTrend");
|
||||
// 2 * 10 + 21 + 4 - 3 = 42.
|
||||
assert_eq!(w.warmup_period(), 42);
|
||||
assert!(w.value().is_none());
|
||||
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();
|
||||
for c in &candles {
|
||||
w.update(*c);
|
||||
}
|
||||
assert!(w.value().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let candles: Vec<Candle> = (0..60_i64)
|
||||
.map(|i| {
|
||||
let p = 100.0 + ((i as f64) * 0.25).sin() * 6.0;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
let mut w = WaveTrend::new(5, 8, 3).unwrap();
|
||||
let warmup = 2 * 5 + 8 + 3 - 3; // 18
|
||||
assert_eq!(w.warmup_period(), warmup);
|
||||
let out = w.batch(&candles);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero_lines() {
|
||||
// Flat market: every ap equals esa within an ULP, so the
|
||||
// flat-tolerance guard collapses ci to 0 and both lines remain at 0.
|
||||
let candles: Vec<Candle> = (0..80_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
|
||||
let mut w = WaveTrend::new(5, 8, 3).unwrap();
|
||||
let last = w.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last.wt1, 0.0);
|
||||
assert_eq!(last.wt2, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_positive() {
|
||||
let candles: Vec<Candle> = (0..120_i64)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64) * 0.5;
|
||||
candle(base + 1.0, base - 0.5, base + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut w = WaveTrend::classic().unwrap();
|
||||
let last = w.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last.wt1 > 0.0,
|
||||
"uptrend wt1 should be positive, got {}",
|
||||
last.wt1
|
||||
);
|
||||
assert!(
|
||||
last.wt2 > 0.0,
|
||||
"uptrend wt2 should be positive, got {}",
|
||||
last.wt2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_downtrend_is_negative() {
|
||||
let candles: Vec<Candle> = (0..120_i64)
|
||||
.map(|i| {
|
||||
let base = 200.0 - (i as f64) * 0.5;
|
||||
candle(base + 1.0, base - 0.5, base - 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let mut w = WaveTrend::classic().unwrap();
|
||||
let last = w.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last.wt1 < 0.0);
|
||||
assert!(last.wt2 < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_remain_finite() {
|
||||
let candles: Vec<Candle> = (0..200_i64)
|
||||
.map(|i| {
|
||||
let p = 100.0 + ((i as f64) * 0.3).sin() * 8.0;
|
||||
candle(p + 2.0, p - 2.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
let mut w = WaveTrend::classic().unwrap();
|
||||
for v in w.batch(&candles).into_iter().flatten() {
|
||||
assert!(v.wt1.is_finite() && v.wt2.is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120_i64)
|
||||
.map(|i| {
|
||||
let p = 100.0 + ((i as f64) * 0.27).sin() * 6.0;
|
||||
candle(p + 1.5, p - 1.5, p, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = WaveTrend::classic().unwrap();
|
||||
let mut b = WaveTrend::classic().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..80_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
|
||||
let mut w = WaveTrend::classic().unwrap();
|
||||
w.batch(&candles);
|
||||
assert!(w.is_ready());
|
||||
w.reset();
|
||||
assert!(!w.is_ready());
|
||||
assert_eq!(w.update(candles[0]), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user