diff --git a/CHANGELOG.md b/CHANGELOG.md index aae16b9d..62317d97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **ADXR (Average Directional Movement Index Rating)** in the Trend & + Directional family. Wilder's directional-strength smoother: the + average of the current `ADX` and the `ADX` from `period - 1` bars + ago. Warmup is `3 * period - 1` (e.g. 41 for the default `period = + 14`). Shipped across all four bindings (Rust core, Python, Node, + WASM) plus fuzz/test/bench coverage. +- **Random Walk Index (RWI)** in the Trend & Directional family. Mike + Poulos' trend-vs.-random-walk gauge: for each lookback `i ∈ [2, + period]` the ratio of actual displacement to the random-walk + expectation `ATR_i * sqrt(i)` is taken; the per-bar output is the + maximum across lookbacks for both the high (`RWI_High`) and low + (`RWI_Low`) directions. Multi-output `(high, low)` across all four + bindings; warmup `= period`. +- **Trend Intensity Index (TII)** in the Trend & Directional family. + M.H. Pee's `[0, 100]` oscillator: the share of the most recent + `dev_period` SMA-deviations that are positive, scaled to + `[0, 100]`. Saturates at 100 on a pure uptrend, at 0 on a pure + downtrend, and returns the neutral 50 on a perfectly flat market. + Canonical Python defaults `(sma_period=60, dev_period=30)`; warmup + `= sma_period + dev_period − 1`. +- **Wave Trend Oscillator (LazyBear)** in the Trend & Directional + family. Two-line mean-reverting momentum gauge built from the + typical price and three cascaded EMAs: + `esa = EMA(ap, channel)`, `d = EMA(|ap − esa|, channel)`, + `ci = (ap − esa) / (0.015 · d)`, `wt1 = EMA(ci, average)`, + `wt2 = SMA(wt1, signal)`. `WaveTrend::classic()` exposes the + LazyBear defaults `(channel = 10, average = 21, signal = 4)`; + warmup `= 2 · channel + average + signal − 3` (42 for the classic + defaults). Includes a sub-ULP flat-tolerance guard on `ci` so a + perfectly flat market reports `(0, 0)` instead of the + mathematically indeterminate `−1 / 0.015 = −66.67`. Multi-output + `(wt1, wt2)` across all four bindings. - **Family 05 — Bands & Channels (11 new indicators).** Eleven additional price-envelope overlays organised into the new "Bands & Channels" family, exposed across all four bindings (Rust, Python, Node, WASM): diff --git a/README.md b/README.md index b6fc5d9e..42a12868 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -107 streaming-first indicators across nine families. Every one passes the +111 streaming-first indicators across nine families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -117,7 +117,7 @@ semantics tests. |--------|-----------| | Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA | | Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia | -| Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter | +| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter | | Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC | | Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility | | Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands | @@ -196,7 +196,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 107 indicators +│ ├── wickra-core/ core engine + all 111 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index dd42f31a..9a23500f 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -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) }, diff --git a/bindings/node/index.js b/bindings/node/index.js index 1f713e53..574a8ad8 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -338,6 +338,7 @@ module.exports.ATR = ATR module.exports.Stochastic = Stochastic module.exports.OBV = OBV module.exports.ADX = ADX +module.exports.ADXR = ADXR module.exports.CCI = CCI module.exports.WilliamsR = WilliamsR module.exports.MFI = MFI @@ -372,6 +373,7 @@ module.exports.STC = STC module.exports.T3 = T3 module.exports.TSI = TSI module.exports.PMO = PMO +module.exports.TII = TII module.exports.ADL = ADL module.exports.VolumePriceTrend = VolumePriceTrend module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow @@ -399,6 +401,8 @@ module.exports.NATR = NATR module.exports.HistoricalVolatility = HistoricalVolatility module.exports.AroonOscillator = AroonOscillator module.exports.Vortex = Vortex +module.exports.RWI = RWI +module.exports.WaveTrend = WaveTrend module.exports.MassIndex = MassIndex module.exports.StochRSI = StochRSI module.exports.UltimateOscillator = UltimateOscillator diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index fdf493a5..a38f56ce 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -528,6 +528,58 @@ impl AdxNode { } } +#[napi(js_name = "ADXR")] +pub struct AdxrNode { + inner: wc::Adxr, +} + +#[napi] +impl AdxrNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::Adxr::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n]; + for i in 0..n { + if let Some(v) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i] = v; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + #[napi(js_name = "CCI")] pub struct CciNode { inner: wc::Cci, @@ -1347,6 +1399,12 @@ impl KstNode { .map_err(map_err)?, }) } + #[napi(factory)] + pub fn classic() -> Self { + Self { + inner: wc::Kst::classic(), + } + } #[napi] pub fn update(&mut self, value: f64) -> Option { self.inner.update(value).map(|o| KstValue { @@ -2126,6 +2184,43 @@ impl PmoNode { // ============================== VWMA ============================== +// ============================== TII ============================== + +#[napi(js_name = "TII")] +pub struct TiiNode { + inner: wc::Tii, +} + +#[napi] +impl TiiNode { + #[napi(constructor)] + pub fn new(sma_period: u32, dev_period: u32) -> napi::Result { + Ok(Self { + inner: wc::Tii::new(sma_period as usize, dev_period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + // ============================== ADL ============================== #[napi(js_name = "ADL")] @@ -3805,6 +3900,155 @@ pub struct VortexValue { pub minus: f64, } +/// Random Walk Index pair: `RWI_High` and `RWI_Low`. +#[napi(object)] +pub struct RwiValue { + pub high: f64, + pub low: f64, +} + +/// Wave Trend Oscillator pair: `wt1` (channel index) and `wt2` (signal SMA). +#[napi(object)] +pub struct WaveTrendValue { + pub wt1: f64, + pub wt2: f64, +} + +#[napi(js_name = "WaveTrend")] +pub struct WaveTrendNode { + inner: wc::WaveTrend, +} + +#[napi] +impl WaveTrendNode { + #[napi(constructor)] + pub fn new(channel_period: u32, average_period: u32, signal_period: u32) -> napi::Result { + Ok(Self { + inner: wc::WaveTrend::new( + channel_period as usize, + average_period as usize, + signal_period as usize, + ) + .map_err(map_err)?, + }) + } + #[napi(factory)] + pub fn classic() -> napi::Result { + Ok(Self { + inner: wc::WaveTrend::classic().map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| WaveTrendValue { + wt1: o.wt1, + wt2: o.wt2, + })) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 2] = o.wt1; + out[i * 2 + 1] = o.wt2; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "RWI")] +pub struct RwiNode { + inner: wc::Rwi, +} + +#[napi] +impl RwiNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::Rwi::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| RwiValue { + high: o.high, + low: o.low, + })) + } + /// Returns `[high0, low0, high1, low1, ...]`, length `2 * n`. Warmup is NaN. + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 2] = o.high; + out[i * 2 + 1] = o.low; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + #[napi(js_name = "Vortex")] pub struct VortexNode { inner: wc::Vortex, diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index d437508c..c23cd408 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -53,6 +53,7 @@ from ._wickra import ( ROC, WilliamsR, ADX, + ADXR, MFI, TRIX, AwesomeOscillator, @@ -61,6 +62,8 @@ from ._wickra import ( CMO, TSI, PMO, + TII, + KST, StochRSI, UltimateOscillator, RVI, @@ -81,6 +84,8 @@ from ._wickra import ( Coppock, AroonOscillator, Vortex, + RWI, + WaveTrend, MassIndex, AcceleratorOscillator, BalanceOfPower, @@ -171,6 +176,7 @@ __all__ = [ "ROC", "WilliamsR", "ADX", + "ADXR", "MFI", "TRIX", "AwesomeOscillator", @@ -179,6 +185,8 @@ __all__ = [ "CMO", "TSI", "PMO", + "TII", + "KST", "StochRSI", "UltimateOscillator", "RVI", @@ -199,6 +207,8 @@ __all__ = [ "Coppock", "AroonOscillator", "Vortex", + "RWI", + "WaveTrend", "MassIndex", "AcceleratorOscillator", "BalanceOfPower", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index a5473e8d..24c1c39f 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1066,6 +1066,12 @@ impl PyKst { .map_err(map_err)?, }) } + #[staticmethod] + fn classic() -> Self { + Self { + inner: wc::Kst::classic(), + } + } fn update(&mut self, value: f64) -> Option<(f64, f64)> { self.inner.update(value).map(|o| (o.kst, o.signal)) } @@ -2173,6 +2179,80 @@ impl PyAdx { } } +// ============================== ADXR ============================== + +#[pyclass(name = "ADXR", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyAdxr { + inner: wc::Adxr, +} + +#[pymethods] +impl PyAdxr { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Adxr::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(v) = self.inner.update(candle) { + out[i] = v; + } + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("ADXR(period={})", self.inner.period()) + } +} + // ============================== MFI ============================== #[pyclass(name = "MFI", module = "wickra._wickra", skip_from_py_object)] @@ -3359,6 +3439,162 @@ impl PyVortex { } } +// ============================== RWI ============================== + +#[pyclass(name = "RWI", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRwi { + inner: wc::Rwi, +} + +#[pymethods] +impl PyRwi { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Rwi::new(period).map_err(map_err)?, + }) + } + /// Returns `(high, low)` or `None` during warmup. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.high, o.low))) + } + /// Batch over high/low/close numpy columns. Returns shape `(n, 2)` for `[high, low]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 2] = o.high; + out[i * 2 + 1] = o.low; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("RWI(period={})", self.inner.period()) + } +} + +// ============================== WaveTrend ============================== + +#[pyclass(name = "WaveTrend", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyWaveTrend { + inner: wc::WaveTrend, +} + +#[pymethods] +impl PyWaveTrend { + #[new] + #[pyo3(signature = (channel_period=10, average_period=21, signal_period=4))] + fn new(channel_period: usize, average_period: usize, signal_period: usize) -> PyResult { + Ok(Self { + inner: wc::WaveTrend::new(channel_period, average_period, signal_period) + .map_err(map_err)?, + }) + } + #[staticmethod] + fn classic() -> PyResult { + Ok(Self { + inner: wc::WaveTrend::classic().map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.wt1, o.wt2))) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 2] = o.wt1; + out[i * 2 + 1] = o.wt2; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray(py)) + } + #[getter] + fn periods(&self) -> (usize, usize, usize) { + self.inner.periods() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (cp, ap, sp) = self.inner.periods(); + format!("WaveTrend(channel_period={cp}, average_period={ap}, signal_period={sp})") + } +} + // ============================== Mass Index ============================== #[pyclass(name = "MassIndex", module = "wickra._wickra", skip_from_py_object)] @@ -3928,6 +4164,59 @@ impl PyPmo { } } +// ============================== TII ============================== + +#[pyclass(name = "TII", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTii { + inner: wc::Tii, +} + +#[pymethods] +impl PyTii { + #[new] + #[pyo3(signature = (sma_period=60, dev_period=30))] + fn new(sma_period: usize, dev_period: usize) -> PyResult { + Ok(Self { + inner: wc::Tii::new(sma_period, dev_period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray(py)) + } + #[getter] + fn periods(&self) -> (usize, usize) { + self.inner.periods() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (s, d) = self.inner.periods(); + format!("TII(sma_period={s}, dev_period={d})") + } +} + // ============================== ZLEMA ============================== #[pyclass(name = "ZLEMA", module = "wickra._wickra", skip_from_py_object)] @@ -6705,6 +6994,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -6723,6 +7013,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -6730,6 +7022,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index afb5a90a..16127378 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -54,6 +54,7 @@ SCALAR = [ (ta.CMO, (14,)), (ta.TSI, (25, 13)), (ta.PMO, (35, 20)), + (ta.TII, (20, 10)), (ta.StochRSI, (14, 14)), (ta.PPO, (12, 26)), (ta.APO, (12, 26)), @@ -196,6 +197,10 @@ CANDLE_SCALAR = { lambda: ta.ChaikinVolatility(10, 10), lambda ind, h, l, c, v: ind.batch(h, l), ), + "ADXR": ( + lambda: ta.ADXR(7), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), "ParkinsonVolatility": ( lambda: ta.ParkinsonVolatility(20, 252), lambda ind, h, l, c, v: ind.batch(h, l), @@ -245,6 +250,11 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv): MULTI = { "Vortex": (lambda: ta.Vortex(14), lambda ind, h, l, c, v: ind.batch(h, l, c)), + "RWI": (lambda: ta.RWI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)), + "WaveTrend": ( + lambda: ta.WaveTrend.classic(), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), "SuperTrend": ( lambda: ta.SuperTrend(10, 3.0), lambda ind, h, l, c, v: ind.batch(h, l, c), @@ -489,6 +499,66 @@ def test_linreg_angle_reference(): assert out[4] == pytest.approx(45.0) +def test_wave_trend_flat_market_yields_zero(): + # On a perfectly flat market the flat-tolerance guard keeps both lines + # at exactly zero (otherwise the ratio ci = (ap - esa) / (0.015 * d) + # would explode on the first esa ULP). + out = ta.WaveTrend.classic().batch( + np.full(80, 10.0), np.full(80, 10.0), np.full(80, 10.0) + ) + last = out[~np.isnan(out[:, 0])][-1] + assert last[0] == 0.0 + assert last[1] == 0.0 + + +def test_kst_classic_constants_yield_zero(): + out = ta.KST.classic().batch(np.full(120, 100.0)) + last_row = out[~np.isnan(out[:, 0])][-1] + assert last_row[0] == pytest.approx(0.0) + assert last_row[1] == pytest.approx(0.0) + + +def test_tii_pure_uptrend_saturates_at_100(): + # On a strictly increasing series every close sits above the lagging + # SMA, so every deviation is positive and TII reaches 100. + prices = np.arange(80, dtype=np.float64) + 100.0 + out = ta.TII(10, 5).batch(prices) + last = out[~np.isnan(out)][-1] + assert last == pytest.approx(100.0) + + +def test_tii_flat_market_yields_50(): + out = ta.TII(5, 4).batch(np.full(30, 10.0)) + last = out[~np.isnan(out)][-1] + assert last == 50.0 + + +def test_rwi_reference_uptrend_dominates_low_line(): + # In a pure linear uptrend RWI_High >> RWI_Low. + n = 60 + base = np.arange(n, dtype=np.float64) * 2.0 + 100.0 + high = base + 1.0 + low = base - 0.5 + close = base + 0.5 + out = ta.RWI(14).batch(high, low, close) + last_row = out[~np.isnan(out[:, 0])][-1] + assert last_row[0] > last_row[1], f"RWI_High {last_row[0]} must dominate RWI_Low {last_row[1]}" + assert last_row[0] > 1.0 + + +def test_adxr_reference_on_pure_uptrend(): + # On a pure linear uptrend ADX saturates at 100, so ADXR (average of two + # saturated ADX values period-1 bars apart) also reads 100. + n = 100 + base = np.arange(n, dtype=np.float64) * 2.0 + 100.0 + high = base + 1.0 + low = base - 0.5 + close = base + 0.5 + out = ta.ADXR(5).batch(high, low, close) + last = out[~np.isnan(out)][-1] + assert last == pytest.approx(100.0) + + def test_z_score_reference(): # Window [1, 3]: mean 2, population stddev 1; latest 3 -> z = 1. out = ta.ZScore(2).batch(np.array([1.0, 3.0])) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 42ea3349..7e1b94fc 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -88,6 +88,75 @@ wasm_scalar_indicator!(WasmMom, "MOM", wc::Mom, period: usize); wasm_scalar_indicator!(WasmCmo, "CMO", wc::Cmo, period: usize); wasm_scalar_indicator!(WasmTsi, "TSI", wc::Tsi, long: usize, short: usize); wasm_scalar_indicator!(WasmPmo, "PMO", wc::Pmo, smoothing1: usize, smoothing2: usize); +wasm_scalar_indicator!(WasmTii, "TII", wc::Tii, sma_period: usize, dev_period: usize); + +#[wasm_bindgen(js_name = KST)] +pub struct WasmKst { + inner: wc::Kst, +} + +#[wasm_bindgen(js_class = KST)] +impl WasmKst { + #[wasm_bindgen(constructor)] + #[allow(clippy::too_many_arguments)] + pub fn new( + roc1: usize, + roc2: usize, + roc3: usize, + roc4: usize, + sma1: usize, + sma2: usize, + sma3: usize, + sma4: usize, + signal_period: usize, + ) -> Result { + Ok(Self { + inner: wc::Kst::new( + roc1, + roc2, + roc3, + roc4, + sma1, + sma2, + sma3, + sma4, + signal_period, + ) + .map_err(map_err)?, + }) + } + pub fn classic() -> WasmKst { + Self { + inner: wc::Kst::classic(), + } + } + pub fn update(&mut self, value: f64) -> JsValue { + match self.inner.update(value) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"kst".into(), &o.kst.into()).ok(); + Reflect::set(&obj, &"signal".into(), &o.signal.into()).ok(); + obj.into() + } + None => JsValue::NULL, + } + } + /// Returns `[kst0, signal0, kst1, signal1, ...]`, length `2 * n`. Warmup is NaN. + pub fn batch(&mut self, prices: &[f64]) -> Float64Array { + let n = prices.len(); + let mut out = vec![f64::NAN; n * 2]; + for (i, &p) in prices.iter().enumerate() { + if let Some(o) = self.inner.update(p) { + out[i * 2] = o.kst; + out[i * 2 + 1] = o.signal; + } + } + Float64Array::from(out.as_slice()) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} wasm_scalar_indicator!(WasmStochRsi, "StochRSI", wc::StochRsi, rsi_period: usize, stoch_period: usize); wasm_scalar_indicator!(WasmDpo, "DPO", wc::Dpo, period: usize); wasm_scalar_indicator!(WasmPpo, "PPO", wc::Ppo, fast: usize, slow: usize); @@ -568,68 +637,6 @@ impl WasmSmi { } } -#[wasm_bindgen(js_name = KST)] -pub struct WasmKst { - inner: wc::Kst, -} - -#[wasm_bindgen(js_class = KST)] -impl WasmKst { - #[wasm_bindgen(constructor)] - #[allow(clippy::too_many_arguments)] - pub fn new( - roc1: usize, - roc2: usize, - roc3: usize, - roc4: usize, - sma1: usize, - sma2: usize, - sma3: usize, - sma4: usize, - signal: usize, - ) -> Result { - Ok(Self { - inner: wc::Kst::new(roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal) - .map_err(map_err)?, - }) - } - /// Returns `[kst0, signal0, kst1, signal1, ...]`, length `2n`. - pub fn batch(&mut self, prices: &[f64]) -> Float64Array { - let n = prices.len(); - let mut out = vec![f64::NAN; n * 2]; - for (i, p) in prices.iter().enumerate() { - if let Some(o) = self.inner.update(*p) { - out[i * 2] = o.kst; - out[i * 2 + 1] = o.signal; - } - } - Float64Array::from(out.as_slice()) - } - /// Streaming update. Returns `{ kst, signal }` once warm, else `null`. - pub fn update(&mut self, value: f64) -> JsValue { - match self.inner.update(value) { - Some(o) => { - let obj = Object::new(); - Reflect::set(&obj, &"kst".into(), &o.kst.into()).ok(); - Reflect::set(&obj, &"signal".into(), &o.signal.into()).ok(); - obj.into() - } - None => JsValue::NULL, - } - } - pub fn reset(&mut self) { - self.inner.reset(); - } - #[wasm_bindgen(js_name = isReady)] - pub fn is_ready(&self) -> bool { - self.inner.is_ready() - } - #[wasm_bindgen(js_name = warmupPeriod)] - pub fn warmup_period(&self) -> usize { - self.inner.warmup_period() - } -} - #[wasm_bindgen(js_name = PGO)] pub struct WasmPgo { inner: wc::Pgo, @@ -1889,6 +1896,117 @@ impl WasmVortex { } } +#[wasm_bindgen(js_name = WaveTrend)] +pub struct WasmWaveTrend { + inner: wc::WaveTrend, +} + +#[wasm_bindgen(js_class = WaveTrend)] +impl WasmWaveTrend { + #[wasm_bindgen(constructor)] + pub fn new( + channel_period: usize, + average_period: usize, + signal_period: usize, + ) -> Result { + Ok(Self { + inner: wc::WaveTrend::new(channel_period, average_period, signal_period) + .map_err(map_err)?, + }) + } + pub fn classic() -> Result { + Ok(Self { + inner: wc::WaveTrend::classic().map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"wt1".into(), &o.wt1.into()).ok(); + Reflect::set(&obj, &"wt2".into(), &o.wt2.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 2] = o.wt1; + out[i * 2 + 1] = o.wt2; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = RWI)] +pub struct WasmRwi { + inner: wc::Rwi, +} + +#[wasm_bindgen(js_class = RWI)] +impl WasmRwi { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::Rwi::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"high".into(), &o.high.into()).ok(); + Reflect::set(&obj, &"low".into(), &o.low.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Returns `[high0, low0, high1, low1, ...]`, length `2 * n`. Warmup is NaN. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 2] = o.high; + out[i * 2 + 1] = o.low; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + #[wasm_bindgen(js_name = MassIndex)] pub struct WasmMassIndex { inner: wc::MassIndex, @@ -2058,6 +2176,52 @@ impl WasmAdx { } } +#[wasm_bindgen(js_name = ADXR)] +pub struct WasmAdxr { + inner: wc::Adxr, +} + +#[wasm_bindgen(js_class = ADXR)] +impl WasmAdxr { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::Adxr::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, JsError> { + let c = make_candle(high, low, close, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + #[wasm_bindgen(js_name = WilliamsR)] pub struct WasmWilliamsR { inner: wc::WilliamsR, diff --git a/crates/wickra-core/src/indicators/adxr.rs b/crates/wickra-core/src/indicators/adxr.rs new file mode 100644 index 00000000..41b1f28a --- /dev/null +++ b/crates/wickra-core/src/indicators/adxr.rs @@ -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, + last: Option, +} + +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 { + 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 { + self.last + } +} + +impl Indicator for Adxr { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + 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 = (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 = (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 = (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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (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); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 87a4c06b..b31f7d21 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -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; diff --git a/crates/wickra-core/src/indicators/rwi.rs b/crates/wickra-core/src/indicators/rwi.rs new file mode 100644 index 00000000..897c0adf --- /dev/null +++ b/crates/wickra-core/src/indicators/rwi.rs @@ -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, + /// Rolling window of `period` true-range values aligned with `candles` + /// after the first bar (so `tr[0]` corresponds to `candles[1]`). + trs: VecDeque, + last: Option, +} + +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 { + 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 { + self.last + } +} + +impl Indicator for Rwi { + type Input = Candle; + type Output = RwiOutput; + + fn update(&mut self, candle: Candle) -> Option { + // 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 = 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::() / (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 = (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 = (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 = (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 = (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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (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); + } +} diff --git a/crates/wickra-core/src/indicators/tii.rs b/crates/wickra-core/src/indicators/tii.rs new file mode 100644 index 00000000..e4c50fa9 --- /dev/null +++ b/crates/wickra-core/src/indicators/tii.rs @@ -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, + sum_pos: f64, + sum_neg: f64, + last: Option, +} + +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 { + 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 { + self.last + } +} + +impl Indicator for Tii { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + 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 = (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 = (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 = (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 = (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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut t = Tii::new(5, 4).unwrap(); + t.batch(&(1..=30).map(f64::from).collect::>()); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/wave_trend.rs b/crates/wickra-core/src/indicators/wave_trend.rs new file mode 100644 index 00000000..cf841150 --- /dev/null +++ b/crates/wickra-core/src/indicators/wave_trend.rs @@ -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, +} + +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 { + 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::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 { + self.last + } +} + +impl Indicator for WaveTrend { + type Input = Candle; + type Output = WaveTrendOutput; + + fn update(&mut self, candle: Candle) -> Option { + 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 = (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 = (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 = (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 = (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 = (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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (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); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 8fe46236..0570feb0 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -44,7 +44,7 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ - AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, Adl, Adx, AdxOutput, + AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, Adl, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands, BollingerBandwidth, BollingerOutput, Cci, Cfo, ChaikinMoneyFlow, @@ -56,13 +56,14 @@ pub use indicators::{ KeltnerOutput, Kst, KstOutput, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Obv, ParkinsonVolatility, PercentB, - Pgo, Pmo, Ppo, Psar, Roc, RogersSatchellVolatility, RollingVwap, Rsi, Rvi, RviVolatility, Sma, - Smi, Smma, StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, - StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima, - Trix, TrueRange, Tsi, TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, - UltimateOscillator, VerticalHorizontalFilter, Vidya, VolumePriceTrend, Vortex, VortexOutput, - Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, WeightedClose, WilliamsR, Wma, - YangZhangVolatility, ZScore, ZeroLagMacd, ZeroLagMacdOutput, Zlema, T3, + Pgo, Pmo, Ppo, Psar, Roc, RogersSatchellVolatility, RollingVwap, Rsi, Rvi, RviVolatility, Rwi, + RwiOutput, Sma, Smi, Smma, StandardErrorBands, StandardErrorBandsOutput, StarcBands, + StarcBandsOutput, Stc, StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, + SuperTrendOutput, Tema, Tii, Trima, Trix, TrueRange, Tsi, TtmSqueeze, TtmSqueezeOutput, + TypicalPrice, UlcerIndex, UltimateOscillator, VerticalHorizontalFilter, Vidya, + VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, + WaveTrend, WaveTrendOutput, WeightedClose, WilliamsR, Wma, YangZhangVolatility, ZScore, + ZeroLagMacd, ZeroLagMacdOutput, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 2d4e5c89..23fb2010 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -19,11 +19,12 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use std::hint::black_box; use wickra::{ - AccelerationBands, Alma, Atr, AtrBands, BatchExt, BollingerBands, Candle, DoubleBollinger, Ema, - FractalChaosBands, Frama, GarmanKlassVolatility, HurstChannel, Indicator, Jma, LinRegChannel, - MaEnvelope, MacdIndicator, McGinleyDynamic, Obv, ParkinsonVolatility, Pgo, - RogersSatchellVolatility, Rsi, Rvi, RviVolatility, Sma, StandardErrorBands, StarcBands, - Stochastic, TtmSqueeze, Vidya, VwapStdDevBands, Wma, YangZhangVolatility, + AccelerationBands, Adxr, Alma, Atr, AtrBands, BatchExt, BollingerBands, Candle, + DoubleBollinger, Ema, FractalChaosBands, Frama, GarmanKlassVolatility, HurstChannel, Indicator, + Jma, Kst, LinRegChannel, MaEnvelope, MacdIndicator, McGinleyDynamic, Obv, ParkinsonVolatility, + Pgo, RogersSatchellVolatility, Rsi, Rvi, RviVolatility, Rwi, Sma, StandardErrorBands, + StarcBands, Stochastic, Tii, TtmSqueeze, Vidya, VwapStdDevBands, WaveTrend, Wma, + YangZhangVolatility, }; use wickra_data::csv::CandleReader; @@ -76,6 +77,24 @@ where group.finish(); } +fn bench_kst(c: &mut Criterion, prices: &[f64]) { + let mut group = c.benchmark_group("kst"); + for &n in SIZES { + let n = n.min(prices.len()); + let series = &prices[..n]; + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, prices| { + b.iter(|| { + let mut ind = Kst::classic(); + for p in prices { + black_box(ind.update(*p)); + } + }); + }); + } + group.finish(); +} + fn bench_macd(c: &mut Criterion, prices: &[f64]) { let mut group = c.benchmark_group("macd"); for &n in SIZES { @@ -142,6 +161,7 @@ fn benches(c: &mut Criterion) { bench_scalar(c, "ema", &closes, || Ema::new(14).unwrap()); bench_scalar(c, "wma", &closes, || Wma::new(14).unwrap()); bench_scalar(c, "rsi", &closes, || Rsi::new(14).unwrap()); + bench_scalar(c, "tii", &closes, || Tii::new(60, 30).unwrap()); bench_scalar(c, "alma", &closes, || Alma::new(9, 0.85, 6.0).unwrap()); bench_scalar(c, "mcginley_dynamic", &closes, || { McGinleyDynamic::new(10).unwrap() @@ -150,8 +170,12 @@ fn benches(c: &mut Criterion) { bench_scalar(c, "vidya", &closes, || Vidya::new(14, 9).unwrap()); bench_scalar(c, "jma", &closes, || Jma::new(14, 0.0, 2).unwrap()); bench_macd(c, &closes); + bench_kst(c, &closes); bench_bollinger(c, &closes); bench_candle_input(c, "atr", &candles, || Atr::new(14).unwrap()); + bench_candle_input(c, "adxr", &candles, || Adxr::new(14).unwrap()); + bench_candle_input(c, "rwi", &candles, || Rwi::new(14).unwrap()); + bench_candle_input(c, "wave_trend", &candles, || WaveTrend::classic().unwrap()); bench_candle_input(c, "stochastic", &candles, Stochastic::classic); bench_candle_input(c, "obv", &candles, Obv::new); diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index 68ce881b..13ee9c90 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -19,8 +19,8 @@ use wickra_core::{ ElderImpulse, Ema, Frama, HistoricalVolatility, Hma, Indicator, Jma, Kama, Kst, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, RviVolatility, Sma, Smma, StandardErrorBands, Stc, - StdDev, StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, Wma, - ZScore, ZeroLagMacd, Zlema, + StdDev, StochRsi, T3, Tema, Tii, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, + Wma, ZScore, ZeroLagMacd, Zlema, }; /// Drive a single streaming + batch run through one scalar indicator. Marked @@ -65,6 +65,7 @@ fuzz_target!(|data: Vec| { drive(|| Cmo::new(14).unwrap(), &data); drive(|| Tsi::new(25, 13).unwrap(), &data); drive(|| Pmo::new(35, 20).unwrap(), &data); + drive(|| Tii::new(60, 30).unwrap(), &data); drive(|| StochRsi::new(14, 14).unwrap(), &data); drive(|| Dpo::new(14).unwrap(), &data); drive(|| Ppo::new(12, 26).unwrap(), &data); diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index 0ae8f119..85806b2f 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -23,14 +23,14 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - AccelerationBands, AcceleratorOscillator, Adl, Adx, Alligator, Aroon, AroonOscillator, Atr, - AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, + 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, Smi, + Natr, Obv, ParkinsonVolatility, Pgo, Psar, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, Smi, StarcBands, Stochastic, SuperTrend, TrueRange, TtmSqueeze, TypicalPrice, UltimateOscillator, - VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, WeightedClose, WilliamsR, + VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, WaveTrend, WeightedClose, WilliamsR, YangZhangVolatility, }; @@ -94,10 +94,13 @@ fuzz_target!(|data: Vec| { // --- 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);