diff --git a/CHANGELOG.md b/CHANGELOG.md index e20f943f..97f85ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Family 02 — Momentum Oscillators.** `Inertia` (Dorsey): a + `LinearRegression` smoothing of the `RVI` series — preserves trend + direction while damping the underlying ratio. Candle input, two + parameters `(rvi_period, linreg_period)` (defaults 14 / 20). Exposed + in all four bindings. +- **Family 02 — Momentum Oscillators.** `ConnorsRsi`: Larry Connors' + 3-component aggregate — `RSI(close)`, `RSI(streak)`, and the + percentile rank of the 1-bar return over the recent `period_rank` + returns. Bounded in `[0, 100]`. Three parameters + `(period_rsi, period_streak, period_rank)` (defaults 3 / 2 / 100). + Exposed in all four bindings. +- **Family 02 — Momentum Oscillators.** `LaguerreRsi` (Ehlers): + four-stage Laguerre polynomial filter wrapped in an RSI-style up/down + accumulator. Single parameter `gamma` in `[0, 1]` (default 0.5) trades + lag for smoothness. State is seeded to the first input so a constant + series stays at the neutral 50. Output clamped to `[0, 100]`. Exposed + in all four bindings. +- **Family 02 — Momentum Oscillators.** `SMI` (Stochastic Momentum + Index, Blau): doubly-`EMA`-smoothed bounded oscillator measuring the + close's displacement from the centre of the recent high-low range, + scaled by the smoothed range. Candle input, three parameters + `(period, d_period, d2_period)` (defaults 5 / 3 / 3). Exposed in all + four bindings. +- **Family 02 — Momentum Oscillators.** `KST` (Know Sure Thing, Pring): + weighted sum of four `SMA`-smoothed `ROC` series with Pring's fixed + weights `1, 2, 3, 4`, plus an `SMA` signal line. Nine parameters + (four ROC periods, four SMA periods, signal period); `Kst::classic()` + uses Pring's recommended defaults. Multi-output indicator emitting + `KstOutput { kst, signal }`. Exposed in all four bindings. +- **Family 02 — Momentum Oscillators.** `PGO` (Pretty Good Oscillator, + Mark Johnson): `(close − SMA(close, period)) / EMA(TR, period)`. + Candle input, single parameter `period` (default 14). Roughly counts + how many ATR-equivalents the close is from its mean. Exposed in all + four bindings. +- **Family 02 — Momentum Oscillators.** `RVI` (Relative Vigor Index, + Dorsey): per-bar ratio `SMA(close - open, period) / SMA(high - low, + period)`. Candle input, single parameter `period` (default 10). + Positive on average-bullish windows, negative on average-bearish. + Holds previous value if the entire window has zero range. Exposed in + all four bindings. - **Family 01 — Moving Averages.** `ALMA` (Arnaud Legoux Moving Average): Gaussian-weighted moving average with configurable centre (`offset` in `[0, 1]`) and kernel width (`sigma > 0`). Community-standard defaults diff --git a/README.md b/README.md index 6efd2c35..4f39c5ff 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,14 @@ python -m benchmarks.compare_libraries ## Indicators -78 streaming-first indicators across eight families. Every one passes the +85 streaming-first indicators across eight families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. | Family | Indicators | |--------|-----------| | 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 | +| 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 | | Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power | | Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility | diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 828766c2..fbde1d85 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -64,6 +64,8 @@ const scalarFactories = { VerticalHorizontalFilter: () => new wickra.VerticalHorizontalFilter(28), ZScore: () => new wickra.ZScore(20), LinRegAngle: () => new wickra.LinRegAngle(14), + LaguerreRSI: () => new wickra.LaguerreRSI(0.5), + ConnorsRSI: () => new wickra.ConnorsRSI(3, 2, 100), }; for (const [name, make] of Object.entries(scalarFactories)) { @@ -91,6 +93,10 @@ const candleScalar = { AwesomeOscillator: { make: () => new wickra.AwesomeOscillator(5, 34), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, OBV: { make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, VWMA: { make: () => new wickra.VWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, + RVI: { make: () => new wickra.RVI(10), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + Inertia: { make: () => new wickra.Inertia(14, 20), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + PGO: { make: () => new wickra.PGO(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + SMI: { make: () => new wickra.SMI(5, 3, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, EVWMA: { make: () => new wickra.EVWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, UltimateOscillator: { make: () => new wickra.UltimateOscillator(7, 14, 28), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, AroonOscillator: { make: () => new wickra.AroonOscillator(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, @@ -128,6 +134,7 @@ for (const [name, d] of Object.entries(candleScalar)) { // --- Multi-output indicators: object update vs interleaved batch --- const multi = { + KST: { make: () => new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, 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) }, MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], 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) }, @@ -266,6 +273,64 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => { assert.ok(Math.abs(out[4] - 45) < 1e-9); }); +test('Inertia(3, 4) on a constant RVI series equals that RVI', () => { + const n = 60; + // Every bar (open, high, low, close) = (10, 11, 9, 10.5) -> RVI = 0.25. + const out = new wickra.Inertia(3, 4).batch( + Array(n).fill(10), + Array(n).fill(11), + Array(n).fill(9), + Array(n).fill(10.5), + ); + for (let i = 5; i < n; i++) assert.ok(Math.abs(out[i] - 0.25) < 1e-12); +}); + +test('ConnorsRSI stays bounded in [0, 100]', () => { + const prices = Array.from({ length: 250 }, (_, i) => 100 + 20 * Math.sin(i * 0.12)); + const out = new wickra.ConnorsRSI(3, 2, 100).batch(prices); + for (let i = 0; i < out.length; i++) { + if (Number.isNaN(out[i])) continue; + assert.ok(out[i] >= 0 && out[i] <= 100, `out[${i}] = ${out[i]}`); + } +}); + +test('LaguerreRSI on a flat series stays at the neutral 50', () => { + const out = new wickra.LaguerreRSI(0.5).batch(Array(40).fill(42)); + for (let i = 0; i < out.length; i++) assert.ok(Math.abs(out[i] - 50) < 1e-12); +}); + +test('SMI with close at range centre emits zero after warmup', () => { + const n = 60; + const out = new wickra.SMI(5, 3, 3).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10)); + // warmup_period = 5 + 3 + 3 - 2 = 9. + for (let i = 8; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12); +}); + +test('KST on a flat series emits zero after warmup', () => { + const kst = new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9); + const n = 80; + const out = kst.batch(Array(n).fill(42)); + const warmup = kst.warmupPeriod(); + for (let i = warmup - 1; i < n; i++) { + assert.ok(Math.abs(out[i * 2]) < 1e-12, `kst[${i}] = ${out[i * 2]}`); + assert.ok(Math.abs(out[i * 2 + 1]) < 1e-12, `signal[${i}] = ${out[i * 2 + 1]}`); + } +}); + +test('PGO(5) on a flat close emits zero after warmup', () => { + const n = 20; + const out = new wickra.PGO(5).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10)); + for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i])); + for (let i = 4; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12, `out[${i}] = ${out[i]}`); +}); + +test('RVI(2) reference value on two bars', () => { + // Bars (open, high, low, close): (10, 11, 9, 10.5), (10.5, 11.5, 10, 11). + const out = new wickra.RVI(2).batch([10, 10.5], [11, 11.5], [9, 10], [10.5, 11]); + assert.ok(Number.isNaN(out[0])); + assert.ok(Math.abs(out[1] - 1 / 3.5) < 1e-12); +}); + test('EVWMA(2) reference values on [10, 20, 30] with volumes [1, 3, 1]', () => { const out = new wickra.EVWMA(2).batch([10, 20, 30], [1, 3, 1]); assert.ok(Number.isNaN(out[0])); diff --git a/bindings/node/index.js b/bindings/node/index.js index 5534e7fe..ca15f937 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, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, 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 } = 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, 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, 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 } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -349,6 +349,13 @@ module.exports.RollingVWAP = RollingVWAP module.exports.AwesomeOscillator = AwesomeOscillator module.exports.Aroon = Aroon module.exports.KAMA = KAMA +module.exports.RVI = RVI +module.exports.PGO = PGO +module.exports.KST = KST +module.exports.SMI = SMI +module.exports.LaguerreRSI = LaguerreRSI +module.exports.ConnorsRSI = ConnorsRSI +module.exports.Inertia = Inertia module.exports.ALMA = ALMA module.exports.McGinleyDynamic = McGinleyDynamic module.exports.FRAMA = FRAMA diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index ce88a912..57d04472 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -1070,6 +1070,386 @@ impl AroonNode { } } +// Helper for OHLC-input indicators that need the open price (not provided +// by `cnd` which fakes open == close). +fn cnd4(open: f64, high: f64, low: f64, close: f64) -> napi::Result { + wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err) +} + +#[napi(js_name = "Inertia")] +pub struct InertiaNode { + inner: wc::Inertia, +} +#[napi] +impl InertiaNode { + #[napi(constructor)] + pub fn new(rvi_period: u32, linreg_period: u32) -> napi::Result { + Ok(Self { + inner: wc::Inertia::new(clamp_period(rvi_period), clamp_period(linreg_period)) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self.inner.update(cnd4(open, high, low, close)?)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if !(open.len() == high.len() && high.len() == low.len() && low.len() == close.len()) { + return Err(NapiError::from_reason( + "open, high, low and close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd4(open[i], high[i], low[i], close[i])?) + .unwrap_or(f64::NAN), + ); + } + 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 = "ConnorsRSI")] +pub struct ConnorsRsiNode { + inner: wc::ConnorsRsi, +} +#[napi] +impl ConnorsRsiNode { + #[napi(constructor)] + pub fn new(period_rsi: u32, period_streak: u32, period_rank: u32) -> napi::Result { + Ok(Self { + inner: wc::ConnorsRsi::new( + clamp_period(period_rsi), + clamp_period(period_streak), + clamp_period(period_rank), + ) + .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 + } +} + +#[napi(js_name = "LaguerreRSI")] +pub struct LaguerreRsiNode { + inner: wc::LaguerreRsi, +} +#[napi] +impl LaguerreRsiNode { + #[napi(constructor)] + pub fn new(gamma: f64) -> napi::Result { + Ok(Self { + inner: wc::LaguerreRsi::new(gamma).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 + } +} + +#[napi(js_name = "SMI")] +pub struct SmiNode { + inner: wc::Smi, +} +#[napi] +impl SmiNode { + #[napi(constructor)] + pub fn new(period: u32, d_period: u32, d2_period: u32) -> napi::Result { + Ok(Self { + inner: wc::Smi::new( + clamp_period(period), + clamp_period(d_period), + clamp_period(d2_period), + ) + .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 and close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + 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(object)] +pub struct KstValue { + pub kst: f64, + pub signal: f64, +} + +#[napi(js_name = "KST")] +pub struct KstNode { + inner: wc::Kst, +} +#[napi] +impl KstNode { + #[napi(constructor)] + #[allow(clippy::too_many_arguments)] + pub fn new( + roc1: u32, + roc2: u32, + roc3: u32, + roc4: u32, + sma1: u32, + sma2: u32, + sma3: u32, + sma4: u32, + signal: u32, + ) -> napi::Result { + Ok(Self { + inner: wc::Kst::new( + clamp_period(roc1), + clamp_period(roc2), + clamp_period(roc3), + clamp_period(roc4), + clamp_period(sma1), + clamp_period(sma2), + clamp_period(sma3), + clamp_period(sma4), + clamp_period(signal), + ) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value).map(|o| KstValue { + kst: o.kst, + signal: o.signal, + }) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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; + } + } + 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 = "PGO")] +pub struct PgoNode { + inner: wc::Pgo, +} +#[napi] +impl PgoNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::Pgo::new(clamp_period(period)).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 and close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + 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 = "RVI")] +pub struct RviNode { + inner: wc::Rvi, +} +#[napi] +impl RviNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::Rvi::new(clamp_period(period)).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self.inner.update(cnd4(open, high, low, close)?)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if !(open.len() == high.len() && high.len() == low.len() && low.len() == close.len()) { + return Err(NapiError::from_reason( + "open, high, low and close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd4(open[i], high[i], low[i], close[i])?) + .unwrap_or(f64::NAN), + ); + } + 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 = "KAMA")] pub struct KamaNode { inner: wc::Kama, diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index c4f66063..c0d05495 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -63,6 +63,13 @@ from ._wickra import ( PMO, StochRSI, UltimateOscillator, + RVI, + PGO, + KST, + SMI, + LaguerreRSI, + ConnorsRSI, + Inertia, PPO, DPO, Coppock, @@ -151,6 +158,13 @@ __all__ = [ "PMO", "StochRSI", "UltimateOscillator", + "RVI", + "PGO", + "KST", + "SMI", + "LaguerreRSI", + "ConnorsRSI", + "Inertia", "PPO", "DPO", "Coppock", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 0f67b57e..78491455 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -812,6 +812,435 @@ impl PyKama { } } +// ============================== Inertia ============================== + +#[pyclass(name = "Inertia", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyInertia { + inner: wc::Inertia, +} + +#[pymethods] +impl PyInertia { + #[new] + #[pyo3(signature = (rvi_period=14, linreg_period=20))] + fn new(rvi_period: usize, linreg_period: usize) -> PyResult { + Ok(Self { + inner: wc::Inertia::new(rvi_period, linreg_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>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + 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 !(o.len() == h.len() && h.len() == l.len() && l.len() == c.len()) { + return Err(PyValueError::new_err( + "open, high, low and close must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + 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 (r, l) = self.inner.periods(); + format!("Inertia(rvi_period={r}, linreg_period={l})") + } +} + +// ============================== Connors RSI ============================== + +#[pyclass(name = "ConnorsRSI", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyConnorsRsi { + inner: wc::ConnorsRsi, +} + +#[pymethods] +impl PyConnorsRsi { + #[new] + #[pyo3(signature = (period_rsi=3, period_streak=2, period_rank=100))] + fn new(period_rsi: usize, period_streak: usize, period_rank: usize) -> PyResult { + Ok(Self { + inner: wc::ConnorsRsi::new(period_rsi, period_streak, period_rank).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 s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + 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 (r, s, k) = self.inner.periods(); + format!("ConnorsRSI(period_rsi={r}, period_streak={s}, period_rank={k})") + } +} + +// ============================== Laguerre RSI ============================== + +#[pyclass(name = "LaguerreRSI", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyLaguerreRsi { + inner: wc::LaguerreRsi, +} + +#[pymethods] +impl PyLaguerreRsi { + #[new] + #[pyo3(signature = (gamma=0.5))] + fn new(gamma: f64) -> PyResult { + Ok(Self { + inner: wc::LaguerreRsi::new(gamma).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 s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn gamma(&self) -> f64 { + self.inner.gamma() + } + 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!("LaguerreRSI(gamma={})", self.inner.gamma()) + } +} + +// ============================== SMI ============================== + +#[pyclass(name = "SMI", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PySmi { + inner: wc::Smi, +} + +#[pymethods] +impl PySmi { + #[new] + #[pyo3(signature = (period=5, d_period=3, d2_period=3))] + fn new(period: usize, d_period: usize, d2_period: usize) -> PyResult { + Ok(Self { + inner: wc::Smi::new(period, d_period, d2_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 and close must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + 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 (p, d, d2) = self.inner.periods(); + format!("SMI(period={p}, d_period={d}, d2_period={d2})") + } +} + +// ============================== KST ============================== + +#[pyclass(name = "KST", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyKst { + inner: wc::Kst, +} + +#[pymethods] +impl PyKst { + #[new] + #[pyo3(signature = (roc1=10, roc2=15, roc3=20, roc4=30, sma1=10, sma2=10, sma3=10, sma4=15, signal=9))] + #[allow(clippy::too_many_arguments)] + fn new( + roc1: usize, + roc2: usize, + roc3: usize, + roc4: usize, + sma1: usize, + sma2: usize, + sma3: usize, + sma4: usize, + signal: usize, + ) -> PyResult { + Ok(Self { + inner: wc::Kst::new(roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal) + .map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option<(f64, f64)> { + self.inner.update(value).map(|o| (o.kst, o.signal)) + } + 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))?; + let n = slice.len(); + let mut out = vec![f64::NAN; n * 2]; + for (i, p) in slice.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 2] = o.kst; + out[i * 2 + 1] = o.signal; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray(py)) + } + 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 { + "KST".to_string() + } +} + +// ============================== PGO ============================== + +#[pyclass(name = "PGO", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyPgo { + inner: wc::Pgo, +} + +#[pymethods] +impl PyPgo { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Pgo::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 and close must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.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!("PGO(period={})", self.inner.period()) + } +} + +// ============================== RVI ============================== + +#[pyclass(name = "RVI", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRvi { + inner: wc::Rvi, +} + +#[pymethods] +impl PyRvi { + #[new] + #[pyo3(signature = (period=10))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Rvi::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>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + 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 !(o.len() == h.len() && h.len() == l.len() && l.len() == c.len()) { + return Err(PyValueError::new_err( + "open, high, low and close must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.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!("RVI(period={})", self.inner.period()) + } +} + // ============================== FRAMA ============================== #[pyclass(name = "FRAMA", module = "wickra._wickra", skip_from_py_object)] @@ -4873,13 +5302,13 @@ 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::()?; - m.add_class::()?; - m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -4939,5 +5368,12 @@ 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::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index eb5b7db2..abed1385 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -66,6 +66,83 @@ def test_rsi_wilder_textbook_first_value(): assert math.isclose(out[14], 70.464, abs_tol=0.05) +def test_inertia_constant_rvi_passes_through_linreg(): + # Every bar identical (open, high, low, close) = (10, 11, 9, 10.5): + # RVI = (c-o) / (h-l) = 0.5 / 2 = 0.25 every bar. LinReg of a constant + # series equals that constant after warmup. + n = 60 + out = ta.Inertia(3, 4).batch( + np.full(n, 10.0), np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.5) + ) + # warmup_period = 3 + 4 - 1 = 6. + np.testing.assert_allclose(out[5:], 0.25, atol=1e-12) + + +def test_connors_rsi_output_is_bounded(): + # CRSI is the average of three [0, 100] components, so the aggregate must + # also sit in [0, 100] after warmup. + prices = 100.0 + 20.0 * np.sin(np.linspace(0, 30, 250)) + out = ta.ConnorsRSI(3, 2, 100).batch(prices.astype(np.float64)) + ready = out[~np.isnan(out)] + assert ready.size > 0 + assert ready.min() >= 0.0 + assert ready.max() <= 100.0 + + +def test_laguerre_rsi_constant_series_stays_at_mid_band(): + # All four Laguerre stages seed to the first input, so subsequent flat + # inputs keep them equal and the up/down accumulator is 0 — Wickra maps + # that to the neutral 50. + out = ta.LaguerreRSI(0.5).batch(np.full(40, 42.0, dtype=np.float64)) + np.testing.assert_allclose(out, 50.0, atol=1e-12) + + +def test_smi_close_at_centre_yields_zero(): + # Close at the midpoint of a flat high/low range -> displacement is + # always zero -> SMI converges to 0. + n = 60 + out = ta.SMI(5, 3, 3).batch(np.full(n, 11.0), np.full(n, 9.0), np.full(n, 10.0)) + # warmup_period = 5 + 3 + 3 - 2 = 9. + np.testing.assert_allclose(out[8:], 0.0, atol=1e-12) + + +def test_kst_constant_series_yields_zero(): + # ROC is zero on a flat input, so every RCMA is zero, so KST and its + # signal SMA are both zero after warmup. + kst = ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9) + out = kst.batch(np.full(80, 42.0, dtype=np.float64)) + warmup = kst.warmup_period() + # Use NaN-safe comparison on the post-warmup tail. + tail = out[warmup - 1 :] + assert np.all(np.isfinite(tail)) + np.testing.assert_allclose(tail, 0.0, atol=1e-12) + + +def test_pgo_flat_close_yields_zero(): + # On a constant close the numerator (close − SMA) is zero, so PGO emits 0 + # regardless of the TR-EMA in the denominator. + n = 20 + high = np.full(n, 11.0) + low = np.full(n, 9.0) + close = np.full(n, 10.0) + out = ta.PGO(5).batch(high, low, close) + assert np.all(np.isnan(out[:4])) + np.testing.assert_allclose(out[4:], 0.0, atol=1e-12) + + +def test_rvi_reference_value_period_2(): + # Two bars: (open, high, low, close) = (10, 11, 9, 10.5), (10.5, 11.5, 10, 11). + # num = (0.5 + 0.5) = 1.0; den = (2.0 + 1.5) = 3.5; RVI = 1 / 3.5. + out = ta.RVI(2).batch( + np.array([10.0, 10.5]), + np.array([11.0, 11.5]), + np.array([9.0, 10.0]), + np.array([10.5, 11.0]), + ) + assert math.isnan(out[0]) + assert math.isclose(out[1], 1.0 / 3.5, abs_tol=1e-12) + + def test_alma_constant_series_yields_the_constant(): # ALMA's Gaussian weights are normalised, so any constant series is # reproduced exactly after warmup. diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index 169f0e45..efdfdc3b 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -68,6 +68,8 @@ SCALAR = [ (ta.VerticalHorizontalFilter, (28,)), (ta.ZScore, (20,)), (ta.LinRegAngle, (14,)), + (ta.LaguerreRSI, (0.5,)), + (ta.ConnorsRSI, (3, 2, 100)), ] @@ -92,6 +94,19 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices): CANDLE_SCALAR = { "VWMA": (lambda: ta.VWMA(20), lambda ind, h, l, c, v: ind.batch(c, v)), + "RVI": ( + # extract_candle pulls the open price from index 0 of the tuple; the + # streaming test below already builds candles with open == close, so + # match that here by passing close as the open column. + lambda: ta.RVI(10), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "Inertia": ( + lambda: ta.Inertia(14, 20), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "PGO": (lambda: ta.PGO(14), lambda ind, h, l, c, v: ind.batch(h, l, c)), + "SMI": (lambda: ta.SMI(5, 3, 3), lambda ind, h, l, c, v: ind.batch(h, l, c)), "EVWMA": (lambda: ta.EVWMA(20), lambda ind, h, l, c, v: ind.batch(c, v)), "UltimateOscillator": ( lambda: ta.UltimateOscillator(7, 14, 28), @@ -207,6 +222,18 @@ MULTI = { ), } +# --- Scalar-input, multi-output indicators -------------------------------- +# +# Same shape contract as MULTI (batch returns (n, 2)) but streaming feeds a +# single float instead of a candle tuple. + +MULTI_SCALAR_INPUT = { + "KST": ( + lambda: ta.KST(10, 15, 20, 30, 10, 10, 10, 15, 9), + lambda ind, c: ind.batch(c), + ), +} + @pytest.mark.parametrize("name", list(MULTI)) def test_multi_streaming_matches_batch(name, ohlcv): @@ -232,6 +259,22 @@ def test_multi_streaming_matches_batch(name, ohlcv): assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch" +@pytest.mark.parametrize("name", list(MULTI_SCALAR_INPUT)) +def test_multi_scalar_streaming_matches_batch(name, ohlcv): + _, _, close, _ = ohlcv + make, batch_call = MULTI_SCALAR_INPUT[name] + + batch = batch_call(make(), close) + assert batch.shape == (close.size, 2) + + streamer = make() + rows = [] + for p in close: + v = streamer.update(float(p)) + rows.append([math.nan, math.nan] if v is None else list(v)) + assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch" + + # --- Alligator (3-tuple output) ------------------------------------------- diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 53427388..b67afc75 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -102,6 +102,8 @@ wasm_scalar_indicator!(WasmLinRegSlope, "LinRegSlope", wc::LinRegSlope, period: wasm_scalar_indicator!(WasmVerticalHorizontalFilter, "VerticalHorizontalFilter", wc::VerticalHorizontalFilter, period: usize); wasm_scalar_indicator!(WasmZScore, "ZScore", wc::ZScore, period: usize); wasm_scalar_indicator!(WasmLinRegAngle, "LinRegAngle", wc::LinRegAngle, period: usize); +wasm_scalar_indicator!(WasmLaguerreRsi, "LaguerreRSI", wc::LaguerreRsi, gamma: f64); +wasm_scalar_indicator!(WasmConnorsRsi, "ConnorsRSI", wc::ConnorsRsi, period_rsi: usize, period_streak: usize, period_rank: usize); // ---------- KAMA (three params) ---------- @@ -241,6 +243,275 @@ fn make_candle(h: f64, l: f64, c: f64, v: f64) -> Result { wc::Candle::new(c, h, l, c, v, 0).map_err(map_err) } +/// Helper for OHLC-input indicators where `open` matters (`RVI`, `BalanceOfPower`). +fn make_candle_ohlc(o: f64, h: f64, l: f64, c: f64) -> Result { + wc::Candle::new(o, h, l, c, 0.0, 0).map_err(map_err) +} + +#[wasm_bindgen(js_name = SMI)] +pub struct WasmSmi { + inner: wc::Smi, +} + +#[wasm_bindgen(js_class = SMI)] +impl WasmSmi { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, d_period: usize, d2_period: usize) -> Result { + Ok(Self { + inner: wc::Smi::new(period, d_period, d2_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 and close must be equal length")); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.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 = 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, +} + +#[wasm_bindgen(js_class = PGO)] +impl WasmPgo { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::Pgo::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 and close must be equal length")); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.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 = Inertia)] +pub struct WasmInertia { + inner: wc::Inertia, +} + +#[wasm_bindgen(js_class = Inertia)] +impl WasmInertia { + #[wasm_bindgen(constructor)] + pub fn new(rvi_period: usize, linreg_period: usize) -> Result { + Ok(Self { + inner: wc::Inertia::new(rvi_period, linreg_period).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> Result, JsError> { + let c = make_candle_ohlc(open, high, low, close)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if !(open.len() == high.len() && high.len() == low.len() && low.len() == close.len()) { + return Err(JsError::new( + "open, high, low and close must be equal length", + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?; + 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 = RVI)] +pub struct WasmRvi { + inner: wc::Rvi, +} + +#[wasm_bindgen(js_class = RVI)] +impl WasmRvi { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::Rvi::new(period).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> Result, JsError> { + let c = make_candle_ohlc(open, high, low, close)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if !(open.len() == high.len() && high.len() == low.len() && low.len() == close.len()) { + return Err(JsError::new( + "open, high, low and close must be equal length", + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?; + 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 = ATR)] pub struct WasmAtr { inner: wc::Atr, diff --git a/crates/wickra-core/src/indicators/connors_rsi.rs b/crates/wickra-core/src/indicators/connors_rsi.rs new file mode 100644 index 00000000..843a1b68 --- /dev/null +++ b/crates/wickra-core/src/indicators/connors_rsi.rs @@ -0,0 +1,307 @@ +//! Connors RSI (CRSI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::rsi::Rsi; +use crate::traits::Indicator; + +/// Larry Connors' RSI — average of three short-term mean-reversion components, +/// each individually bounded in `[0, 100]` so the aggregate is too: +/// +/// 1. `RSI(close, period_rsi)` — a fast `RSI` (Connors' default `3`). +/// 2. `RSI(streak, period_streak)` — `RSI` of the current up/down run length +/// (`+1, +2, ...` for consecutive up closes, `−1, −2, ...` for down closes, +/// `0` for unchanged). Connors' default `2`. +/// 3. `PercentRank(ROC(1), period_rank)` — the percentile rank of yesterday's +/// 1-period return in the last `period_rank` returns. Connors' default `100`. +/// +/// ```text +/// CRSI = (RSI(close)_t + RSI(streak)_t + PercentRank(roc1)_t) / 3 +/// ``` +/// +/// All three components live in `[0, 100]`, so `CRSI ∈ [0, 100]`. Connors' +/// trading rule of thumb: `CRSI < 5` is oversold, `CRSI > 95` is overbought +/// — both rare conditions, hence the short lookbacks. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{ConnorsRsi, Indicator}; +/// +/// let mut crsi = ConnorsRsi::classic(); +/// let mut last = None; +/// for i in 0..200 { +/// last = crsi.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ConnorsRsi { + period_rsi: usize, + period_streak: usize, + period_rank: usize, + rsi_close: Rsi, + rsi_streak: Rsi, + prev_price: Option, + streak: f64, + /// Rolling window of the last `period_rank` 1-period returns + /// (`(price_t − price_{t-1}) / price_{t-1}`). + rocs: VecDeque, + current: Option, +} + +impl ConnorsRsi { + /// # Errors + /// Returns [`Error::PeriodZero`] if any of the three periods is zero. + pub fn new(period_rsi: usize, period_streak: usize, period_rank: usize) -> Result { + if period_rsi == 0 || period_streak == 0 || period_rank == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period_rsi, + period_streak, + period_rank, + rsi_close: Rsi::new(period_rsi)?, + rsi_streak: Rsi::new(period_streak)?, + prev_price: None, + streak: 0.0, + rocs: VecDeque::with_capacity(period_rank), + current: None, + }) + } + + /// Connors' recommended defaults: `(period_rsi = 3, period_streak = 2, period_rank = 100)`. + pub fn classic() -> Self { + Self::new(3, 2, 100).expect("classic Connors RSI parameters are valid") + } + + /// Configured `(period_rsi, period_streak, period_rank)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.period_rsi, self.period_streak, self.period_rank) + } +} + +impl Indicator for ConnorsRsi { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.current; + } + // Run the close-RSI on every input so it warms up regardless of the + // streak / percent-rank branches. + let rsi_close = self.rsi_close.update(input); + + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + + // Update the up/down streak run length. + self.streak = if input > prev { + self.streak.max(0.0) + 1.0 + } else if input < prev { + self.streak.min(0.0) - 1.0 + } else { + 0.0 + }; + let rsi_streak = self.rsi_streak.update(self.streak); + + // 1-period return; defined only when the previous price is non-zero. + if prev != 0.0 { + let roc = (input - prev) / prev; + if self.rocs.len() == self.period_rank { + self.rocs.pop_front(); + } + self.rocs.push_back(roc); + } + self.prev_price = Some(input); + + // PercentRank emits once the ROC window has filled. + let percent_rank = if self.rocs.len() == self.period_rank { + let latest = *self.rocs.back().expect("non-empty window"); + let below = self.rocs.iter().filter(|&&r| r < latest).count(); + Some(100.0 * below as f64 / self.period_rank as f64) + } else { + None + }; + + let value = (rsi_close?, rsi_streak?, percent_rank?); + let crsi = (value.0 + value.1 + value.2) / 3.0; + self.current = Some(crsi); + Some(crsi) + } + + fn reset(&mut self) { + self.rsi_close.reset(); + self.rsi_streak.reset(); + self.prev_price = None; + self.streak = 0.0; + self.rocs.clear(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // The slowest branch is the percent-rank: it needs period_rank + 1 + // prices (period_rank one-period returns). The close-RSI needs + // period_rsi + 1 prices and the streak-RSI needs period_streak + 1 + // streak values = period_streak + 2 prices. The rank branch dominates + // for Connors' defaults. + let rsi_close = self.period_rsi + 1; + let rsi_streak = self.period_streak + 2; + let rank = self.period_rank + 1; + rsi_close.max(rsi_streak).max(rank) + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "ConnorsRSI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(ConnorsRsi::new(0, 2, 100), Err(Error::PeriodZero))); + assert!(matches!(ConnorsRsi::new(3, 0, 100), Err(Error::PeriodZero))); + assert!(matches!(ConnorsRsi::new(3, 2, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let crsi = ConnorsRsi::classic(); + assert_eq!(crsi.periods(), (3, 2, 100)); + assert_eq!(crsi.name(), "ConnorsRSI"); + // Slowest branch: percent_rank with period_rank + 1 = 101. + assert_eq!(crsi.warmup_period(), 101); + } + + #[test] + fn classic_factory() { + assert_eq!(ConnorsRsi::classic().periods(), (3, 2, 100)); + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + // Use small periods so the test is fast. + let mut crsi = ConnorsRsi::new(3, 2, 5).unwrap(); + // Slowest: 5 + 1 = 6. + assert_eq!(crsi.warmup_period(), 6); + let prices: Vec = (1..=8).map(f64::from).collect(); + let out = crsi.batch(&prices); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn pure_uptrend_saturates_high() { + // A monotonic uptrend drives all three components toward 100: + // RSI of monotonic ups is 100, streak stays positive and growing so + // its RSI is 100, and every new 1-period return matches the prior + // ones so percent rank stabilises near 0 — but the average of all + // three still climbs well above 50. + let mut crsi = ConnorsRsi::classic(); + for i in 1..=200 { + crsi.update(f64::from(i)); + } + let v = crsi.current.unwrap(); + assert!( + v > 60.0, + "uptrend should drive Connors RSI well above 50: {v}" + ); + } + + #[test] + fn output_is_bounded() { + let mut crsi = ConnorsRsi::classic(); + let prices: Vec = (0..300) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 20.0) + .collect(); + for v in crsi.batch(&prices).iter().flatten() { + assert!( + (0.0..=100.0).contains(v), + "Connors RSI out of [0, 100]: {v}" + ); + } + } + + #[test] + fn streak_resets_to_zero_on_unchanged_close() { + // Helper: feed a sequence and inspect the internal streak. + let mut crsi = ConnorsRsi::new(3, 2, 100).unwrap(); + crsi.update(10.0); + crsi.update(11.0); + crsi.update(12.0); + assert_eq!(crsi.streak, 2.0); + crsi.update(12.0); + assert_relative_eq!(crsi.streak, 0.0, epsilon = 1e-12); + crsi.update(11.0); + assert_eq!(crsi.streak, -1.0); + crsi.update(10.0); + assert_eq!(crsi.streak, -2.0); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1) + .collect(); + let mut a = ConnorsRsi::classic(); + let mut b = ConnorsRsi::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut crsi = ConnorsRsi::classic(); + let prices: Vec = (1..=200).map(f64::from).collect(); + crsi.batch(&prices); + assert!(crsi.is_ready()); + crsi.reset(); + assert!(!crsi.is_ready()); + assert_eq!(crsi.streak, 0.0); + assert!(crsi.prev_price.is_none()); + } + + #[test] + fn ignores_non_finite_input() { + let mut crsi = ConnorsRsi::classic(); + let prices: Vec = (1..=200).map(f64::from).collect(); + crsi.batch(&prices); + let before = crsi.current; + assert_eq!(crsi.update(f64::NAN), before); + assert_eq!(crsi.update(f64::INFINITY), before); + } + + #[test] + fn zero_prev_skips_roc_update() { + // A previous price of 0.0 makes the 1-bar return undefined; the + // ROC ring buffer must be left unchanged on that step. Feeding + // 0.0 as the very first price seeds `prev_price = Some(0.0)`, so + // the next bar takes the `prev == 0.0` branch. + let mut crsi = ConnorsRsi::new(3, 2, 4).unwrap(); + // Bar 1 seeds prev_price to 0.0. + crsi.update(0.0); + // Bar 2 must not push onto the ROC window; we cannot observe the + // ring directly but the indicator must not panic and must not + // emit until at least period_rank distinct non-zero returns have + // accumulated. + let after = crsi.update(1.0); + assert!(after.is_none(), "CRSI cannot emit on bar 2: {after:?}"); + } +} diff --git a/crates/wickra-core/src/indicators/inertia.rs b/crates/wickra-core/src/indicators/inertia.rs new file mode 100644 index 00000000..63ad8255 --- /dev/null +++ b/crates/wickra-core/src/indicators/inertia.rs @@ -0,0 +1,179 @@ +//! Inertia (Donald Dorsey). + +use crate::error::{Error, Result}; +use crate::indicators::linreg::LinearRegression; +use crate::indicators::rvi::Rvi; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Donald Dorsey's Inertia — a Linear-Regression-smoothed `RVI` (Relative Vigor +/// Index). The endpoint of an `n`-bar least-squares fit of the `RVI` series is +/// taken as the indicator's reading, smoothing the underlying ratio while +/// preserving its trend direction. +/// +/// ```text +/// Inertia_t = LinearRegression(RVI(close - open, high - low; rvi_period), linreg_period)_t +/// ``` +/// +/// Dorsey's recommended defaults are `(rvi_period = 14, linreg_period = 20)`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Inertia}; +/// +/// let mut inertia = Inertia::new(14, 20).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let o = 100.0 + f64::from(i); +/// let c = o + 0.5; +/// let candle = Candle::new(o, c + 0.2, o - 0.2, c, 1.0, i64::from(i)).unwrap(); +/// last = inertia.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Inertia { + rvi_period: usize, + linreg_period: usize, + rvi: Rvi, + linreg: LinearRegression, +} + +impl Inertia { + /// # Errors + /// Returns [`Error::PeriodZero`] if either period is zero. + pub fn new(rvi_period: usize, linreg_period: usize) -> Result { + if rvi_period == 0 || linreg_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + rvi_period, + linreg_period, + rvi: Rvi::new(rvi_period)?, + linreg: LinearRegression::new(linreg_period)?, + }) + } + + /// Dorsey's recommended defaults `(rvi_period = 14, linreg_period = 20)`. + pub fn classic() -> Self { + Self::new(14, 20).expect("classic Inertia parameters are valid") + } + + /// Configured `(rvi_period, linreg_period)`. + pub const fn periods(&self) -> (usize, usize) { + (self.rvi_period, self.linreg_period) + } +} + +impl Indicator for Inertia { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let rvi = self.rvi.update(candle)?; + self.linreg.update(rvi) + } + + fn reset(&mut self) { + self.rvi.reset(); + self.linreg.reset(); + } + + fn warmup_period(&self) -> usize { + // RVI emits at `rvi_period` candles; the LinearRegression then needs + // `linreg_period − 1` more RVI values to fill its window. + self.rvi_period + self.linreg_period - 1 + } + + fn is_ready(&self) -> bool { + self.linreg.is_ready() + } + + fn name(&self) -> &'static str { + "Inertia" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Inertia::new(0, 20), Err(Error::PeriodZero))); + assert!(matches!(Inertia::new(14, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let inertia = Inertia::classic(); + assert_eq!(inertia.periods(), (14, 20)); + assert_eq!(inertia.warmup_period(), 33); + assert_eq!(inertia.name(), "Inertia"); + } + + #[test] + fn classic_factory() { + assert_eq!(Inertia::classic().periods(), (14, 20)); + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + // Smaller periods for a fast test: RVI(3) emits at 3 candles, then + // LinReg(4) needs 4 RVI values -> total 3 + 4 - 1 = 6. + let mut inertia = Inertia::new(3, 4).unwrap(); + assert_eq!(inertia.warmup_period(), 6); + for i in 0..5 { + assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, i)), None); + } + assert!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 5)).is_some()); + } + + #[test] + fn constant_rvi_yields_constant_inertia() { + // Every bar identical -> RVI is constant -> LinReg of a constant + // series equals that constant after warmup. + let mut inertia = Inertia::new(3, 4).unwrap(); + let mut last = None; + for i in 0..40 { + last = inertia.update(candle(10.0, 11.0, 9.0, 10.5, i)); + } + // RVI = SMA(c-o, 3) / SMA(h-l, 3) = 0.5 / 2.0 = 0.25 on every bar. + let v = last.unwrap(); + assert_relative_eq!(v, 0.25, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80_i64) + .map(|i| { + let o = 100.0 + (i as f64 * 0.3).sin() * 5.0; + let c = o + (i as f64 * 0.1).cos(); + candle(o, o.max(c) + 0.5, o.min(c) - 0.5, c, i) + }) + .collect(); + let batch = Inertia::classic().batch(&candles); + let mut b = Inertia::classic(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut inertia = Inertia::classic(); + for i in 0..50 { + inertia.update(candle(10.0, 11.0, 9.0, 10.5, i)); + } + assert!(inertia.is_ready()); + inertia.reset(); + assert!(!inertia.is_ready()); + assert_eq!(inertia.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None); + } +} diff --git a/crates/wickra-core/src/indicators/kst.rs b/crates/wickra-core/src/indicators/kst.rs new file mode 100644 index 00000000..ff3e1f9e --- /dev/null +++ b/crates/wickra-core/src/indicators/kst.rs @@ -0,0 +1,303 @@ +//! Know Sure Thing (KST). + +use crate::error::{Error, Result}; +use crate::indicators::roc::Roc; +use crate::indicators::sma::Sma; +use crate::traits::Indicator; + +/// `KST` output: the indicator line and its `SMA` signal line. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct KstOutput { + /// Weighted sum of four smoothed `ROC` series. + pub kst: f64, + /// `SMA` of `kst` over the signal period. + pub signal: f64, +} + +/// Pring's Know Sure Thing — a long-horizon momentum oscillator that combines +/// four `ROC` series at different lookbacks, each smoothed by its own `SMA`, +/// summed with Pring's fixed weights `1, 2, 3, 4`: +/// +/// ```text +/// RCMA_i = SMA(ROC(close, roc_i), sma_i) for i = 1..=4 +/// KST = 1·RCMA_1 + 2·RCMA_2 + 3·RCMA_3 + 4·RCMA_4 +/// Signal = SMA(KST, signal_period) +/// ``` +/// +/// Pring's recommended defaults are +/// `(roc1, roc2, roc3, roc4) = (10, 15, 20, 30)`, +/// `(sma1, sma2, sma3, sma4) = (10, 10, 10, 15)`, +/// `signal_period = 9`. `Kst::classic()` constructs that configuration. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Kst}; +/// +/// let mut kst = Kst::classic(); +/// let mut last = None; +/// for i in 0..200 { +/// last = kst.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Kst { + roc1_period: usize, + roc2_period: usize, + roc3_period: usize, + roc4_period: usize, + sma1_period: usize, + sma2_period: usize, + sma3_period: usize, + sma4_period: usize, + signal_period: usize, + roc1: Roc, + roc2: Roc, + roc3: Roc, + roc4: Roc, + sma1: Sma, + sma2: Sma, + sma3: Sma, + sma4: Sma, + signal_sma: Sma, + last_line: Option, + last_signal: Option, +} + +impl Kst { + /// # Errors + /// Returns [`Error::PeriodZero`] if any of the nine periods is zero. + #[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 { + if [roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal].contains(&0) { + return Err(Error::PeriodZero); + } + Ok(Self { + roc1_period: roc1, + roc2_period: roc2, + roc3_period: roc3, + roc4_period: roc4, + sma1_period: sma1, + sma2_period: sma2, + sma3_period: sma3, + sma4_period: sma4, + signal_period: signal, + roc1: Roc::new(roc1)?, + roc2: Roc::new(roc2)?, + roc3: Roc::new(roc3)?, + roc4: Roc::new(roc4)?, + sma1: Sma::new(sma1)?, + sma2: Sma::new(sma2)?, + sma3: Sma::new(sma3)?, + sma4: Sma::new(sma4)?, + signal_sma: Sma::new(signal)?, + last_line: None, + last_signal: None, + }) + } + + /// Pring's recommended defaults: `KST(10, 15, 20, 30, 10, 10, 10, 15, 9)`. + pub fn classic() -> Self { + Self::new(10, 15, 20, 30, 10, 10, 10, 15, 9).expect("classic KST parameters are valid") + } + + /// Configured `(roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal)`. + pub const fn periods( + &self, + ) -> ( + usize, + usize, + usize, + usize, + usize, + usize, + usize, + usize, + usize, + ) { + ( + self.roc1_period, + self.roc2_period, + self.roc3_period, + self.roc4_period, + self.sma1_period, + self.sma2_period, + self.sma3_period, + self.sma4_period, + self.signal_period, + ) + } +} + +impl Indicator for Kst { + type Input = f64; + type Output = KstOutput; + + fn update(&mut self, input: f64) -> Option { + // Feed every inner state machine on every input so they warm up in + // parallel. The KST line waits for all four RCMA branches; the signal + // line additionally waits for its own SMA to fill. + let r1 = self.roc1.update(input); + let r2 = self.roc2.update(input); + let r3 = self.roc3.update(input); + let r4 = self.roc4.update(input); + let rcma1 = r1.and_then(|x| self.sma1.update(x)); + let rcma2 = r2.and_then(|x| self.sma2.update(x)); + let rcma3 = r3.and_then(|x| self.sma3.update(x)); + let rcma4 = r4.and_then(|x| self.sma4.update(x)); + let (rcma1, rcma2, rcma3, rcma4) = (rcma1?, rcma2?, rcma3?, rcma4?); + let kst = rcma1 + 2.0 * rcma2 + 3.0 * rcma3 + 4.0 * rcma4; + self.last_line = Some(kst); + let signal = self.signal_sma.update(kst); + let signal = signal?; + self.last_signal = Some(signal); + Some(KstOutput { kst, signal }) + } + + fn reset(&mut self) { + self.roc1.reset(); + self.roc2.reset(); + self.roc3.reset(); + self.roc4.reset(); + self.sma1.reset(); + self.sma2.reset(); + self.sma3.reset(); + self.sma4.reset(); + self.signal_sma.reset(); + self.last_line = None; + self.last_signal = None; + } + + fn warmup_period(&self) -> usize { + // Each RCMA_i emits once the inner ROC has warmed up (roc_i + 1 + // inputs) AND the SMA has filled (sma_i inputs through it). All four + // run in parallel so the slowest branch dominates, and the signal SMA + // adds signal_period − 1 inputs on top of the slowest branch. + let branch = |roc: usize, sma: usize| roc + sma; + let slowest = branch(self.roc1_period, self.sma1_period) + .max(branch(self.roc2_period, self.sma2_period)) + .max(branch(self.roc3_period, self.sma3_period)) + .max(branch(self.roc4_period, self.sma4_period)); + slowest + self.signal_period - 1 + } + + fn is_ready(&self) -> bool { + self.last_signal.is_some() + } + + fn name(&self) -> &'static str { + "KST" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!( + Kst::new(0, 15, 20, 30, 10, 10, 10, 15, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + Kst::new(10, 15, 20, 30, 10, 10, 10, 15, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let kst = Kst::classic(); + assert_eq!(kst.periods(), (10, 15, 20, 30, 10, 10, 10, 15, 9)); + assert_eq!(kst.name(), "KST"); + // The slowest branch is ROC(30) + SMA(15) = 45; signal_period - 1 = 8. + assert_eq!(kst.warmup_period(), 53); + } + + #[test] + fn classic_factory_matches_pring_defaults() { + let kst = Kst::classic(); + let (r1, r2, r3, r4, s1, s2, s3, s4, sig) = kst.periods(); + assert_eq!((r1, r2, r3, r4), (10, 15, 20, 30)); + assert_eq!((s1, s2, s3, s4), (10, 10, 10, 15)); + assert_eq!(sig, 9); + } + + #[test] + fn constant_series_yields_zero() { + // ROC is zero on a flat series, so every RCMA collapses to zero and + // KST itself is zero. The signal SMA inherits that. + let mut kst = Kst::classic(); + let prices = vec![42.0_f64; 80]; + let out = kst.batch(&prices); + for v in out.iter().skip(kst.warmup_period() - 1).flatten() { + assert_relative_eq!(v.kst, 0.0, epsilon = 1e-12); + assert_relative_eq!(v.signal, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut kst = Kst::new(2, 3, 4, 5, 2, 2, 2, 3, 2).unwrap(); + // Slowest branch is ROC(5) + SMA(3) = 8; signal − 1 = 1; total 9. + assert_eq!(kst.warmup_period(), 9); + let prices: Vec = (1..=15).map(f64::from).collect(); + let out = kst.batch(&prices); + for v in out.iter().take(8) { + assert!(v.is_none()); + } + assert!(out[8].is_some()); + } + + #[test] + fn pure_uptrend_is_positive() { + // Monotonic uptrend -> every ROC > 0 -> every RCMA > 0 -> KST > 0. + let mut kst = Kst::classic(); + let prices: Vec = (1..=120).map(|i| f64::from(i) * 2.0).collect(); + let out = kst.batch(&prices); + let last = out.iter().rev().flatten().next().unwrap(); + assert!( + last.kst > 0.0, + "KST on a clean uptrend should be positive: {}", + last.kst + ); + assert!(last.signal > 0.0); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1) + .collect(); + let mut a = Kst::classic(); + let mut b = Kst::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut kst = Kst::classic(); + let prices: Vec = (1..=120).map(f64::from).collect(); + kst.batch(&prices); + assert!(kst.is_ready()); + kst.reset(); + assert!(!kst.is_ready()); + } +} diff --git a/crates/wickra-core/src/indicators/laguerre_rsi.rs b/crates/wickra-core/src/indicators/laguerre_rsi.rs new file mode 100644 index 00000000..e7efaae6 --- /dev/null +++ b/crates/wickra-core/src/indicators/laguerre_rsi.rs @@ -0,0 +1,283 @@ +//! Ehlers' Laguerre RSI. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// John Ehlers' Laguerre RSI — a four-stage Laguerre polynomial filter wrapped +/// in an `RSI`-style up/down accumulator. The single tuning parameter `gamma` +/// in `[0, 1]` trades lag for smoothness: small `gamma` is fast and noisy, +/// large `gamma` is slow and smooth (Ehlers recommends `0.5`). +/// +/// ```text +/// alpha = 1 − gamma +/// L0_t = alpha · price_t + gamma · L0_{t-1} +/// L1_t = −gamma · L0_t + L0_{t-1} + gamma · L1_{t-1} +/// L2_t = −gamma · L1_t + L1_{t-1} + gamma · L2_{t-1} +/// L3_t = −gamma · L2_t + L2_{t-1} + gamma · L3_{t-1} +/// +/// cu, cd = 0 +/// for each pair (L0, L1), (L1, L2), (L2, L3): +/// if upper ≥ lower: cu += upper − lower +/// else : cd += lower − upper +/// +/// LRSI = 100 · cu / (cu + cd) +/// ``` +/// +/// The output is bounded in `[0, 100]`. State is seeded by setting all four +/// `L_i` to the first input, so the first emission lands on input #1. +/// +/// Reference: John F. Ehlers, *Time Warp — Without Space Travel*, 2002. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LaguerreRsi}; +/// +/// let mut lrsi = LaguerreRsi::new(0.5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// last = lrsi.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct LaguerreRsi { + gamma: f64, + alpha: f64, + l0: f64, + l1: f64, + l2: f64, + l3: f64, + seeded: bool, + current: Option, +} + +impl LaguerreRsi { + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `gamma` is non-finite or outside `[0, 1]`. + pub fn new(gamma: f64) -> Result { + if !gamma.is_finite() || !(0.0..=1.0).contains(&gamma) { + return Err(Error::InvalidPeriod { + message: "LaguerreRSI gamma must be a finite value in [0, 1]", + }); + } + Ok(Self { + gamma, + alpha: 1.0 - gamma, + l0: 0.0, + l1: 0.0, + l2: 0.0, + l3: 0.0, + seeded: false, + current: None, + }) + } + + /// Ehlers' recommended `gamma = 0.5`. + pub fn classic() -> Self { + Self::new(0.5).expect("classic LaguerreRSI gamma is valid") + } + + /// Configured `gamma`. + pub const fn gamma(&self) -> f64 { + self.gamma + } +} + +impl Indicator for LaguerreRsi { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + return self.current; + } + if !self.seeded { + // Seed all four polynomial stages with the first input so a + // constant series produces zero up/down accumulators (which we + // map to 50.0 below — the canonical neutral mid-band reading). + self.l0 = input; + self.l1 = input; + self.l2 = input; + self.l3 = input; + self.seeded = true; + self.current = Some(50.0); + return self.current; + } + let (l0_prev, l1_prev, l2_prev) = (self.l0, self.l1, self.l2); + let l0_new = self.alpha * input + self.gamma * l0_prev; + let l1_new = -self.gamma * l0_new + l0_prev + self.gamma * self.l1; + let l2_new = -self.gamma * l1_new + l1_prev + self.gamma * self.l2; + let l3_new = -self.gamma * l2_new + l2_prev + self.gamma * self.l3; + self.l0 = l0_new; + self.l1 = l1_new; + self.l2 = l2_new; + self.l3 = l3_new; + + let mut cu = 0.0; + let mut cd = 0.0; + let pairs = [(l0_new, l1_new), (l1_new, l2_new), (l2_new, l3_new)]; + for (upper, lower) in pairs { + if upper >= lower { + cu += upper - lower; + } else { + cd += lower - upper; + } + } + let total = cu + cd; + let value = if total > 0.0 { + // Floating-point rounding can push `cu / total` a hair above 1.0; + // clamp to the algebraic bound to keep the output strictly inside + // [0, 100]. + (100.0 * cu / total).clamp(0.0, 100.0) + } else { + // No up- or down-displacements between stages: stay at the + // neutral mid-band rather than report 0 / 0. + 50.0 + }; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.l0 = 0.0; + self.l1 = 0.0; + self.l2 = 0.0; + self.l3 = 0.0; + self.seeded = false; + self.current = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "LaguerreRSI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_invalid_gamma() { + assert!(matches!( + LaguerreRsi::new(-0.1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + LaguerreRsi::new(1.1), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + LaguerreRsi::new(f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let lrsi = LaguerreRsi::new(0.5).unwrap(); + assert_eq!(lrsi.gamma(), 0.5); + assert_eq!(lrsi.warmup_period(), 1); + assert_eq!(lrsi.name(), "LaguerreRSI"); + } + + #[test] + fn classic_factory() { + assert_eq!(LaguerreRsi::classic().gamma(), 0.5); + } + + #[test] + fn constant_series_stays_at_mid_band() { + // All four L_i seed to the constant; on subsequent flat inputs they + // stay equal, so cu = cd = 0 and LRSI reports the neutral 50. + let mut lrsi = LaguerreRsi::classic(); + let out = lrsi.batch(&[42.0_f64; 60]); + for v in out.iter().flatten() { + assert_relative_eq!(*v, 50.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_bounded() { + let mut lrsi = LaguerreRsi::classic(); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 25.0) + .collect(); + for v in lrsi.batch(&prices).iter().flatten() { + assert!(*v >= 0.0 && *v <= 100.0, "out of range: {v}"); + } + } + + #[test] + fn pure_uptrend_saturates_high() { + let mut lrsi = LaguerreRsi::classic(); + for i in 0..200 { + lrsi.update(100.0 + f64::from(i)); + } + let v = lrsi.current.unwrap(); + assert!(v > 80.0, "uptrend should drive LRSI well above 50: {v}"); + } + + #[test] + fn pure_downtrend_saturates_low() { + let mut lrsi = LaguerreRsi::classic(); + for i in 0..200 { + lrsi.update(300.0 - f64::from(i)); + } + let v = lrsi.current.unwrap(); + assert!(v < 20.0, "downtrend should drive LRSI well below 50: {v}"); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = LaguerreRsi::classic(); + let mut b = LaguerreRsi::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut lrsi = LaguerreRsi::classic(); + lrsi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(lrsi.is_ready()); + lrsi.reset(); + assert!(!lrsi.is_ready()); + assert!(!lrsi.seeded); + } + + #[test] + fn ignores_non_finite_input() { + let mut lrsi = LaguerreRsi::classic(); + let before = lrsi.update(10.0).unwrap(); + assert_eq!(lrsi.update(f64::NAN), Some(before)); + assert_eq!(lrsi.update(f64::INFINITY), Some(before)); + } + + #[test] + fn gamma_zero_passes_through_l0() { + // gamma = 0 -> alpha = 1, so L0 mirrors the input exactly each step. + // The polynomial chain then lags by one stage; the up/down accumulator + // still produces a bounded reading and the first non-seed step shifts + // off 50 as soon as input changes. + let mut lrsi = LaguerreRsi::new(0.0).unwrap(); + assert_eq!(lrsi.update(10.0), Some(50.0)); + let v = lrsi.update(11.0).unwrap(); + assert!((0.0..=100.0).contains(&v)); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 3f030c59..dac8d8e4 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -25,6 +25,7 @@ mod chandelier_exit; mod choppiness_index; mod cmf; mod cmo; +mod connors_rsi; mod coppock; mod dema; mod donchian; @@ -36,9 +37,12 @@ mod force_index; mod frama; mod historical_volatility; mod hma; +mod inertia; mod jma; mod kama; mod keltner; +mod kst; +mod laguerre_rsi; mod linreg; mod linreg_angle; mod linreg_slope; @@ -51,12 +55,15 @@ mod mom; mod natr; mod obv; mod percent_b; +mod pgo; mod pmo; mod ppo; mod psar; mod roc; mod rsi; +mod rvi; mod sma; +mod smi; mod smma; mod std_dev; mod stoch_rsi; @@ -104,6 +111,7 @@ pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput}; pub use choppiness_index::ChoppinessIndex; pub use cmf::ChaikinMoneyFlow; pub use cmo::Cmo; +pub use connors_rsi::ConnorsRsi; pub use coppock::Coppock; pub use dema::Dema; pub use donchian::{Donchian, DonchianOutput}; @@ -115,9 +123,12 @@ pub use force_index::ForceIndex; pub use frama::Frama; pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; +pub use inertia::Inertia; pub use jma::Jma; pub use kama::Kama; pub use keltner::{Keltner, KeltnerOutput}; +pub use kst::{Kst, KstOutput}; +pub use laguerre_rsi::LaguerreRsi; pub use linreg::LinearRegression; pub use linreg_angle::LinRegAngle; pub use linreg_slope::LinRegSlope; @@ -130,12 +141,15 @@ pub use mom::Mom; pub use natr::Natr; pub use obv::Obv; pub use percent_b::PercentB; +pub use pgo::Pgo; pub use pmo::Pmo; pub use ppo::Ppo; pub use psar::Psar; pub use roc::Roc; pub use rsi::Rsi; +pub use rvi::Rvi; pub use sma::Sma; +pub use smi::Smi; pub use smma::Smma; pub use std_dev::StdDev; pub use stoch_rsi::StochRsi; diff --git a/crates/wickra-core/src/indicators/pgo.rs b/crates/wickra-core/src/indicators/pgo.rs new file mode 100644 index 00000000..c2ba9b69 --- /dev/null +++ b/crates/wickra-core/src/indicators/pgo.rs @@ -0,0 +1,219 @@ +//! Pretty Good Oscillator (PGO). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::indicators::sma::Sma; +use crate::indicators::true_range::TrueRange; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Mark Johnson's Pretty Good Oscillator — displacement of the close from its +/// `period`-bar `SMA`, normalised by the `period`-bar `EMA` of the True Range. +/// +/// ```text +/// PGO_t = (close_t − SMA(close, period)_t) / EMA(TR_t, period) +/// ``` +/// +/// The numerator is positive when the close is above its mean of the last +/// `period` bars and negative when below. The denominator is the EMA-smoothed +/// volatility scale, so PGO is roughly "how many ATR-equivalents is the close +/// away from its mean?". Johnson's heuristic: cross above `+3` is a long entry, +/// below `−3` a short entry. +/// +/// The first output lands once both inner indicators have warmed up — for the +/// shared `period` parameter, that is exactly `period` candles in. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Pgo}; +/// +/// let mut pgo = Pgo::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap(); +/// last = pgo.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Pgo { + period: usize, + sma: Sma, + tr: TrueRange, + ema_tr: Ema, + current: Option, +} + +impl Pgo { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + sma: Sma::new(period)?, + tr: TrueRange::new(), + ema_tr: Ema::new(period)?, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Pgo { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let mean = self.sma.update(candle.close); + // TrueRange always emits (it falls back to high − low without a + // previous close), so we can unwrap the inner option safely. + let tr = self.tr.update(candle).expect("TrueRange always emits"); + let ema_tr = self.ema_tr.update(tr); + let mean = mean?; + let ema_tr = ema_tr?; + if ema_tr <= 0.0 { + // Pathological window of perfectly flat candles: divisor zero. + // Hold the previous value rather than blow up. + return self.current; + } + let value = (candle.close - mean) / ema_tr; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.sma.reset(); + self.tr.reset(); + self.ema_tr.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // Both inner state machines reach readiness at exactly `period` + // candles, so PGO emits at the same boundary. + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "PGO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(close: f64, high: f64, low: f64, ts: i64) -> Candle { + Candle::new(close, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Pgo::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut p = Pgo::new(14).unwrap(); + assert_eq!(p.period(), 14); + assert_eq!(p.warmup_period(), 14); + assert_eq!(p.name(), "PGO"); + assert!(!p.is_ready()); + for i in 0..14 { + p.update(candle(10.0, 11.0, 9.0, i)); + } + assert!(p.is_ready()); + } + + #[test] + fn flat_close_yields_zero_numerator() { + // Constant close -> SMA == close, so numerator is 0 regardless of the + // TR-EMA in the denominator (which is non-zero thanks to spread). + let mut p = Pgo::new(5).unwrap(); + let mut out = None; + for i in 0..20 { + out = p.update(candle(10.0, 11.0, 9.0, i)); + } + let v = out.unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut p = Pgo::new(3).unwrap(); + for i in 0..2 { + assert_eq!(p.update(candle(10.0, 11.0, 9.0, i)), None); + } + assert!(p.update(candle(10.0, 11.0, 9.0, 2)).is_some()); + } + + #[test] + fn close_above_mean_is_positive() { + // Rising series: latest close sits above its SMA, so PGO > 0. + let mut p = Pgo::new(5).unwrap(); + for i in 0..20 { + let c = 10.0 + f64::from(i); + p.update(candle(c, c + 0.5, c - 0.5, i64::from(i))); + } + // Use the last value implicitly. + let last = p.update(candle(40.0, 40.5, 39.5, 20)).expect("PGO is warm"); + assert!( + last > 0.0, + "PGO on rising series should be positive: {last}" + ); + } + + #[test] + fn zero_tr_holds_value() { + // Every candle is a single point (high == low == close): TR is zero, + // EMA(TR) collapses to zero -> PGO holds its previous value. + let mut p = Pgo::new(3).unwrap(); + p.update(candle(10.0, 10.0, 10.0, 0)); + p.update(candle(10.0, 10.0, 10.0, 1)); + let v = p.update(candle(10.0, 10.0, 10.0, 2)); + // With zero denominator on the first ready step we have no previous + // value, so the indicator stays unset. + assert!(v.is_none(), "expected hold, got {v:?}"); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60_i64) + .map(|i| { + let c = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle(c, c + 1.0, c - 1.0, i) + }) + .collect(); + let batch = Pgo::new(14).unwrap().batch(&candles); + let mut b = Pgo::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut p = Pgo::new(5).unwrap(); + for i in 0..20 { + p.update(candle(10.0, 11.0, 9.0, i)); + } + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.update(candle(10.0, 11.0, 9.0, 0)), None); + } +} diff --git a/crates/wickra-core/src/indicators/rvi.rs b/crates/wickra-core/src/indicators/rvi.rs new file mode 100644 index 00000000..a1c609b2 --- /dev/null +++ b/crates/wickra-core/src/indicators/rvi.rs @@ -0,0 +1,217 @@ +//! Relative Vigor Index (RVI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Relative Vigor Index — Donald Dorsey's ratio of intra-bar drive (close − open) +/// to intra-bar range (high − low), averaged over a `period`-bar window. +/// +/// The reading is `SMA(close − open, period) / SMA(high − low, period)`. A +/// positive value means the average bar in the window closed above where it +/// opened (bullish "vigor"); a negative value means the average closed below. +/// The denominator's rolling-window SMA can fall to zero on a perfectly flat +/// stretch, in which case the recurrence is undefined and the indicator holds +/// its previous value. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Rvi}; +/// +/// let mut rvi = Rvi::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let o = 100.0 + f64::from(i); +/// let c = o + 0.5; +/// let candle = Candle::new(o, c + 0.2, o - 0.2, c, 1.0, i64::from(i)).unwrap(); +/// last = rvi.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Rvi { + period: usize, + window: VecDeque<(f64, f64)>, + sum_num: f64, + sum_den: f64, + current: Option, +} + +impl Rvi { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_num: 0.0, + sum_den: 0.0, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Rvi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let num = candle.close - candle.open; + let den = candle.high - candle.low; + if self.window.len() == self.period { + let (old_n, old_d) = self.window.pop_front().expect("window is non-empty"); + self.sum_num -= old_n; + self.sum_den -= old_d; + } + self.window.push_back((num, den)); + self.sum_num += num; + self.sum_den += den; + if self.window.len() < self.period { + return None; + } + if self.sum_den <= 0.0 { + // Window of perfectly flat (zero-range) bars: ratio undefined. + // Hold the previous value rather than emitting NaN / inf. + return self.current; + } + let value = self.sum_num / self.sum_den; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_num = 0.0; + self.sum_den = 0.0; + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "RVI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Rvi::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let mut r = Rvi::new(10).unwrap(); + assert_eq!(r.period(), 10); + assert_eq!(r.warmup_period(), 10); + assert_eq!(r.name(), "RVI"); + assert_eq!(r.value(), None); + for i in 0..10 { + r.update(candle(10.0, 11.0, 9.0, 10.5, i)); + } + assert!(r.value().is_some()); + } + + #[test] + fn reference_value_period_2() { + // Two bars with (open, high, low, close) = (10, 11, 9, 10.5) and + // (10.5, 11.5, 10, 11). Per bar: + // num1 = 0.5, num2 = 0.5; sum = 1.0 + // den1 = 2.0, den2 = 1.5; sum = 3.5 + // RVI = 1.0 / 3.5 ≈ 0.2857142857 + let mut r = Rvi::new(2).unwrap(); + assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None); + let v = r.update(candle(10.5, 11.5, 10.0, 11.0, 1)).unwrap(); + assert_relative_eq!(v, 1.0 / 3.5, epsilon = 1e-12); + } + + #[test] + fn warmup_emits_first_value_at_period() { + let mut r = Rvi::new(3).unwrap(); + for i in 0..2 { + assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, i)), None); + } + assert!(r.update(candle(10.5, 11.5, 10.0, 11.0, 2)).is_some()); + } + + #[test] + fn pure_uptrend_is_positive() { + // Every bar closes above its open and has a non-zero range: RVI > 0. + let mut r = Rvi::new(5).unwrap(); + for i in 0..10 { + let o = 10.0 + f64::from(i); + let c = o + 0.5; + r.update(candle(o, c + 0.2, o - 0.2, c, i64::from(i))); + } + let v = r.value().unwrap(); + assert!(v > 0.0, "uptrend RVI should be positive: {v}"); + } + + #[test] + fn zero_range_window_holds_value() { + // Window of perfectly flat bars (high == low): ratio undefined, + // indicator holds. + let mut r = Rvi::new(3).unwrap(); + r.update(candle(10.0, 10.0, 10.0, 10.0, 0)); + r.update(candle(10.0, 10.0, 10.0, 10.0, 1)); + assert_eq!(r.update(candle(10.0, 10.0, 10.0, 10.0, 2)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40_i64) + .map(|i| { + let o = 100.0 + (i as f64 * 0.3).sin() * 5.0; + let c = o + (i as f64 * 0.1).cos(); + candle(o, o.max(c) + 0.5, o.min(c) - 0.5, c, i) + }) + .collect(); + let batch = Rvi::new(10).unwrap().batch(&candles); + let mut b = Rvi::new(10).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut r = Rvi::new(5).unwrap(); + for i in 0..10 { + r.update(candle(10.0, 11.0, 9.0, 10.5, i)); + } + assert!(r.is_ready()); + r.reset(); + assert!(!r.is_ready()); + assert_eq!(r.update(candle(10.0, 11.0, 9.0, 10.5, 0)), None); + } +} diff --git a/crates/wickra-core/src/indicators/smi.rs b/crates/wickra-core/src/indicators/smi.rs new file mode 100644 index 00000000..584d17e3 --- /dev/null +++ b/crates/wickra-core/src/indicators/smi.rs @@ -0,0 +1,285 @@ +//! Stochastic Momentum Index (SMI). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// William Blau's Stochastic Momentum Index — a doubly-smoothed, +/// `±100`-bounded oscillator built from the close's distance to the centre +/// of the recent high-low range. +/// +/// Over the lookback `period`, let `HH = max(high)`, `LL = min(low)`, +/// `C = (HH + LL) / 2` and `R = HH - LL`. The raw displacement is +/// `d_t = close_t - C_t`. Both `d` and `R` are smoothed twice with `EMA`s, +/// then combined into the bounded reading: +/// +/// ```text +/// D_smoothed = EMA(EMA(d, d_period), d2_period) +/// HL_smoothed = EMA(EMA(R, d_period), d2_period) +/// SMI = 100 · D_smoothed / (HL_smoothed / 2) +/// ``` +/// +/// Blau's recommended defaults are `(period = 5, d = 3, d2 = 3)`. Wickra +/// publishes the SMI value only; the optional signal `EMA(SMI, k)` is left +/// to the consumer via `Chain` / their own `Ema`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Smi}; +/// +/// let mut smi = Smi::new(5, 3, 3).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap(); +/// last = smi.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Smi { + period: usize, + d_period: usize, + d2_period: usize, + highs: VecDeque, + lows: VecDeque, + ema_d1: Ema, + ema_d2: Ema, + ema_r1: Ema, + ema_r2: Ema, + current: Option, +} + +impl Smi { + /// # Errors + /// Returns [`Error::PeriodZero`] if any period is zero. + pub fn new(period: usize, d_period: usize, d2_period: usize) -> Result { + if period == 0 || d_period == 0 || d2_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + d_period, + d2_period, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + ema_d1: Ema::new(d_period)?, + ema_d2: Ema::new(d2_period)?, + ema_r1: Ema::new(d_period)?, + ema_r2: Ema::new(d2_period)?, + current: None, + }) + } + + /// Blau's recommended defaults `(period = 5, d = 3, d2 = 3)`. + pub fn classic() -> Self { + Self::new(5, 3, 3).expect("classic SMI parameters are valid") + } + + /// Configured `(period, d_period, d2_period)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.period, self.d_period, self.d2_period) + } +} + +impl Indicator for Smi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.highs.len() == self.period { + self.highs.pop_front(); + self.lows.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + if self.highs.len() < self.period { + return None; + } + let hh = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let ll = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + let center = f64::midpoint(hh, ll); + let displacement = candle.close - center; + let range = hh - ll; + + // Feed every EMA on every candle so both stacks warm in parallel — + // gating the range stack behind the displacement stack would starve + // it by one input. + let d1 = self.ema_d1.update(displacement); + let r1 = self.ema_r1.update(range); + let d2 = d1.and_then(|x| self.ema_d2.update(x)); + let r2 = r1.and_then(|x| self.ema_r2.update(x)); + let (d2, r2) = (d2?, r2?); + + if r2 <= 0.0 { + // Window where the smoothed range collapses to zero: the formula + // is undefined. Hold the previous reading rather than emit inf. + return self.current; + } + let value = 100.0 * d2 / (r2 / 2.0); + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.highs.clear(); + self.lows.clear(); + self.ema_d1.reset(); + self.ema_d2.reset(); + self.ema_r1.reset(); + self.ema_r2.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // The high-low window needs `period` candles; then both EMA stacks + // need `d_period + d2_period - 1` more values to fully warm up. + self.period + self.d_period + self.d2_period - 2 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "SMI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(close, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Smi::new(0, 3, 3), Err(Error::PeriodZero))); + assert!(matches!(Smi::new(5, 0, 3), Err(Error::PeriodZero))); + assert!(matches!(Smi::new(5, 3, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let smi = Smi::new(5, 3, 3).unwrap(); + assert_eq!(smi.periods(), (5, 3, 3)); + assert_eq!(smi.warmup_period(), 9); + assert_eq!(smi.name(), "SMI"); + } + + #[test] + fn classic_factory() { + let smi = Smi::classic(); + assert_eq!(smi.periods(), (5, 3, 3)); + } + + #[test] + fn close_at_high_pushes_toward_plus_100() { + // Every candle's close equals its high in a rising series: the + // displacement is at the top of the range every bar, so SMI sits in + // the strongly positive region. After enough double-smoothing it + // approaches the upper bound. + let mut smi = Smi::classic(); + let mut last = None; + for i in 0..80 { + let h = 100.0 + f64::from(i); + let l = h - 2.0; + last = smi.update(candle(h, l, h, i64::from(i))); + } + let v = last.expect("SMI is warm"); + assert!( + v > 50.0, + "close-at-high series should drive SMI well above 0: {v}" + ); + } + + #[test] + fn close_at_low_pushes_toward_minus_100() { + let mut smi = Smi::classic(); + let mut last = None; + for i in 0..80 { + let h = 100.0 - f64::from(i); + let l = h - 2.0; + last = smi.update(candle(h, l, l, i64::from(i))); + } + let v = last.expect("SMI is warm"); + assert!( + v < -50.0, + "close-at-low series should drive SMI well below 0: {v}" + ); + } + + #[test] + fn warmup_emits_first_value_at_warmup_period() { + let mut smi = Smi::new(3, 2, 2).unwrap(); + // period 3 + d 2 + d2 2 - 2 = 5. + assert_eq!(smi.warmup_period(), 5); + let mut got = None; + for i in 0..5 { + got = smi.update(candle(11.0, 9.0, 10.0, i)); + } + assert!(got.is_some()); + } + + #[test] + fn flat_close_yields_zero_displacement() { + // Every close is exactly at the centre of the range -> displacement + // is 0 every bar -> SMI converges to 0. + let mut smi = Smi::classic(); + let mut last = None; + for i in 0..60 { + // High and low straddle a constant close. + last = smi.update(candle(11.0, 9.0, 10.0, i)); + } + let v = last.unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80_i64) + .map(|i| { + let c = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle(c + 1.0, c - 1.0, c, i) + }) + .collect(); + let batch = Smi::classic().batch(&candles); + let mut b = Smi::classic(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut smi = Smi::classic(); + for i in 0..40 { + smi.update(candle(11.0, 9.0, 10.0, i)); + } + assert!(smi.is_ready()); + smi.reset(); + assert!(!smi.is_ready()); + } + + #[test] + fn zero_range_holds_previous_value() { + // High == low on every bar -> instantaneous range is zero, the + // EMA of (range / 2) settles to zero, so `r2 <= 0.0` after warmup + // and the indicator must hold its previous value (None here, since + // r2 was zero from the very first warm bar) rather than divide by + // zero. + let mut smi = Smi::new(3, 2, 2).unwrap(); + // warmup_period = 3 + 2 + 2 - 2 = 5; feed warmup + 2 extra bars. + for i in 0..7 { + let v = smi.update(candle(10.0, 10.0, 10.0, i)); + assert_eq!(v, None, "zero-range SMI must hold None, got {v:?}"); + } + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index fa264562..4b3a011d 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -48,14 +48,15 @@ pub use indicators::{ AroonOscillator, AroonOutput, Atr, AtrTrailingStop, AwesomeOscillator, BalanceOfPower, BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, - ChandelierExitOutput, ChoppinessIndex, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, - EaseOfMovement, Ema, Evwma, ForceIndex, Frama, HistoricalVolatility, Hma, Jma, Kama, Keltner, - KeltnerOutput, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator, MacdOutput, - MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, - RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, - SuperTrendOutput, Tema, Trima, Trix, TrueRange, Tsi, TypicalPrice, UlcerIndex, - UltimateOscillator, VerticalHorizontalFilter, Vidya, VolumePriceTrend, Vortex, VortexOutput, - Vwap, Vwma, WeightedClose, WilliamsR, Wma, ZScore, Zlema, T3, + ChandelierExitOutput, ChoppinessIndex, Cmo, ConnorsRsi, Coppock, Dema, Donchian, + DonchianOutput, Dpo, EaseOfMovement, Ema, Evwma, ForceIndex, Frama, HistoricalVolatility, Hma, + Inertia, Jma, Kama, Keltner, KeltnerOutput, Kst, KstOutput, LaguerreRsi, LinRegAngle, + LinRegSlope, LinearRegression, MacdIndicator, MacdOutput, MassIndex, McGinleyDynamic, + MedianPrice, Mfi, Mom, Natr, Obv, PercentB, Pgo, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Rvi, + Sma, Smi, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, + Tema, Trima, Trix, TrueRange, Tsi, TypicalPrice, UlcerIndex, UltimateOscillator, + VerticalHorizontalFilter, Vidya, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, + WeightedClose, WilliamsR, Wma, ZScore, 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 27d7df92..91cd9720 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -20,7 +20,7 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Through use std::hint::black_box; use wickra::{ Alma, Atr, BatchExt, BollingerBands, Candle, Ema, Frama, Indicator, Jma, MacdIndicator, - McGinleyDynamic, Obv, Rsi, Sma, Stochastic, Vidya, Wma, + McGinleyDynamic, Obv, Pgo, Rsi, Rvi, Sma, Stochastic, Vidya, Wma, }; use wickra_data::csv::CandleReader; @@ -151,6 +151,8 @@ fn benches(c: &mut Criterion) { bench_candle_input(c, "atr", &candles, || Atr::new(14).unwrap()); bench_candle_input(c, "stochastic", &candles, Stochastic::classic); bench_candle_input(c, "obv", &candles, Obv::new); + bench_candle_input(c, "rvi", &candles, || Rvi::new(10).unwrap()); + bench_candle_input(c, "pgo", &candles, || Pgo::new(14).unwrap()); } criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches); diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index 0518f336..77b0393e 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -15,10 +15,11 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - Alma, BatchExt, BollingerBands, Cmo, Coppock, Dema, Dpo, Ema, Frama, HistoricalVolatility, Hma, - Indicator, Jma, Kama, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator, - McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, Sma, Smma, StdDev, StochRsi, T3, Tema, Trima, Trix, - Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, Wma, ZScore, Zlema, + Alma, BatchExt, BollingerBands, Cmo, ConnorsRsi, Coppock, Dema, Dpo, Ema, Frama, + HistoricalVolatility, Hma, Indicator, Jma, Kama, Kst, LaguerreRsi, LinRegAngle, LinRegSlope, + LinearRegression, MacdIndicator, McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, Sma, Smma, StdDev, + StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Vidya, Wma, ZScore, + Zlema, }; /// Drive a single streaming + batch run through one scalar indicator. Marked @@ -75,6 +76,18 @@ fuzz_target!(|data: Vec| { drive(|| LinRegAngle::new(14).unwrap(), &data); drive(|| VerticalHorizontalFilter::new(14).unwrap(), &data); drive(|| ZScore::new(14).unwrap(), &data); + drive(|| LaguerreRsi::new(0.5).unwrap(), &data); + drive(|| ConnorsRsi::classic(), &data); + + // KST is scalar-input but emits `KstOutput`, so it bypasses the generic + // `drive` helper. Streaming + batch are still both exercised. + { + let mut kst = Kst::classic(); + for &x in &data { + let _ = kst.update(x); + } + let _ = Kst::classic().batch(&data); + } // MACD and Bollinger Bands have non-`f64` outputs, so they cannot use the // generic `drive` helper above. Streaming + batch are still both exercised. diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index 76e56a33..ce1e36e2 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -26,8 +26,8 @@ use wickra_core::{ AcceleratorOscillator, Adl, Adx, Alligator, Aroon, AroonOscillator, Atr, AtrTrailingStop, AwesomeOscillator, BalanceOfPower, BatchExt, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, Donchian, EaseOfMovement, - Evwma, ForceIndex, Indicator, Keltner, MassIndex, MedianPrice, Mfi, Natr, Obv, Psar, - RollingVwap, + Evwma, ForceIndex, Indicator, Inertia, Keltner, MassIndex, MedianPrice, Mfi, Natr, Obv, Pgo, + Psar, RollingVwap, Rvi, Smi, Stochastic, SuperTrend, TrueRange, TypicalPrice, UltimateOscillator, VolumePriceTrend, Vortex, Vwap, Vwma, WeightedClose, WilliamsR, }; @@ -97,6 +97,10 @@ fuzz_target!(|data: Vec| { // --- Momentum & Oscillators --- drive(|| Cci::new(20).unwrap(), &candles); + drive(|| Rvi::new(10).unwrap(), &candles); + drive(|| Inertia::new(14, 20).unwrap(), &candles); + drive(|| Pgo::new(14).unwrap(), &candles); + drive(|| Smi::classic(), &candles); drive(|| WilliamsR::new(14).unwrap(), &candles); drive(|| AwesomeOscillator::new(5, 34).unwrap(), &candles); drive(|| AcceleratorOscillator::new(5, 34, 5).unwrap(), &candles);