From 880a0e74301253b770adb19100a0b8084e868844 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 25 May 2026 19:15:22 +0200 Subject: [PATCH] feat: Family 07 Volume - 6 new volume-flow indicators (#45) * feat(kvo): add Klinger Volume Oscillator Stephen J. Klinger's trend-aware volume-force MACD. Each bar produces a 'volume force' (vf) signed by the local trend (+1 / -1 / carry) and scaled by the ratio of the current accumulation horizon to its previous trend. KVO = EMA(vf, fast) - EMA(vf, slow), classic (34, 55). Rust core (Kvo) with 7 unit tests (rejects zero / fast>=slow, accessors, constant series collapses to 0, warmup lands at slow+1, batch == streaming, reset clears state), plus Python (PyKvo + KVO export), Node (KvoNode), and WASM (WasmKvo) bindings. Fuzz target adds Kvo to the candle-input sweep, bench adds the candle-input KVO benchmark, README counter 71 -> 72 + family table row, CHANGELOG [Unreleased]. * feat(volume-oscillator): add Volume Oscillator (VO) Percent difference between a fast and a slow SMA of the bar volume: 100 * (SMA(vol, fast) - SMA(vol, slow)) / SMA(vol, slow). Default (14, 28). The line stays near zero in stable conditions; positive readings show rising short-term participation, negative readings show waning interest. Rust core (VolumeOscillator) with 8 unit tests (period validation, accessors, constant volume == 0, zero-volume window defensive branch, two reference values verified algebraically, batch == streaming, reset), plus Python (PyVolumeOscillator + VolumeOscillator export), Node (VolumeOscillatorNode), and WASM (WasmVolumeOscillator) bindings. Fuzz target adds VolumeOscillator to the candle-input sweep, bench adds the volume_oscillator benchmark, README counter 72 -> 73 + family table row, CHANGELOG [Unreleased]. * feat(nvi-pvi): add Negative & Positive Volume Index Paul Dysart's cumulative volume-flow indices, popularised by Norman Fosback in 'Stock Market Logic'. Both run from a 1000.0 baseline and only update on a specific direction of volume change: - NVI updates on volume-contraction bars (volume_t < volume_{t-1}), absorbing the percent close change. Tracks the 'smart money' leg per Fosback. - PVI updates on volume-expansion bars (volume_t > volume_{t-1}). Tracks the 'crowd' leg. Both expose with_baseline(f64) for custom starting indexes. The NVI/PVI pair is listed as a single line in indicator-ideas/families/07-volume.md and shares the same lifecycle/test/binding surface, so they ship as one commit. Rust core (Nvi, Pvi) with 9 unit tests each (accessors, baseline seed, volume direction branches, zero-prev-close guard, custom baseline, batch == streaming, reset), plus Python (PyNvi/PyPvi + NVI/PVI exports), Node (NviNode/PviNode), and WASM (WasmNvi/WasmPvi) bindings. Fuzz target adds Nvi+Pvi to the candle-input sweep, bench adds nvi+pvi entries, README counter 73 -> 75 + family table row, CHANGELOG [Unreleased]. * feat(family-07): add Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index Finishes the volume-flow family with the remaining (new) entries from indicator-ideas/families/07-volume.md. Indicators added: - Williams A/D (`WilliamsAD`): Larry Williams' volume-less cumulative accumulation/distribution line. Anchors each bar's contribution to the previous close via true-high/true-low (gap-aware). - Anchored VWAP (`AnchoredVwap`): cumulative VWAP whose accumulation starts at a user-chosen anchor bar. Exposes `set_anchor()` (queued to the next `update`) for click-to-anchor workflows. Reset clears both state and pending-anchor flag. - Demand Index (`DemandIndex`): James Sibbet's smoothed buying-vs- selling pressure, in the streaming-friendly textbook form `EMA(volume * close-return * (1 + range/close), period)`. - Time Segmented Volume (`Tsv`): Don Worden's rolling window-sum of `(close_t - close_{t-1}) * volume_t`. Default `period = 18`. - Volume Zone Oscillator (`Vzo`): Walid Khalil's normalised volume-flow oscillator bounded in `[-100, +100]`, defined as `100 * EMA(signed_volume) / EMA(volume)`. - Market Facilitation Index (`MarketFacilitationIndex`): Bill Williams' per-bar `(high - low) / volume`. Returns `None` on zero-volume bars. All six indicators ship with unit tests (`rejects_zero_period` where applicable, `accessors_and_metadata`, constant-series behaviour, batch == streaming equivalence, reset semantics, and reference-value or saturation-extreme tests), Python / Node / WASM bindings, fuzz coverage in `indicator_update_candle`, a `bench_candle_input` line per indicator, README + CHANGELOG entries, and Python reference-value tests in `test_new_indicators.py`. The README indicator counter advances 75 -> 81. * test(family-07): cover defensive cold paths + Default impls - ad_oscillator: exercise `value()` after first emission. - kvo: cover the `cm == 0.0` zero-OHLC defensive branch. - nvi / pvi: exercise the Default impls. --- CHANGELOG.md | 33 + README.md | 6 +- bindings/node/__tests__/indicators.test.js | 10 + bindings/node/index.js | 12 +- bindings/node/src/lib.rs | 548 +++++++++++++++ bindings/python/python/wickra/__init__.py | 20 + bindings/python/src/lib.rs | 663 ++++++++++++++++++ bindings/python/tests/test_new_indicators.py | 172 +++++ bindings/wasm/src/lib.rs | 391 +++++++++++ .../src/indicators/ad_oscillator.rs | 220 ++++++ .../src/indicators/anchored_vwap.rs | 207 ++++++ .../src/indicators/demand_index.rs | 242 +++++++ crates/wickra-core/src/indicators/kvo.rs | 263 +++++++ .../indicators/market_facilitation_index.rs | 185 +++++ crates/wickra-core/src/indicators/mod.rs | 20 + crates/wickra-core/src/indicators/nvi.rs | 240 +++++++ crates/wickra-core/src/indicators/pvi.rs | 228 ++++++ crates/wickra-core/src/indicators/tsv.rs | 205 ++++++ .../src/indicators/volume_oscillator.rs | 206 ++++++ crates/wickra-core/src/indicators/vzo.rs | 218 ++++++ crates/wickra-core/src/lib.rs | 42 +- crates/wickra/benches/indicators.rs | 34 +- fuzz/fuzz_targets/indicator_update_candle.rs | 28 +- 23 files changed, 4155 insertions(+), 38 deletions(-) create mode 100644 crates/wickra-core/src/indicators/ad_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/anchored_vwap.rs create mode 100644 crates/wickra-core/src/indicators/demand_index.rs create mode 100644 crates/wickra-core/src/indicators/kvo.rs create mode 100644 crates/wickra-core/src/indicators/market_facilitation_index.rs create mode 100644 crates/wickra-core/src/indicators/nvi.rs create mode 100644 crates/wickra-core/src/indicators/pvi.rs create mode 100644 crates/wickra-core/src/indicators/tsv.rs create mode 100644 crates/wickra-core/src/indicators/volume_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/vzo.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 62317d97..356eb657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Klinger Volume Oscillator (KVO).** Stephen J. Klinger's trend-aware + volume-force oscillator: `EMA(vf, fast) − EMA(vf, slow)` over a daily + volume force scaled by cumulative-measurement ratio. Classic + `(fast, slow) = (34, 55)` exposed via `Kvo::classic()`. +- **Volume Oscillator (VO).** Percent difference between a fast and a + slow SMA of bar volume: `100 · (SMA(vol, fast) − SMA(vol, slow)) / + SMA(vol, slow)`. Default `(14, 28)`. +- **Negative Volume Index (NVI).** Paul Dysart's cumulative index that + only updates on volume-contraction bars (`volume_t < volume_{t−1}`), + absorbing the percent close change on those quiet days. Fosback + baseline `1000.0`, configurable via `Nvi::with_baseline`. +- **Positive Volume Index (PVI).** The complementary index that + updates on volume-expansion bars (`volume_t > volume_{t−1}`). +- **Williams Accumulation/Distribution.** Larry Williams' volume-less + cumulative flow that anchors to the previous close (true high/low) and + classifies each bar as accumulation, distribution, or neutral by the + sign of the close-to-close change. +- **Anchored VWAP.** A cumulative VWAP whose accumulation begins at a + user-chosen anchor bar rather than the session open. Re-anchor at + runtime via `AnchoredVwap::set_anchor` for click-to-anchor trader + workflows. +- **Demand Index (Sibbet).** James Sibbet's smoothed buying-vs-selling + pressure ratio in the streaming-friendly textbook form + `EMA(volume · close-return · (1 + range/close), period)`. +- **Time Segmented Volume (TSV).** Don Worden's rolling sum of signed + volume weighted by the close-to-close move: a window-sum measure of + net accumulation/distribution. +- **Volume Zone Oscillator (VZO).** Walid Khalil's normalised + volume-flow oscillator bounded in `[−100, 100]`, defined as + `100 · EMA(signed_volume) / EMA(volume)`. +- **Market Facilitation Index (Bill Williams).** Per-bar + `(high − low) / volume` — how much price movement the market produces + per unit of volume. - **ADXR (Average Directional Movement Index Rating)** in the Trend & Directional family. Wilder's directional-strength smoother: the average of the current `ADX` and the `ADX` from `period - 1` bars diff --git a/README.md b/README.md index 42a12868..8872607f 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -111 streaming-first indicators across nine families. Every one passes the +121 streaming-first indicators across nine families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -122,7 +122,7 @@ semantics tests. | Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility | | Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands | | Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop | -| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement | +| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index | | Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle | Adding a new indicator means implementing one trait in Rust; all four bindings @@ -196,7 +196,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 111 indicators +│ ├── wickra-core/ core engine + all 121 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 9a23500f..5543d7ed 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -114,6 +114,16 @@ const candleScalar = { ChaikinOscillator: { make: () => new wickra.ChaikinOscillator(3, 10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, ForceIndex: { make: () => new wickra.ForceIndex(13), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, EaseOfMovement: { make: () => new wickra.EaseOfMovement(14, 1e8), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) }, + KVO: { make: () => new wickra.KVO(34, 55), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, + VolumeOscillator: { make: () => new wickra.VolumeOscillator(14, 28), step: (ind, i) => ind.update(volume[i]), batch: (ind) => ind.batch(volume) }, + NVI: { make: () => new wickra.NVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, + PVI: { make: () => new wickra.PVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, + WilliamsAD: { make: () => new wickra.WilliamsAD(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + AnchoredVWAP: { make: () => new wickra.AnchoredVWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, + DemandIndex: { make: () => new wickra.DemandIndex(10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, + TSV: { make: () => new wickra.TSV(18), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, + VZO: { make: () => new wickra.VZO(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, + MarketFacilitationIndex: { make: () => new wickra.MarketFacilitationIndex(), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) }, AtrTrailingStop: { make: () => new wickra.AtrTrailingStop(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, TypicalPrice: { make: () => new wickra.TypicalPrice(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, MedianPrice: { make: () => new wickra.MedianPrice(), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, diff --git a/bindings/node/index.js b/bindings/node/index.js index 574a8ad8..c93879fe 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, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -380,6 +380,16 @@ module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow module.exports.ChaikinOscillator = ChaikinOscillator module.exports.ForceIndex = ForceIndex module.exports.EaseOfMovement = EaseOfMovement +module.exports.KVO = KVO +module.exports.VolumeOscillator = VolumeOscillator +module.exports.NVI = NVI +module.exports.PVI = PVI +module.exports.WilliamsAD = WilliamsAD +module.exports.AnchoredVWAP = AnchoredVWAP +module.exports.DemandIndex = DemandIndex +module.exports.TSV = TSV +module.exports.VZO = VZO +module.exports.MarketFacilitationIndex = MarketFacilitationIndex module.exports.SuperTrend = SuperTrend module.exports.ChandelierExit = ChandelierExit module.exports.ChandeKrollStop = ChandeKrollStop diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index a38f56ce..1260c83e 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -2519,6 +2519,554 @@ impl ForceIndexNode { } } +// ============================== Negative Volume Index ============================== + +#[napi(js_name = "NVI")] +pub struct NviNode { + inner: wc::Nvi, +} + +#[napi] +impl NviNode { + #[napi(constructor)] + pub fn new(baseline: Option) -> Self { + Self { + inner: wc::Nvi::with_baseline(baseline.unwrap_or(1000.0)), + } + } + #[napi] + pub fn update(&mut self, close: f64, volume: f64) -> napi::Result> { + Ok(self.inner.update(cnd(close, close, close, volume)?)) + } + #[napi] + pub fn batch(&mut self, close: Vec, volume: Vec) -> napi::Result> { + if close.len() != volume.len() { + return Err(NapiError::from_reason( + "close and volume 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(close[i], close[i], close[i], volume[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 + } +} + +// ============================== Positive Volume Index ============================== + +#[napi(js_name = "PVI")] +pub struct PviNode { + inner: wc::Pvi, +} + +#[napi] +impl PviNode { + #[napi(constructor)] + pub fn new(baseline: Option) -> Self { + Self { + inner: wc::Pvi::with_baseline(baseline.unwrap_or(1000.0)), + } + } + #[napi] + pub fn update(&mut self, close: f64, volume: f64) -> napi::Result> { + Ok(self.inner.update(cnd(close, close, close, volume)?)) + } + #[napi] + pub fn batch(&mut self, close: Vec, volume: Vec) -> napi::Result> { + if close.len() != volume.len() { + return Err(NapiError::from_reason( + "close and volume 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(close[i], close[i], close[i], volume[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 + } +} + +// ============================== Volume Oscillator ============================== + +#[napi(js_name = "VolumeOscillator")] +pub struct VolumeOscillatorNode { + inner: wc::VolumeOscillator, +} + +#[napi] +impl VolumeOscillatorNode { + #[napi(constructor)] + pub fn new(fast: u32, slow: u32) -> napi::Result { + Ok(Self { + inner: wc::VolumeOscillator::new(fast as usize, slow as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, volume: f64) -> napi::Result> { + Ok(self.inner.update(cnd(10.0, 10.0, 10.0, volume)?)) + } + #[napi] + pub fn batch(&mut self, volume: Vec) -> napi::Result> { + let mut out = Vec::with_capacity(volume.len()); + for &v in &volume { + out.push( + self.inner + .update(cnd(10.0, 10.0, 10.0, v)?) + .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 + } +} + +// ============================== Klinger Volume Oscillator ============================== + +#[napi(js_name = "KVO")] +pub struct KvoNode { + inner: wc::Kvo, +} + +#[napi] +impl KvoNode { + #[napi(constructor)] + pub fn new(fast: u32, slow: u32) -> napi::Result { + Ok(Self { + inner: wc::Kvo::new(fast as usize, slow as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, volume)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(NapiError::from_reason( + "high, low, close, volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], volume[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 + } +} + +// ============================== Williams A/D ============================== + +#[napi(js_name = "WilliamsAD")] +pub struct AdOscillatorNode { + inner: wc::AdOscillator, +} + +#[napi] +impl AdOscillatorNode { + #[napi(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { + inner: wc::AdOscillator::new(), + } + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.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 + } +} + +// ============================== Anchored VWAP ============================== + +#[napi(js_name = "AnchoredVWAP")] +pub struct AnchoredVwapNode { + inner: wc::AnchoredVwap, +} + +#[napi] +impl AnchoredVwapNode { + #[napi(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { + inner: wc::AnchoredVwap::new(), + } + } + #[napi(js_name = "setAnchor")] + pub fn set_anchor(&mut self) { + self.inner.set_anchor(); + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, volume)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(NapiError::from_reason( + "high, low, close, volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], volume[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 + } +} + +// ============================== Demand Index ============================== + +#[napi(js_name = "DemandIndex")] +pub struct DemandIndexNode { + inner: wc::DemandIndex, +} + +#[napi] +impl DemandIndexNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::DemandIndex::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, volume)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(NapiError::from_reason( + "high, low, close, volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], volume[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 + } +} + +// ============================== Time Segmented Volume ============================== + +#[napi(js_name = "TSV")] +pub struct TsvNode { + inner: wc::Tsv, +} + +#[napi] +impl TsvNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::Tsv::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, close: f64, volume: f64) -> napi::Result> { + Ok(self.inner.update(cnd(close, close, close, volume)?)) + } + #[napi] + pub fn batch(&mut self, close: Vec, volume: Vec) -> napi::Result> { + if close.len() != volume.len() { + return Err(NapiError::from_reason( + "close and volume 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(close[i], close[i], close[i], volume[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 + } +} + +// ============================== Volume Zone Oscillator ============================== + +#[napi(js_name = "VZO")] +pub struct VzoNode { + inner: wc::Vzo, +} + +#[napi] +impl VzoNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::Vzo::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, close: f64, volume: f64) -> napi::Result> { + Ok(self.inner.update(cnd(close, close, close, volume)?)) + } + #[napi] + pub fn batch(&mut self, close: Vec, volume: Vec) -> napi::Result> { + if close.len() != volume.len() { + return Err(NapiError::from_reason( + "close and volume 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(close[i], close[i], close[i], volume[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 + } +} + +// ============================== Market Facilitation Index ============================== + +#[napi(js_name = "MarketFacilitationIndex")] +pub struct MarketFacilitationIndexNode { + inner: wc::MarketFacilitationIndex, +} + +#[napi] +impl MarketFacilitationIndexNode { + #[napi(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { + inner: wc::MarketFacilitationIndex::new(), + } + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, volume: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, low, volume)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + volume: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != volume.len() { + return Err(NapiError::from_reason( + "high, low, volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], low[i], volume[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 + } +} + // ============================== Ease of Movement ============================== #[napi(js_name = "EaseOfMovement")] diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index c23cd408..2367dc42 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -123,6 +123,16 @@ from ._wickra import ( ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, + KVO, + VolumeOscillator, + NVI, + PVI, + WilliamsAD, + AnchoredVWAP, + DemandIndex, + TSV, + VZO, + MarketFacilitationIndex, EaseOfMovement, # Statistics TypicalPrice, @@ -246,6 +256,16 @@ __all__ = [ "ChaikinMoneyFlow", "ChaikinOscillator", "ForceIndex", + "KVO", + "VolumeOscillator", + "NVI", + "PVI", + "WilliamsAD", + "AnchoredVWAP", + "DemandIndex", + "TSV", + "VZO", + "MarketFacilitationIndex", "EaseOfMovement", # Statistics "TypicalPrice", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 24c1c39f..5d0be0cc 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -4718,6 +4718,659 @@ impl PyForceIndex { } } +// ============================== Negative Volume Index ============================== + +#[pyclass(name = "NVI", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyNvi { + inner: wc::Nvi, +} + +#[pymethods] +impl PyNvi { + #[new] + #[pyo3(signature = (baseline=1000.0))] + fn new(baseline: f64) -> Self { + Self { + inner: wc::Nvi::with_baseline(baseline), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over close + volume numpy arrays. + fn batch<'py>( + &mut self, + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if c.len() != v.len() { + return Err(PyValueError::new_err( + "close and volume 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], c[i], c[i], c[i], v[i], 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 { + "NVI()".to_string() + } +} + +// ============================== Positive Volume Index ============================== + +#[pyclass(name = "PVI", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyPvi { + inner: wc::Pvi, +} + +#[pymethods] +impl PyPvi { + #[new] + #[pyo3(signature = (baseline=1000.0))] + fn new(baseline: f64) -> Self { + Self { + inner: wc::Pvi::with_baseline(baseline), + } + } + 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>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if c.len() != v.len() { + return Err(PyValueError::new_err( + "close and volume 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], c[i], c[i], c[i], v[i], 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 { + "PVI()".to_string() + } +} + +// ============================== Volume Oscillator ============================== + +#[pyclass( + name = "VolumeOscillator", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyVolumeOscillator { + inner: wc::VolumeOscillator, +} + +#[pymethods] +impl PyVolumeOscillator { + #[new] + #[pyo3(signature = (fast=14, slow=28))] + fn new(fast: usize, slow: usize) -> PyResult { + Ok(Self { + inner: wc::VolumeOscillator::new(fast, slow).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over a 1-D numpy volume array. + fn batch<'py>( + &mut self, + py: Python<'py>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let mut out = Vec::with_capacity(v.len()); + for &vol in v { + let candle = wc::Candle::new(10.0, 10.0, 10.0, 10.0, vol, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn periods(&self) -> (usize, usize) { + self.inner.periods() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (fast, slow) = self.inner.periods(); + format!("VolumeOscillator(fast={fast}, slow={slow})") + } +} + +// ============================== Klinger Volume Oscillator ============================== + +#[pyclass(name = "KVO", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyKvo { + inner: wc::Kvo, +} + +#[pymethods] +impl PyKvo { + #[new] + #[pyo3(signature = (fast=34, slow=55))] + fn new(fast: usize, slow: usize) -> PyResult { + Ok(Self { + inner: wc::Kvo::new(fast, slow).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over high/low/close/volume numpy columns. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: 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))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() { + return Err(PyValueError::new_err( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn periods(&self) -> (usize, usize) { + self.inner.periods() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (fast, slow) = self.inner.periods(); + format!("KVO(fast={fast}, slow={slow})") + } +} + +// ============================== Williams A/D Oscillator ============================== + +#[pyclass(name = "WilliamsAD", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyAdOscillator { + inner: wc::AdOscillator, +} + +#[pymethods] +impl PyAdOscillator { + #[new] + fn new() -> Self { + Self { + inner: wc::AdOscillator::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over high/low/close numpy columns. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.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 { + "WilliamsAD()".to_string() + } +} + +// ============================== Anchored VWAP ============================== + +#[pyclass(name = "AnchoredVWAP", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyAnchoredVwap { + inner: wc::AnchoredVwap, +} + +#[pymethods] +impl PyAnchoredVwap { + #[new] + fn new() -> Self { + Self { + inner: wc::AnchoredVwap::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Re-anchor the cumulative window at the next bar that arrives. + fn set_anchor(&mut self) { + self.inner.set_anchor(); + } + /// Batch over high/low/close/volume numpy columns. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: 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))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() { + return Err(PyValueError::new_err( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 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 { + "AnchoredVWAP()".to_string() + } +} + +// ============================== Demand Index ============================== + +#[pyclass(name = "DemandIndex", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyDemandIndex { + inner: wc::DemandIndex, +} + +#[pymethods] +impl PyDemandIndex { + #[new] + #[pyo3(signature = (period=10))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::DemandIndex::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over high/low/close/volume numpy columns. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: 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))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() { + return Err(PyValueError::new_err( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 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!("DemandIndex(period={})", self.inner.period()) + } +} + +// ============================== Time Segmented Volume ============================== + +#[pyclass(name = "TSV", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTsv { + inner: wc::Tsv, +} + +#[pymethods] +impl PyTsv { + #[new] + #[pyo3(signature = (period=18))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Tsv::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over close + volume numpy columns. + fn batch<'py>( + &mut self, + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if c.len() != v.len() { + return Err(PyValueError::new_err( + "close and volume 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], c[i], c[i], c[i], v[i], 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!("TSV(period={})", self.inner.period()) + } +} + +// ============================== Volume Zone Oscillator ============================== + +#[pyclass(name = "VZO", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyVzo { + inner: wc::Vzo, +} + +#[pymethods] +impl PyVzo { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Vzo::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over close + volume numpy columns. + fn batch<'py>( + &mut self, + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if c.len() != v.len() { + return Err(PyValueError::new_err( + "close and volume 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], c[i], c[i], c[i], v[i], 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!("VZO(period={})", self.inner.period()) + } +} + +// ============================== Market Facilitation Index ============================== + +#[pyclass( + name = "MarketFacilitationIndex", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyMarketFacilitationIndex { + inner: wc::MarketFacilitationIndex, +} + +#[pymethods] +impl PyMarketFacilitationIndex { + #[new] + fn new() -> Self { + Self { + inner: wc::MarketFacilitationIndex::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over high/low/volume numpy columns. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + volume: 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 v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != v.len() { + return Err(PyValueError::new_err( + "high, low, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(l[i], h[i], l[i], l[i], v[i], 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 { + "MarketFacilitationIndex()".to_string() + } +} + // ============================== Ease of Movement ============================== #[pyclass( @@ -7036,6 +7689,16 @@ 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::()?; diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index 16127378..5098d62f 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -155,6 +155,46 @@ CANDLE_SCALAR = { lambda: ta.EaseOfMovement(14), lambda ind, h, l, c, v: ind.batch(h, l, v), ), + "KVO": ( + lambda: ta.KVO(34, 55), + lambda ind, h, l, c, v: ind.batch(h, l, c, v), + ), + "VolumeOscillator": ( + lambda: ta.VolumeOscillator(14, 28), + lambda ind, h, l, c, v: ind.batch(v), + ), + "NVI": ( + lambda: ta.NVI(), + lambda ind, h, l, c, v: ind.batch(c, v), + ), + "PVI": ( + lambda: ta.PVI(), + lambda ind, h, l, c, v: ind.batch(c, v), + ), + "WilliamsAD": ( + lambda: ta.WilliamsAD(), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "AnchoredVWAP": ( + lambda: ta.AnchoredVWAP(), + lambda ind, h, l, c, v: ind.batch(h, l, c, v), + ), + "DemandIndex": ( + lambda: ta.DemandIndex(10), + lambda ind, h, l, c, v: ind.batch(h, l, c, v), + ), + "TSV": ( + lambda: ta.TSV(18), + lambda ind, h, l, c, v: ind.batch(c, v), + ), + "VZO": ( + lambda: ta.VZO(14), + lambda ind, h, l, c, v: ind.batch(c, v), + ), + "MarketFacilitationIndex": ( + lambda: ta.MarketFacilitationIndex(), + lambda ind, h, l, c, v: ind.batch(h, l, v), + ), "AtrTrailingStop": ( lambda: ta.AtrTrailingStop(14, 3.0), lambda ind, h, l, c, v: ind.batch(h, l, c), @@ -463,6 +503,138 @@ def test_weighted_close_reference(): ) +def test_nvi_reference(): + # closes [10, 11], volumes [200, 100]: volume contracts -> NVI absorbs +10%. + # 1000 * (1 + 0.1) = 1100. + nvi = ta.NVI() + out = nvi.batch(np.array([10.0, 11.0]), np.array([200.0, 100.0])) + assert out[0] == pytest.approx(1000.0) + assert out[1] == pytest.approx(1100.0) + + +def test_pvi_reference(): + # closes [10, 11], volumes [100, 200]: volume expands -> PVI absorbs +10%. + pvi = ta.PVI() + out = pvi.batch(np.array([10.0, 11.0]), np.array([100.0, 200.0])) + assert out[0] == pytest.approx(1000.0) + assert out[1] == pytest.approx(1100.0) + + +def test_volume_oscillator_reference(): + # fast=2, slow=4 over volumes [10, 20, 30, 40, 50]: + # bar 4 -> fast=(30+40)/2=35, slow=(10+20+30+40)/4=25 -> VO = 100*(35-25)/25 = 40. + vo = ta.VolumeOscillator(2, 4) + out = vo.batch(np.array([10.0, 20.0, 30.0, 40.0, 50.0])) + assert math.isnan(out[2]) + assert out[3] == pytest.approx(40.0) + assert out[4] == pytest.approx(1000.0 / 35.0) + + +def test_kvo_constant_series_is_zero(): + # A flat series produces dm with no sign change; vf collapses to 0 every + # bar and both EMAs hold at 0, so the KVO line stays at 0. + kvo = ta.KVO(3, 6) + high = np.full(60, 10.0) + low = np.full(60, 10.0) + close = np.full(60, 10.0) + volume = np.full(60, 100.0) + out = kvo.batch(high, low, close, volume) + for v in out[~np.isnan(out)]: + assert v == pytest.approx(0.0, abs=1e-12) + + +def test_williams_ad_reference(): + # bar 0 seeds prev_close = 10. + # bar 1: prev=10, today high=13, low=8, close=12 (up day). + # TR_l = min(10, 8) = 8 -> delta = 12 - 8 = 4. AD = 4. + # bar 2: prev=12, today high=11, low=7, close=7 (down day). + # TR_h = max(12, 11) = 12 -> delta = 7 - 12 = -5. AD = 4 - 5 = -1. + ad = ta.WilliamsAD() + high = np.array([11.0, 13.0, 11.0]) + low = np.array([9.0, 8.0, 7.0]) + close = np.array([10.0, 12.0, 7.0]) + out = ad.batch(high, low, close) + assert math.isnan(out[0]) + assert out[1] == pytest.approx(4.0) + assert out[2] == pytest.approx(-1.0) + + +def test_anchored_vwap_reference(): + # Three flat-OHLC bars: typical_price equals price. + # 10@1, 20@1, 30@1 -> mean = 20. + avwap = ta.AnchoredVWAP() + high = np.array([10.0, 20.0, 30.0]) + low = np.array([10.0, 20.0, 30.0]) + close = np.array([10.0, 20.0, 30.0]) + volume = np.array([1.0, 1.0, 1.0]) + out = avwap.batch(high, low, close, volume) + assert out[2] == pytest.approx(20.0) + + +def test_anchored_vwap_set_anchor_clears_window(): + # Drive a few flat bars, re-anchor, then drive a high-priced bar: + # the new running mean must equal the new bar's typical price. + avwap = ta.AnchoredVWAP() + for _ in range(3): + avwap.update((10.0, 10.0, 10.0, 10.0, 1.0, 0)) + assert avwap.is_ready() + avwap.set_anchor() + v = avwap.update((100.0, 100.0, 100.0, 100.0, 5.0, 1)) + assert v == pytest.approx(100.0) + + +def test_tsv_reference(): + # closes = [10, 11, 13, 12, 14, 15] + # volumes = [50, 100, 200, 150, 50, 200] + # flows = [None, 1*100=100, 2*200=400, -1*150=-150, 2*50=100, 1*200=200] + # period=3: first emission at index 3. + # bar 3 window=[100,400,-150] -> 350 + # bar 4 window=[400,-150,100] -> 350 + # bar 5 window=[-150,100,200] -> 150 + tsv = ta.TSV(3) + close = np.array([10.0, 11.0, 13.0, 12.0, 14.0, 15.0]) + volume = np.array([50.0, 100.0, 200.0, 150.0, 50.0, 200.0]) + out = tsv.batch(close, volume) + assert math.isnan(out[0]) and math.isnan(out[1]) and math.isnan(out[2]) + assert out[3] == pytest.approx(350.0) + assert out[4] == pytest.approx(350.0) + assert out[5] == pytest.approx(150.0) + + +def test_vzo_strictly_rising_saturates_to_plus_100(): + # Every bar is an up-day with identical volume -> signed_volume == volume, + # so the smoothed signed-volume EMA equals the smoothed total-volume EMA, + # giving a ratio of 1 -> VZO = +100. + vzo = ta.VZO(5) + close = np.array([10.0 + i for i in range(60)]) + volume = np.full(60, 100.0) + out = vzo.batch(close, volume) + last = out[~np.isnan(out)][-1] + assert last == pytest.approx(100.0) + + +def test_market_facilitation_index_reference(): + # (high - low) / volume = (12 - 8) / 200 = 0.02. + mfi_bw = ta.MarketFacilitationIndex() + high = np.array([12.0]) + low = np.array([8.0]) + volume = np.array([200.0]) + out = mfi_bw.batch(high, low, volume) + assert out[0] == pytest.approx(0.02) + + +def test_demand_index_constant_series_is_zero(): + # Flat close -> pressure = 0 every bar -> EMA stays at 0. + di = ta.DemandIndex(5) + high = np.full(60, 10.0) + low = np.full(60, 10.0) + close = np.full(60, 10.0) + volume = np.full(60, 100.0) + out = di.batch(high, low, close, volume) + for v in out[~np.isnan(out)]: + assert v == pytest.approx(0.0, abs=1e-12) + + def test_chaikin_money_flow_reference(): cmf = ta.ChaikinMoneyFlow(2) assert cmf.update((8.0, 10.0, 8.0, 10.0, 100.0, 0)) is None diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 7e1b94fc..0a2bc544 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1198,6 +1198,397 @@ impl WasmForceIndex { } } +#[wasm_bindgen(js_name = VolumeOscillator)] +pub struct WasmVolumeOscillator { + inner: wc::VolumeOscillator, +} + +#[wasm_bindgen(js_class = VolumeOscillator)] +impl WasmVolumeOscillator { + #[wasm_bindgen(constructor)] + pub fn new(fast: usize, slow: usize) -> Result { + Ok(Self { + inner: wc::VolumeOscillator::new(fast, slow).map_err(map_err)?, + }) + } + pub fn update(&mut self, volume: f64) -> Result, JsError> { + let c = make_candle(10.0, 10.0, 10.0, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, volume: &[f64]) -> Result { + let mut out = Vec::with_capacity(volume.len()); + for &v in volume { + let c = make_candle(10.0, 10.0, 10.0, v)?; + 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 = NVI)] +pub struct WasmNvi { + inner: wc::Nvi, +} + +#[wasm_bindgen(js_class = NVI)] +impl WasmNvi { + #[wasm_bindgen(constructor)] + pub fn new(baseline: Option) -> WasmNvi { + Self { + inner: wc::Nvi::with_baseline(baseline.unwrap_or(1000.0)), + } + } + pub fn update(&mut self, close: f64, volume: f64) -> Result, JsError> { + let c = make_candle(close, close, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result { + if close.len() != volume.len() { + return Err(JsError::new("close and volume must be equal length")); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle(close[i], close[i], close[i], volume[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 = PVI)] +pub struct WasmPvi { + inner: wc::Pvi, +} + +#[wasm_bindgen(js_class = PVI)] +impl WasmPvi { + #[wasm_bindgen(constructor)] + pub fn new(baseline: Option) -> WasmPvi { + Self { + inner: wc::Pvi::with_baseline(baseline.unwrap_or(1000.0)), + } + } + pub fn update(&mut self, close: f64, volume: f64) -> Result, JsError> { + let c = make_candle(close, close, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result { + if close.len() != volume.len() { + return Err(JsError::new("close and volume must be equal length")); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle(close[i], close[i], close[i], volume[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 = KVO)] +pub struct WasmKvo { + inner: wc::Kvo, +} + +#[wasm_bindgen(js_class = KVO)] +impl WasmKvo { + #[wasm_bindgen(constructor)] + pub fn new(fast: usize, slow: usize) -> Result { + Ok(Self { + inner: wc::Kvo::new(fast, slow).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> Result, JsError> { + let c = make_candle(high, low, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n || volume.len() != n { + return Err(JsError::new( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], volume[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 = WilliamsAD)] +pub struct WasmAdOscillator { + inner: wc::AdOscillator, +} + +#[wasm_bindgen(js_class = WilliamsAD)] +impl WasmAdOscillator { + #[wasm_bindgen(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> WasmAdOscillator { + Self { + inner: wc::AdOscillator::new(), + } + } + 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 { + let n = high.len(); + if low.len() != n || close.len() != n { + return Err(JsError::new("high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + 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 = AnchoredVWAP)] +pub struct WasmAnchoredVwap { + inner: wc::AnchoredVwap, +} + +#[wasm_bindgen(js_class = AnchoredVWAP)] +impl WasmAnchoredVwap { + #[wasm_bindgen(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> WasmAnchoredVwap { + Self { + inner: wc::AnchoredVwap::new(), + } + } + #[wasm_bindgen(js_name = setAnchor)] + pub fn set_anchor(&mut self) { + self.inner.set_anchor(); + } + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> Result, JsError> { + let c = make_candle(high, low, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n || volume.len() != n { + return Err(JsError::new( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], volume[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 = DemandIndex)] +pub struct WasmDemandIndex { + inner: wc::DemandIndex, +} + +#[wasm_bindgen(js_class = DemandIndex)] +impl WasmDemandIndex { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::DemandIndex::new(period).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> Result, JsError> { + let c = make_candle(high, low, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n || volume.len() != n { + return Err(JsError::new( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], volume[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 = TSV)] +pub struct WasmTsv { + inner: wc::Tsv, +} + +#[wasm_bindgen(js_class = TSV)] +impl WasmTsv { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::Tsv::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, close: f64, volume: f64) -> Result, JsError> { + let c = make_candle(close, close, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result { + if close.len() != volume.len() { + return Err(JsError::new("close and volume must be equal length")); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle(close[i], close[i], close[i], volume[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 = VZO)] +pub struct WasmVzo { + inner: wc::Vzo, +} + +#[wasm_bindgen(js_class = VZO)] +impl WasmVzo { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::Vzo::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, close: f64, volume: f64) -> Result, JsError> { + let c = make_candle(close, close, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result { + if close.len() != volume.len() { + return Err(JsError::new("close and volume must be equal length")); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle(close[i], close[i], close[i], volume[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 = MarketFacilitationIndex)] +pub struct WasmMarketFacilitationIndex { + inner: wc::MarketFacilitationIndex, +} + +#[wasm_bindgen(js_class = MarketFacilitationIndex)] +impl WasmMarketFacilitationIndex { + #[wasm_bindgen(constructor)] + #[allow(clippy::new_without_default)] + pub fn new() -> WasmMarketFacilitationIndex { + Self { + inner: wc::MarketFacilitationIndex::new(), + } + } + pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result, JsError> { + let c = make_candle(high, low, low, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + volume: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || volume.len() != n { + return Err(JsError::new("high, low, volume must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], low[i], volume[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 = EaseOfMovement)] pub struct WasmEaseOfMovement { inner: wc::EaseOfMovement, diff --git a/crates/wickra-core/src/indicators/ad_oscillator.rs b/crates/wickra-core/src/indicators/ad_oscillator.rs new file mode 100644 index 00000000..c978e760 --- /dev/null +++ b/crates/wickra-core/src/indicators/ad_oscillator.rs @@ -0,0 +1,220 @@ +//! Williams Accumulation/Distribution. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Larry Williams' Accumulation/Distribution — a cumulative volume-less price +/// flow that classifies each bar as accumulation or distribution based on its +/// close relative to the previous close, then sums the directional component. +/// +/// Williams' definition (1972) uses a *true* high/low that includes the prior +/// close as an anchor — the same idea that motivates true range: +/// +/// ```text +/// TR_h_t = max(close_{t−1}, high_t) +/// TR_l_t = min(close_{t−1}, low_t) +/// AD_t = AD_{t−1} + (close_t − TR_l_t) if close_t > close_{t−1} (accumulation) +/// AD_t = AD_{t−1} + (close_t − TR_h_t) if close_t < close_{t−1} (distribution) +/// AD_t = AD_{t−1} if close_t == close_{t−1} (no change) +/// ``` +/// +/// Unlike Chaikin's Accumulation/Distribution Line, the Williams A/D ignores +/// volume entirely — Williams argued that the relative position of the close +/// already encodes the day's "true" buying or selling pressure. The series is +/// unbounded and used primarily for divergence analysis. The first candle only +/// seeds the previous close; the first emission lands at bar 2. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AdOscillator}; +/// +/// let mut indicator = AdOscillator::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AdOscillator { + prev_close: Option, + total: f64, + has_emitted: bool, +} + +impl AdOscillator { + /// Construct a new Williams A/D starting at zero. + pub const fn new() -> Self { + Self { + prev_close: None, + total: 0.0, + has_emitted: false, + } + } + + /// Current cumulative value if at least one emission has happened. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.total) + } else { + None + } + } +} + +impl Indicator for AdOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev_close else { + // The first bar only establishes the previous close anchor. + self.prev_close = Some(candle.close); + return None; + }; + let delta = if candle.close > prev { + // Accumulation: distance from the true low. + let tr_l = prev.min(candle.low); + candle.close - tr_l + } else if candle.close < prev { + // Distribution: distance from the true high (negative). + let tr_h = prev.max(candle.high); + candle.close - tr_h + } else { + // Unchanged close contributes nothing. + 0.0 + }; + self.total += delta; + self.prev_close = Some(candle.close); + self.has_emitted = true; + Some(self.total) + } + + fn reset(&mut self) { + self.prev_close = None; + self.total = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // One seed bar; the second bar is the first emission. + 2 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "WilliamsAD" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 100.0, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let ad = AdOscillator::new(); + assert_eq!(ad.name(), "WilliamsAD"); + assert_eq!(ad.warmup_period(), 2); + assert_eq!(ad.value(), None); + } + + #[test] + fn value_returns_total_after_first_emission() { + let mut ad = AdOscillator::new(); + ad.update(c(10.0, 11.0, 9.0, 10.0, 0)); + let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap(); + assert_relative_eq!(ad.value().unwrap(), v, epsilon = 1e-12); + } + + #[test] + fn first_bar_only_seeds() { + let mut ad = AdOscillator::new(); + assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 0)), None); + assert!(!ad.is_ready()); + } + + #[test] + fn accumulation_adds_distance_from_true_low() { + // prev close = 10, today low = 8, today close = 12 (up day). + // TR_l = min(10, 8) = 8, delta = 12 - 8 = 4. AD = 0 + 4 = 4. + let mut ad = AdOscillator::new(); + ad.update(c(10.0, 11.0, 9.0, 10.0, 0)); + let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap(); + assert_relative_eq!(v, 4.0, epsilon = 1e-12); + } + + #[test] + fn distribution_adds_distance_from_true_high() { + // prev close = 10, today high = 11, today close = 7 (down day). + // TR_h = max(10, 11) = 11, delta = 7 - 11 = -4. AD = -4. + let mut ad = AdOscillator::new(); + ad.update(c(10.0, 11.0, 9.0, 10.0, 0)); + let v = ad.update(c(10.0, 11.0, 7.0, 7.0, 1)).unwrap(); + assert_relative_eq!(v, -4.0, epsilon = 1e-12); + } + + #[test] + fn unchanged_close_keeps_total() { + // close equals prev close -> no contribution. + let mut ad = AdOscillator::new(); + ad.update(c(10.0, 11.0, 9.0, 10.0, 0)); + let v = ad.update(c(10.0, 12.0, 8.0, 10.0, 1)).unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + // Every close equals the previous -> AD stays at zero forever. + let candles: Vec = (0..40).map(|i| c(10.0, 11.0, 9.0, 10.0, i)).collect(); + let mut ad = AdOscillator::new(); + for v in ad.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80i64) + .map(|i| { + let f = i as f64; + let mid = 100.0 + (f * 0.3).sin() * 5.0; + c(mid, mid + 2.0, mid - 2.0, mid + 0.5, i) + }) + .collect(); + let mut a = AdOscillator::new(); + let mut b = AdOscillator::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut ad = AdOscillator::new(); + ad.batch(&[ + c(10.0, 11.0, 9.0, 10.0, 0), + c(10.0, 12.0, 9.0, 11.0, 1), + c(11.0, 13.0, 10.0, 12.0, 2), + ]); + assert!(ad.is_ready()); + ad.reset(); + assert!(!ad.is_ready()); + assert_eq!(ad.value(), None); + assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 3)), None); + } +} diff --git a/crates/wickra-core/src/indicators/anchored_vwap.rs b/crates/wickra-core/src/indicators/anchored_vwap.rs new file mode 100644 index 00000000..593b0248 --- /dev/null +++ b/crates/wickra-core/src/indicators/anchored_vwap.rs @@ -0,0 +1,207 @@ +//! Anchored Volume-Weighted Average Price. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Anchored VWAP — a cumulative VWAP whose accumulation begins at a +/// user-chosen anchor bar rather than the session open. +/// +/// ```text +/// AVWAP_t = Σ_{i ≥ anchor} (typical_price_i · volume_i) / Σ_{i ≥ anchor} volume_i +/// ``` +/// +/// The indicator emits `None` until the first anchored bar has been ingested. +/// Calling [`AnchoredVwap::set_anchor`] re-anchors at the **next** bar that +/// arrives, clearing the running sums; this is the conventional behaviour for +/// "click to anchor" trader workflows where the anchor is set on the close of +/// a swing point and the next bar starts the new accumulation. The cumulative +/// total is unbounded; for finite-memory needs use [`crate::RollingVwap`]. +/// +/// Bars where the running volume is still zero (only happens if every anchored +/// bar so far carried zero volume) return `None` to avoid a zero-division. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AnchoredVwap, Candle, Indicator}; +/// +/// let mut indicator = AnchoredVwap::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// // Re-anchor at bar 40 (e.g. a major swing low). +/// if i == 40 { +/// indicator.set_anchor(); +/// } +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AnchoredVwap { + sum_pv: f64, + sum_v: f64, + has_emitted: bool, + pending_anchor: bool, +} + +impl AnchoredVwap { + /// Construct a fresh Anchored VWAP. The first bar to arrive is the anchor. + pub const fn new() -> Self { + Self { + sum_pv: 0.0, + sum_v: 0.0, + has_emitted: false, + pending_anchor: false, + } + } + + /// Mark a re-anchor: the **next** [`Indicator::update`] call clears the + /// running sums before adding its own contribution, effectively starting a + /// fresh anchored window. + pub fn set_anchor(&mut self) { + self.pending_anchor = true; + } + + /// Current anchored value if at least one bar with non-zero volume has + /// been observed in the current anchor window. + pub fn value(&self) -> Option { + if self.sum_v == 0.0 { + None + } else { + Some(self.sum_pv / self.sum_v) + } + } +} + +impl Indicator for AnchoredVwap { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if self.pending_anchor { + // Drop the old window before folding in this bar. + self.sum_pv = 0.0; + self.sum_v = 0.0; + self.has_emitted = false; + self.pending_anchor = false; + } + let tp = candle.typical_price(); + self.sum_pv += tp * candle.volume; + self.sum_v += candle.volume; + if self.sum_v == 0.0 { + return None; + } + self.has_emitted = true; + Some(self.sum_pv / self.sum_v) + } + + fn reset(&mut self) { + self.sum_pv = 0.0; + self.sum_v = 0.0; + self.has_emitted = false; + self.pending_anchor = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AnchoredVWAP" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(price: f64, volume: f64, ts: i64) -> Candle { + Candle::new(price, price, price, price, volume, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let v = AnchoredVwap::new(); + assert_eq!(v.name(), "AnchoredVWAP"); + assert_eq!(v.warmup_period(), 1); + assert_eq!(v.value(), None); + } + + #[test] + fn first_bar_with_zero_volume_returns_none() { + let mut v = AnchoredVwap::new(); + assert_eq!(v.update(c(50.0, 0.0, 0)), None); + assert!(!v.is_ready()); + // The next bar with volume still works. + assert_relative_eq!(v.update(c(10.0, 4.0, 1)).unwrap(), 10.0, epsilon = 1e-12); + } + + #[test] + fn equal_volumes_yield_mean_typical_price() { + // typical_price of a flat OHLC bar equals the price. + let mut v = AnchoredVwap::new(); + let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1), c(30.0, 1.0, 2)]); + assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-12); + } + + #[test] + fn set_anchor_clears_old_window() { + // Run a few bars at price 10, then re-anchor and pump in price 100. + // After the re-anchor the running mean must be 100, not the mix. + let mut v = AnchoredVwap::new(); + v.batch(&[c(10.0, 1.0, 0), c(10.0, 1.0, 1), c(10.0, 1.0, 2)]); + assert_relative_eq!(v.value().unwrap(), 10.0, epsilon = 1e-12); + v.set_anchor(); + let after = v.update(c(100.0, 5.0, 3)).unwrap(); + assert_relative_eq!(after, 100.0, epsilon = 1e-12); + } + + #[test] + fn set_anchor_before_first_bar_acts_as_normal_first_bar() { + // Calling set_anchor on an empty indicator should be a no-op effect: + // the first bar still anchors the window. + let mut v = AnchoredVwap::new(); + v.set_anchor(); + assert_relative_eq!(v.update(c(42.0, 2.0, 0)).unwrap(), 42.0, epsilon = 1e-12); + } + + #[test] + fn weighted_average_reference() { + // Two bars: 10@1, 20@3 -> (10 + 60) / 4 = 17.5. + let mut v = AnchoredVwap::new(); + let out = v.batch(&[c(10.0, 1.0, 0), c(20.0, 3.0, 1)]); + assert_relative_eq!(out[1].unwrap(), 17.5, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (1..30).map(|i| c(f64::from(i), 1.0, i.into())).collect(); + let mut a = AnchoredVwap::new(); + let mut b = AnchoredVwap::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut v = AnchoredVwap::new(); + v.batch(&[c(10.0, 1.0, 0), c(20.0, 1.0, 1)]); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.value(), None); + // After reset the first bar acts as the new anchor. + assert_relative_eq!(v.update(c(50.0, 1.0, 2)).unwrap(), 50.0, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/demand_index.rs b/crates/wickra-core/src/indicators/demand_index.rs new file mode 100644 index 00000000..ed1ccfb3 --- /dev/null +++ b/crates/wickra-core/src/indicators/demand_index.rs @@ -0,0 +1,242 @@ +//! Demand Index (James Sibbet). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// James Sibbet's Demand Index — a smoothed ratio of buying pressure to +/// selling pressure, classifying each bar's volume by whether the close rose +/// or fell relative to the previous close. +/// +/// Sibbet's original 1970s formulation runs the raw buying/selling pressure +/// through several smoothings and yields a number that swings in `[−100, 100]`. +/// This implementation uses the textbook simplified form that captures the same +/// signal in a streaming-friendly shape: +/// +/// ```text +/// pressure_t = volume_t · ((close_t − close_{t−1}) / max(close_{t−1}, ε)) +/// · (1 + (high_t − low_t) / max(close_{t−1}, ε)) +/// DI_t = EMA(pressure, period)_t +/// ``` +/// +/// Positive readings mean the smoothed money flow is leaning to the buy side +/// (up-day volume dominates), negative to the sell side. The first candle only +/// establishes the previous close, so the first non-`None` value lands once the +/// EMA has accumulated `period` pressure samples. A previous close of zero +/// contributes no signal (avoids division by zero). The output is unbounded; +/// what matters is the sign and the divergence against price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, DemandIndex, Indicator}; +/// +/// let mut indicator = DemandIndex::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 50.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct DemandIndex { + period: usize, + ema: Ema, + prev_close: Option, +} + +impl DemandIndex { + /// Construct a new Demand Index with the given EMA smoothing period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + ema: Ema::new(period)?, + prev_close: None, + }) + } + + /// Configured EMA smoothing period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for DemandIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + let pressure = if prev == 0.0 { + // No prior baseline -> can't normalise; treat as no flow. + 0.0 + } else { + let ret = (candle.close - prev) / prev; + let range_norm = (candle.high - candle.low) / prev; + candle.volume * ret * (1.0 + range_norm) + }; + self.prev_close = Some(candle.close); + self.ema.update(pressure) + } + + fn reset(&mut self) { + self.ema.reset(); + self.prev_close = None; + } + + fn warmup_period(&self) -> usize { + // One seed bar to establish the previous close, then the EMA needs + // `period` samples to seed. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.ema.is_ready() + } + + fn name(&self) -> &'static str { + "DemandIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(DemandIndex::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let di = DemandIndex::new(10).unwrap(); + assert_eq!(di.period(), 10); + assert_eq!(di.name(), "DemandIndex"); + assert_eq!(di.warmup_period(), 11); + } + + #[test] + fn constant_series_yields_zero() { + // No close change -> pressure = 0 on every bar -> EMA stays at 0. + let candles: Vec = (0..40) + .map(|i| c(10.0, 10.0, 10.0, 10.0, 100.0, i)) + .collect(); + let mut di = DemandIndex::new(5).unwrap(); + for v in di.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn rising_series_yields_positive_signal() { + // Strictly rising closes on constant volume -> pressure is positive every + // bar -> smoothed DI must end up strictly positive. + let candles: Vec = (0..40) + .map(|i| { + let f = i as f64; + c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i) + }) + .collect(); + let mut di = DemandIndex::new(5).unwrap(); + let out = di.batch(&candles); + let last = out.iter().filter_map(|x| *x).next_back().unwrap(); + assert!( + last > 0.0, + "rising series must yield positive DI, got {last}" + ); + } + + #[test] + fn falling_series_yields_negative_signal() { + let candles: Vec = (0..40) + .map(|i| { + let f = i as f64; + c(200.0 - f, 201.0 - f, 199.0 - f, 199.5 - f, 100.0, i) + }) + .collect(); + let mut di = DemandIndex::new(5).unwrap(); + let out = di.batch(&candles); + let last = out.iter().filter_map(|x| *x).next_back().unwrap(); + assert!( + last < 0.0, + "falling series must yield negative DI, got {last}" + ); + } + + #[test] + fn zero_prev_close_contributes_no_signal() { + // First two bars: prev close is exactly zero -> pressure clipped to 0. + // We then continue with a non-zero series and confirm output behaves. + let mut di = DemandIndex::new(3).unwrap(); + di.update(c(0.0, 0.0, 0.0, 0.0, 100.0, 0)); + // Bar 2 sees prev_close == 0 -> pressure = 0. + di.update(c(0.0, 1.0, 0.0, 1.0, 100.0, 1)); + // Subsequent bars now have non-zero prev_close. + di.update(c(1.0, 2.0, 1.0, 2.0, 100.0, 2)); + // Just check that nothing exploded; an EMA(3) needs 3 samples post-seed. + // The first sample at bar 2 was zero, the second at bar 3 positive. + let v = di.update(c(2.0, 3.0, 2.0, 3.0, 100.0, 3)); + assert!(v.is_some()); + assert!(v.unwrap().is_finite()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..100i64) + .map(|i| { + let f = i as f64; + let mid = 100.0 + (f * 0.2).sin() * 5.0; + c( + mid, + mid + 1.5, + mid - 1.5, + mid + 0.3, + 80.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let mut a = DemandIndex::new(10).unwrap(); + let mut b = DemandIndex::new(10).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let f = i as f64; + c(100.0 + f, 101.0 + f, 99.0 + f, 100.5 + f, 100.0, i) + }) + .collect(); + let mut di = DemandIndex::new(5).unwrap(); + di.batch(&candles); + assert!(di.is_ready()); + di.reset(); + assert!(!di.is_ready()); + assert_eq!(di.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/kvo.rs b/crates/wickra-core/src/indicators/kvo.rs new file mode 100644 index 00000000..b2c249a6 --- /dev/null +++ b/crates/wickra-core/src/indicators/kvo.rs @@ -0,0 +1,263 @@ +//! Klinger Volume Oscillator. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Stephen J. Klinger's Volume Oscillator — a long/short-term volume-force +/// MACD with trend-aware cumulative-money-flow weighting. +/// +/// Each bar produces a "volume force" (`vf`) whose sign tracks the daily trend +/// (`+1` on an up day, `−1` on a down day, carry-over otherwise) and whose +/// magnitude scales with how the current accumulation horizon compares to the +/// previous trend's. The KVO line is the difference of two EMAs of `vf`: +/// +/// ```text +/// dm_t = high_t + low_t + close_t (the "daily measurement") +/// trend = sign(dm_t − dm_{t−1}) if differs from previous trend, reset cm +/// cm_t = cm_{t−1} + dm_t if trend unchanged +/// cm_t = dm_{t−1} + dm_t if trend just flipped +/// vf_t = volume_t · |2·(dm_t/cm_t − 1)| · trend · 100 +/// KVO_t = EMA(vf, fast)_t − EMA(vf, slow)_t +/// ``` +/// +/// Klinger's textbook configuration is `fast = 34, slow = 55` on daily bars. +/// The first bar only seeds `dm_{t−1}`, so the very first `vf` lands at bar 2; +/// the slow EMA then needs `slow` raw `vf` values to seed, putting the first +/// KVO emission at bar `slow + 1`. A zero `cm_t` (which only happens on the +/// trend-flip branch when both the prior and current `dm` are zero) collapses +/// `vf` to `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Kvo}; +/// +/// let mut indicator = Kvo::new(34, 55).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Kvo { + fast_period: usize, + slow_period: usize, + fast: Ema, + slow: Ema, + prev_dm: Option, + trend: i8, + cm: f64, +} + +impl Kvo { + /// Construct a new KVO with the given EMA periods. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if either period is zero, or + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "KVO needs fast < slow", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + fast: Ema::new(fast)?, + slow: Ema::new(slow)?, + prev_dm: None, + trend: 0, + cm: 0.0, + }) + } + + /// Klinger's classic configuration: `EMA(vf, 34) − EMA(vf, 55)`. + pub fn classic() -> Self { + Self::new(34, 55).expect("classic Klinger periods are valid") + } + + /// Configured `(fast, slow)` periods. + pub const fn periods(&self) -> (usize, usize) { + (self.fast_period, self.slow_period) + } +} + +impl Indicator for Kvo { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let dm = candle.high + candle.low + candle.close; + let Some(prev_dm) = self.prev_dm else { + // The first bar only establishes the previous daily measurement. + self.prev_dm = Some(dm); + return None; + }; + + // Determine the bar's trend sign relative to the previous bar. + let new_trend: i8 = if dm > prev_dm { + 1 + } else if dm < prev_dm { + -1 + } else { + self.trend + }; + + // Cumulative measurement resets to (prev_dm + dm) whenever the trend + // flips. On the very first sign read (trend was 0) we also seed from + // the two-bar sum, matching the textbook definition. + if new_trend != self.trend || self.trend == 0 { + self.cm = prev_dm + dm; + } else { + self.cm += dm; + } + self.trend = new_trend; + + let vf = if self.cm == 0.0 { + // Pathological all-zero OHLC stretch — no force to register. + 0.0 + } else { + candle.volume * (2.0 * (dm / self.cm - 1.0)).abs() * f64::from(new_trend) * 100.0 + }; + + self.prev_dm = Some(dm); + + let fast = self.fast.update(vf); + let slow = self.slow.update(vf); + Some(fast? - slow?) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + self.prev_dm = None; + self.trend = 0; + self.cm = 0.0; + } + + fn warmup_period(&self) -> usize { + // One bar to seed `prev_dm`, then the slow EMA needs `slow` raw `vf` values. + self.slow_period + 1 + } + + fn is_ready(&self) -> bool { + self.fast.is_ready() && self.slow.is_ready() + } + + fn name(&self) -> &'static str { + "KVO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(low, high, low, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Kvo::new(0, 10), Err(Error::PeriodZero))); + assert!(matches!(Kvo::new(3, 0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!(Kvo::new(34, 34), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Kvo::new(55, 34), Err(Error::InvalidPeriod { .. }))); + } + + #[test] + fn accessors_and_metadata() { + let k = Kvo::classic(); + assert_eq!(k.periods(), (34, 55)); + assert_eq!(k.name(), "KVO"); + assert_eq!(k.warmup_period(), 56); + } + + #[test] + fn zero_ohlc_collapses_vf_to_zero() { + // Two consecutive all-zero bars: dm = 0 for both, so prev_dm + dm = 0 + // and `cm == 0.0` fires the defensive branch, holding vf at zero. + let mut k = Kvo::new(3, 6).unwrap(); + let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 100.0, 0).unwrap(); + assert_eq!(k.update(zero), None); + assert_eq!(k.update(zero), None); + assert_eq!(k.update(zero), None); + } + + #[test] + fn constant_series_yields_zero() { + // dm flat -> trend never sets to a nonzero sign and vf collapses to 0 + // for every bar; both EMAs hold at 0 once seeded. + let candles: Vec = (0..120).map(|i| c(10.0, 10.0, 10.0, 100.0, i)).collect(); + let mut k = Kvo::new(3, 6).unwrap(); + for v in k.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_at_slow_plus_one() { + let candles: Vec = (0..30i64) + .map(|i| { + let f = i as f64; + c(10.0 + f, 8.0 + f, 9.0 + f, 100.0, i) + }) + .collect(); + let mut k = Kvo::new(3, 5).unwrap(); + let out = k.batch(&candles); + for (i, v) in out.iter().enumerate().take(5) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + // First emission lands at index slow_period (one seed bar + slow EMA seeding from there). + assert!(out[5].is_some(), "first value lands at slow_period"); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..100i64) + .map(|i| { + let f = i as f64; + let mid = 100.0 + (f * 0.2).sin() * 4.0; + c(mid + 1.0, mid - 1.0, mid, 10.0 + ((i % 5) as f64), i) + }) + .collect(); + let mut a = Kvo::classic(); + let mut b = Kvo::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..80i64) + .map(|i| { + let f = i as f64; + c(11.0 + f, 9.0 + f, 10.0 + f, 100.0, i) + }) + .collect(); + let mut k = Kvo::classic(); + k.batch(&candles); + assert!(k.is_ready()); + k.reset(); + assert!(!k.is_ready()); + assert_eq!(k.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/market_facilitation_index.rs b/crates/wickra-core/src/indicators/market_facilitation_index.rs new file mode 100644 index 00000000..0f0aad0a --- /dev/null +++ b/crates/wickra-core/src/indicators/market_facilitation_index.rs @@ -0,0 +1,185 @@ +//! Market Facilitation Index (Bill Williams). + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Bill Williams' Market Facilitation Index — how much price movement the +/// market produces per unit of volume. +/// +/// ```text +/// MFI_BW_t = (high_t − low_t) / volume_t +/// ``` +/// +/// A rising MFI on rising volume ("green") signals strong participation behind +/// the move; a rising MFI on falling volume ("fake") suggests a low-volume push +/// that may not hold. Williams pairs MFI with a "Squat" or "Fade" classification +/// against the prior bar's MFI/volume — a downstream concern; this struct only +/// emits the per-bar ratio. A bar with zero volume returns `None` (no +/// facilitation can be defined). Output is emitted from the very first bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MarketFacilitationIndex}; +/// +/// let mut indicator = MarketFacilitationIndex::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 50.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct MarketFacilitationIndex { + has_emitted: bool, + last_value: f64, +} + +impl MarketFacilitationIndex { + /// Construct a new Market Facilitation Index. + pub const fn new() -> Self { + Self { + has_emitted: false, + last_value: 0.0, + } + } + + /// Most recent value if at least one bar with non-zero volume has been + /// observed. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.last_value) + } else { + None + } + } +} + +impl Indicator for MarketFacilitationIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if candle.volume == 0.0 { + // No trade activity -> facilitation is undefined. + return None; + } + let v = (candle.high - candle.low) / candle.volume; + self.last_value = v; + self.has_emitted = true; + Some(v) + } + + fn reset(&mut self) { + self.has_emitted = false; + self.last_value = 0.0; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "MarketFacilitationIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let m = MarketFacilitationIndex::new(); + assert_eq!(m.name(), "MarketFacilitationIndex"); + assert_eq!(m.warmup_period(), 1); + assert_eq!(m.value(), None); + } + + #[test] + fn reference_value() { + // (12 − 8) / 200 = 0.02. + let mut m = MarketFacilitationIndex::new(); + let v = m.update(c(10.0, 12.0, 8.0, 11.0, 200.0, 0)).unwrap(); + assert_relative_eq!(v, 0.02, epsilon = 1e-12); + assert_relative_eq!(m.value().unwrap(), 0.02, epsilon = 1e-12); + } + + #[test] + fn constant_series_is_constant() { + // Same OHLCV every bar -> same ratio every bar. + let candles: Vec = (0..30) + .map(|i| c(10.0, 11.0, 9.0, 10.0, 100.0, i)) + .collect(); + let mut m = MarketFacilitationIndex::new(); + for v in m.batch(&candles).into_iter().flatten() { + // 2/100 = 0.02. + assert_relative_eq!(v, 0.02, epsilon = 1e-12); + } + } + + #[test] + fn zero_volume_returns_none() { + let mut m = MarketFacilitationIndex::new(); + assert_eq!(m.update(c(10.0, 11.0, 9.0, 10.0, 0.0, 0)), None); + assert!(!m.is_ready()); + // Subsequent non-zero-volume bar still works. + let v = m.update(c(10.0, 12.0, 8.0, 10.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 0.04, epsilon = 1e-12); + } + + #[test] + fn zero_range_bar_yields_zero() { + // high == low -> ratio = 0. + let mut m = MarketFacilitationIndex::new(); + let v = m.update(c(10.0, 10.0, 10.0, 10.0, 100.0, 0)).unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60i64) + .map(|i| { + let f = i as f64; + let mid = 100.0 + (f * 0.3).sin() * 5.0; + c( + mid, + mid + 2.0, + mid - 2.0, + mid + 0.5, + 50.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let mut a = MarketFacilitationIndex::new(); + let mut b = MarketFacilitationIndex::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut m = MarketFacilitationIndex::new(); + m.update(c(10.0, 12.0, 8.0, 11.0, 100.0, 0)); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + assert_eq!(m.value(), None); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index b31f7d21..7eb83b0a 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -6,11 +6,13 @@ mod acceleration_bands; mod accelerator_oscillator; +mod ad_oscillator; mod adl; mod adx; mod adxr; mod alligator; mod alma; +mod anchored_vwap; mod apo; mod aroon; mod aroon_oscillator; @@ -34,6 +36,7 @@ mod cmo; mod connors_rsi; mod coppock; mod dema; +mod demand_index; mod donchian; mod double_bollinger; mod dpo; @@ -53,6 +56,7 @@ mod jma; mod kama; mod keltner; mod kst; +mod kvo; mod laguerre_rsi; mod linreg; mod linreg_angle; @@ -60,12 +64,14 @@ mod linreg_channel; mod linreg_slope; mod ma_envelope; mod macd; +mod market_facilitation_index; mod mass_index; mod mcginley_dynamic; mod median_price; mod mfi; mod mom; mod natr; +mod nvi; mod obv; mod parkinson; mod percent_b; @@ -73,6 +79,7 @@ mod pgo; mod pmo; mod ppo; mod psar; +mod pvi; mod roc; mod rogers_satchell; mod rsi; @@ -96,17 +103,20 @@ mod trima; mod trix; mod true_range; mod tsi; +mod tsv; mod ttm_squeeze; mod typical_price; mod ulcer_index; mod ultimate_oscillator; mod vertical_horizontal_filter; mod vidya; +mod volume_oscillator; mod vortex; mod vpt; mod vwap; mod vwap_stddev_bands; mod vwma; +mod vzo; mod wave_trend; mod weighted_close; mod williams_r; @@ -118,11 +128,13 @@ mod zlema; pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput}; pub use accelerator_oscillator::AcceleratorOscillator; +pub use ad_oscillator::AdOscillator; pub use adl::Adl; pub use adx::{Adx, AdxOutput}; pub use adxr::Adxr; pub use alligator::{Alligator, AlligatorOutput}; pub use alma::Alma; +pub use anchored_vwap::AnchoredVwap; pub use apo::Apo; pub use aroon::{Aroon, AroonOutput}; pub use aroon_oscillator::AroonOscillator; @@ -146,6 +158,7 @@ pub use cmo::Cmo; pub use connors_rsi::ConnorsRsi; pub use coppock::Coppock; pub use dema::Dema; +pub use demand_index::DemandIndex; pub use donchian::{Donchian, DonchianOutput}; pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput}; pub use dpo::Dpo; @@ -165,6 +178,7 @@ pub use jma::Jma; pub use kama::Kama; pub use keltner::{Keltner, KeltnerOutput}; pub use kst::{Kst, KstOutput}; +pub use kvo::Kvo; pub use laguerre_rsi::LaguerreRsi; pub use linreg::LinearRegression; pub use linreg_angle::LinRegAngle; @@ -172,12 +186,14 @@ pub use linreg_channel::{LinRegChannel, LinRegChannelOutput}; pub use linreg_slope::LinRegSlope; pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput}; pub use macd::{MacdIndicator, MacdOutput}; +pub use market_facilitation_index::MarketFacilitationIndex; pub use mass_index::MassIndex; pub use mcginley_dynamic::McGinleyDynamic; pub use median_price::MedianPrice; pub use mfi::Mfi; pub use mom::Mom; pub use natr::Natr; +pub use nvi::Nvi; pub use obv::Obv; pub use parkinson::ParkinsonVolatility; pub use percent_b::PercentB; @@ -185,6 +201,7 @@ pub use pgo::Pgo; pub use pmo::Pmo; pub use ppo::Ppo; pub use psar::Psar; +pub use pvi::Pvi; pub use roc::Roc; pub use rogers_satchell::RogersSatchellVolatility; pub use rsi::Rsi; @@ -208,17 +225,20 @@ pub use trima::Trima; pub use trix::Trix; pub use true_range::TrueRange; pub use tsi::Tsi; +pub use tsv::Tsv; pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput}; pub use typical_price::TypicalPrice; pub use ulcer_index::UlcerIndex; pub use ultimate_oscillator::UltimateOscillator; pub use vertical_horizontal_filter::VerticalHorizontalFilter; pub use vidya::Vidya; +pub use volume_oscillator::VolumeOscillator; pub use vortex::{Vortex, VortexOutput}; pub use vpt::VolumePriceTrend; pub use vwap::{RollingVwap, Vwap}; pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput}; pub use vwma::Vwma; +pub use vzo::Vzo; pub use wave_trend::{WaveTrend, WaveTrendOutput}; pub use weighted_close::WeightedClose; pub use williams_r::WilliamsR; diff --git a/crates/wickra-core/src/indicators/nvi.rs b/crates/wickra-core/src/indicators/nvi.rs new file mode 100644 index 00000000..29a7c8f3 --- /dev/null +++ b/crates/wickra-core/src/indicators/nvi.rs @@ -0,0 +1,240 @@ +//! Negative Volume Index. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Default starting value for both NVI and PVI; matches Norman Fosback's +/// textbook convention. +const STARTING_INDEX: f64 = 1000.0; + +/// Negative Volume Index (Paul Dysart, popularised by Norman Fosback). +/// +/// A cumulative index that only updates when **volume contracts** — the +/// hypothesis is that smart-money accumulation happens on quiet days, so the +/// NVI tracks the "smart money" leg of price action while ignoring the +/// volume-spike days that retail tends to chase. When today's volume is at or +/// above yesterday's, the NVI is left unchanged. +/// +/// ```text +/// NVI_t = NVI_{t−1} · (1 + (close_t − close_{t−1}) / close_{t−1}) if volume_t < volume_{t−1} +/// NVI_t = NVI_{t−1} otherwise +/// ``` +/// +/// The first bar establishes the baseline at `1000.0` (Fosback's convention). +/// A bar whose previous close is zero contributes no return (avoids dividing +/// by zero). Output is `Some` from the very first bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Nvi}; +/// +/// let mut indicator = Nvi::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Nvi { + prev_close: Option, + prev_volume: Option, + index: f64, + has_emitted: bool, +} + +impl Nvi { + /// Construct a new NVI starting at `1000.0`. + pub const fn new() -> Self { + Self { + prev_close: None, + prev_volume: None, + index: STARTING_INDEX, + has_emitted: false, + } + } + + /// Construct a new NVI with a custom starting baseline. + pub const fn with_baseline(baseline: f64) -> Self { + Self { + prev_close: None, + prev_volume: None, + index: baseline, + has_emitted: false, + } + } + + /// Current cumulative value if at least one candle has been ingested. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.index) + } else { + None + } + } +} + +impl Default for Nvi { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Nvi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // First bar establishes the baseline at `index`; the `if let` handles + // every later bar, which has both predecessors recorded by construction. + if let (Some(pc), Some(pv)) = (self.prev_close, self.prev_volume) { + if candle.volume < pv && pc != 0.0 { + let ret = (candle.close - pc) / pc; + self.index += self.index * ret; + } + } + self.prev_close = Some(candle.close); + self.prev_volume = Some(candle.volume); + self.has_emitted = true; + Some(self.index) + } + + fn reset(&mut self) { + self.prev_close = None; + self.prev_volume = None; + self.index = STARTING_INDEX; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "NVI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let mut n = Nvi::new(); + assert_eq!(n.warmup_period(), 1); + assert_eq!(n.name(), "NVI"); + assert_eq!(n.value(), None); + n.update(c(10.0, 100.0, 0)); + assert_eq!(n.value(), Some(1000.0)); + } + + #[test] + fn default_matches_new() { + let a = Nvi::default(); + let b = Nvi::new(); + assert_eq!(a.warmup_period(), b.warmup_period()); + assert_eq!(a.value(), b.value()); + assert_eq!(a.is_ready(), b.is_ready()); + } + + #[test] + fn first_bar_seeds_baseline() { + let mut n = Nvi::new(); + assert_relative_eq!( + n.update(c(10.0, 100.0, 0)).unwrap(), + 1000.0, + epsilon = 1e-12 + ); + } + + #[test] + fn volume_rise_leaves_index_unchanged() { + // Bar 2 has higher volume than bar 1, so NVI does not update even though + // the close changed. + let mut n = Nvi::new(); + n.update(c(10.0, 100.0, 0)); + let v = n.update(c(11.0, 200.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn volume_fall_applies_percent_change() { + // Bar 2 has lower volume; NVI absorbs the percent close change. + // 1000 * (1 + (11 - 10)/10) = 1100. + let mut n = Nvi::new(); + n.update(c(10.0, 200.0, 0)); + let v = n.update(c(11.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 1100.0, epsilon = 1e-12); + } + + #[test] + fn equal_volume_leaves_index_unchanged() { + // The textbook rule says "strictly less"; equal volume is skipped. + let mut n = Nvi::new(); + n.update(c(10.0, 100.0, 0)); + let v = n.update(c(11.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn zero_previous_close_contributes_no_return() { + // The previous close is exactly zero — guarded against div-by-zero. + let mut n = Nvi::new(); + n.update(c(0.0, 200.0, 0)); + let v = n.update(c(5.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn custom_baseline() { + let mut n = Nvi::with_baseline(100.0); + assert_relative_eq!(n.update(c(10.0, 100.0, 0)).unwrap(), 100.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80i64) + .map(|i| { + let f = i as f64; + c( + 100.0 + (f * 0.3).sin() * 5.0, + 50.0 + ((i % 7) as f64) * 10.0, + i, + ) + }) + .collect(); + let mut a = Nvi::new(); + let mut b = Nvi::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut n = Nvi::new(); + n.batch(&[c(10.0, 200.0, 0), c(11.0, 100.0, 1)]); + assert!(n.is_ready()); + n.reset(); + assert!(!n.is_ready()); + assert_eq!(n.value(), None); + // After reset, first bar re-seeds at the default baseline. + assert_relative_eq!(n.update(c(50.0, 1.0, 2)).unwrap(), 1000.0, epsilon = 1e-12); + } +} diff --git a/crates/wickra-core/src/indicators/pvi.rs b/crates/wickra-core/src/indicators/pvi.rs new file mode 100644 index 00000000..2bf19346 --- /dev/null +++ b/crates/wickra-core/src/indicators/pvi.rs @@ -0,0 +1,228 @@ +//! Positive Volume Index. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Default starting value; matches Norman Fosback's textbook convention. +const STARTING_INDEX: f64 = 1000.0; + +/// Positive Volume Index (Paul Dysart, popularised by Norman Fosback). +/// +/// The PVI only updates when **volume expands** — Fosback's interpretation is +/// that the crowd ("uninformed money") trades on volume spikes, so the PVI +/// tracks the crowd-driven leg of price action. When today's volume is at or +/// below yesterday's, the PVI is left unchanged. +/// +/// ```text +/// PVI_t = PVI_{t−1} · (1 + (close_t − close_{t−1}) / close_{t−1}) if volume_t > volume_{t−1} +/// PVI_t = PVI_{t−1} otherwise +/// ``` +/// +/// The first bar establishes the baseline at `1000.0`. A bar whose previous +/// close is zero contributes no return. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Pvi}; +/// +/// let mut indicator = Pvi::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Pvi { + prev_close: Option, + prev_volume: Option, + index: f64, + has_emitted: bool, +} + +impl Pvi { + /// Construct a new PVI starting at `1000.0`. + pub const fn new() -> Self { + Self { + prev_close: None, + prev_volume: None, + index: STARTING_INDEX, + has_emitted: false, + } + } + + /// Construct a new PVI with a custom starting baseline. + pub const fn with_baseline(baseline: f64) -> Self { + Self { + prev_close: None, + prev_volume: None, + index: baseline, + has_emitted: false, + } + } + + /// Current cumulative value if at least one candle has been ingested. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.index) + } else { + None + } + } +} + +impl Default for Pvi { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Pvi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + if let (Some(pc), Some(pv)) = (self.prev_close, self.prev_volume) { + if candle.volume > pv && pc != 0.0 { + let ret = (candle.close - pc) / pc; + self.index += self.index * ret; + } + } + self.prev_close = Some(candle.close); + self.prev_volume = Some(candle.volume); + self.has_emitted = true; + Some(self.index) + } + + fn reset(&mut self) { + self.prev_close = None; + self.prev_volume = None; + self.index = STARTING_INDEX; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "PVI" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let mut p = Pvi::new(); + assert_eq!(p.warmup_period(), 1); + assert_eq!(p.name(), "PVI"); + assert_eq!(p.value(), None); + p.update(c(10.0, 100.0, 0)); + assert_eq!(p.value(), Some(1000.0)); + } + + #[test] + fn default_matches_new() { + let a = Pvi::default(); + let b = Pvi::new(); + assert_eq!(a.warmup_period(), b.warmup_period()); + assert_eq!(a.value(), b.value()); + assert_eq!(a.is_ready(), b.is_ready()); + } + + #[test] + fn first_bar_seeds_baseline() { + let mut p = Pvi::new(); + assert_relative_eq!( + p.update(c(10.0, 100.0, 0)).unwrap(), + 1000.0, + epsilon = 1e-12 + ); + } + + #[test] + fn volume_rise_applies_percent_change() { + // 1000 * (1 + (11 - 10)/10) = 1100. + let mut p = Pvi::new(); + p.update(c(10.0, 100.0, 0)); + let v = p.update(c(11.0, 200.0, 1)).unwrap(); + assert_relative_eq!(v, 1100.0, epsilon = 1e-12); + } + + #[test] + fn volume_fall_leaves_index_unchanged() { + let mut p = Pvi::new(); + p.update(c(10.0, 200.0, 0)); + let v = p.update(c(11.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn equal_volume_leaves_index_unchanged() { + let mut p = Pvi::new(); + p.update(c(10.0, 100.0, 0)); + let v = p.update(c(11.0, 100.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn zero_previous_close_contributes_no_return() { + let mut p = Pvi::new(); + p.update(c(0.0, 100.0, 0)); + let v = p.update(c(5.0, 200.0, 1)).unwrap(); + assert_relative_eq!(v, 1000.0, epsilon = 1e-12); + } + + #[test] + fn custom_baseline() { + let mut p = Pvi::with_baseline(100.0); + assert_relative_eq!(p.update(c(10.0, 100.0, 0)).unwrap(), 100.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80i64) + .map(|i| { + let f = i as f64; + c( + 100.0 + (f * 0.3).sin() * 5.0, + 50.0 + ((i % 7) as f64) * 10.0, + i, + ) + }) + .collect(); + let mut a = Pvi::new(); + let mut b = Pvi::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut p = Pvi::new(); + p.batch(&[c(10.0, 100.0, 0), c(11.0, 200.0, 1)]); + assert!(p.is_ready()); + p.reset(); + assert!(!p.is_ready()); + assert_eq!(p.value(), None); + } +} diff --git a/crates/wickra-core/src/indicators/tsv.rs b/crates/wickra-core/src/indicators/tsv.rs new file mode 100644 index 00000000..28c16c1b --- /dev/null +++ b/crates/wickra-core/src/indicators/tsv.rs @@ -0,0 +1,205 @@ +//! Time Segmented Volume (Worden). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Time Segmented Volume (Don Worden) — a rolling sum of *signed* volume +/// weighted by the bar's close-to-close move. +/// +/// Each bar's contribution is the close change times the bar volume. Summed +/// over a fixed window, the result quantifies the net accumulation (positive) +/// or distribution (negative) over that span: +/// +/// ```text +/// flow_t = (close_t − close_{t−1}) · volume_t (signed money flow) +/// TSV_t = Σ_{i = t−period+1}^{t} flow_i (rolling window sum) +/// ``` +/// +/// The first candle only seeds `close_{t−1}`; the first flow lands at bar 2, +/// and the first TSV emission lands once the window has accumulated `period` +/// flows — i.e. at bar `period + 1`. Worden's original TC2000 implementation +/// often charts an additional EMA smoothing of TSV as a signal line; that is +/// left to the caller via [`crate::Ema`] composition. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Tsv}; +/// +/// let mut indicator = Tsv::new(18).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Tsv { + period: usize, + prev_close: Option, + window: VecDeque, + sum: f64, +} + +impl Tsv { + /// Construct a new TSV with the given rolling window length. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev_close: None, + window: VecDeque::with_capacity(period), + sum: 0.0, + }) + } + + /// Configured window length. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Tsv { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + let flow = (candle.close - prev) * candle.volume; + self.prev_close = Some(candle.close); + + if self.window.len() == self.period { + self.sum -= self.window.pop_front().expect("non-empty"); + } + self.window.push_back(flow); + self.sum += flow; + if self.window.len() < self.period { + return None; + } + Some(self.sum) + } + + fn reset(&mut self) { + self.prev_close = None; + self.window.clear(); + self.sum = 0.0; + } + + fn warmup_period(&self) -> usize { + // One seed bar for `prev_close`, then `period` flows to fill the window. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "TSV" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Tsv::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let t = Tsv::new(18).unwrap(); + assert_eq!(t.period(), 18); + assert_eq!(t.name(), "TSV"); + assert_eq!(t.warmup_period(), 19); + } + + #[test] + fn constant_close_yields_zero() { + // Flat close -> every flow is zero -> rolling sum stays at zero. + let candles: Vec = (0..30).map(|i| c(10.0, 100.0, i)).collect(); + let mut t = Tsv::new(5).unwrap(); + for v in t.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reference_window_sum() { + // closes = [10, 11, 13, 12, 14, 15] + // volumes = [.., 100, 200, 150, 50, 200] + // flows = [None, (1)*100=100, (2)*200=400, (-1)*150=-150, (2)*50=100, (1)*200=200] + // period = 3: first emission at bar index 3 (the 4th flow, since one bar seeds). + // Wait: bar 0 seeds, bars 1..5 produce 5 flows. Window of 3 fills at the + // 3rd flow, i.e. bar index 3. + // bar 3 -> window = [100, 400, -150] -> sum = 350. + // bar 4 -> window = [400, -150, 100] -> sum = 350. + // bar 5 -> window = [-150, 100, 200] -> sum = 150. + let mut t = Tsv::new(3).unwrap(); + let out = t.batch(&[ + c(10.0, 50.0, 0), + c(11.0, 100.0, 1), + c(13.0, 200.0, 2), + c(12.0, 150.0, 3), + c(14.0, 50.0, 4), + c(15.0, 200.0, 5), + ]); + assert!(out[0].is_none() && out[1].is_none() && out[2].is_none()); + assert_relative_eq!(out[3].unwrap(), 350.0, epsilon = 1e-9); + assert_relative_eq!(out[4].unwrap(), 350.0, epsilon = 1e-9); + assert_relative_eq!(out[5].unwrap(), 150.0, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80i64) + .map(|i| { + let f = i as f64; + c( + 100.0 + (f * 0.3).sin() * 5.0, + 50.0 + (i % 7) as f64 * 10.0, + i, + ) + }) + .collect(); + let mut a = Tsv::new(18).unwrap(); + let mut b = Tsv::new(18).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40).map(|i| c(10.0 + i as f64, 100.0, i)).collect(); + let mut t = Tsv::new(10).unwrap(); + t.batch(&candles); + assert!(t.is_ready()); + t.reset(); + assert!(!t.is_ready()); + assert_eq!(t.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/volume_oscillator.rs b/crates/wickra-core/src/indicators/volume_oscillator.rs new file mode 100644 index 00000000..a3eda3e6 --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_oscillator.rs @@ -0,0 +1,206 @@ +//! Volume Oscillator. + +use crate::error::{Error, Result}; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume Oscillator — the percent difference between a fast and a slow SMA +/// of the bar volume. +/// +/// ```text +/// VO_t = 100 · (SMA(volume, fast)_t − SMA(volume, slow)_t) / SMA(volume, slow)_t +/// ``` +/// +/// A positive reading means short-term volume is running above the longer-term +/// average (rising participation), a negative reading the opposite. The line is +/// unbounded above and below `-100`, but stays near zero in stable conditions. +/// Classic configuration is `fast = 14, slow = 28`. The first emission lands +/// after `slow` candles. A slow average of `0` (only possible if every volume +/// in the slow window was zero) collapses the output to `0` rather than NaN. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumeOscillator}; +/// +/// let mut indicator = VolumeOscillator::new(14, 28).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VolumeOscillator { + fast_period: usize, + slow_period: usize, + fast: Sma, + slow: Sma, +} + +impl VolumeOscillator { + /// Construct a Volume Oscillator with the given SMA periods. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if either period is zero, or + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "VolumeOscillator needs fast < slow", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + fast: Sma::new(fast)?, + slow: Sma::new(slow)?, + }) + } + + /// Configured `(fast, slow)` periods. + pub const fn periods(&self) -> (usize, usize) { + (self.fast_period, self.slow_period) + } +} + +impl Indicator for VolumeOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let f = self.fast.update(candle.volume); + let s = self.slow.update(candle.volume); + let (fast_v, slow_v) = (f?, s?); + if slow_v == 0.0 { + // Whole slow window is zero-volume — the ratio is undefined; report 0. + return Some(0.0); + } + Some(100.0 * (fast_v - slow_v) / slow_v) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + } + + fn warmup_period(&self) -> usize { + self.slow_period + } + + fn is_ready(&self) -> bool { + self.slow.is_ready() + } + + fn name(&self) -> &'static str { + "VolumeOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(volume: f64, ts: i64) -> Candle { + Candle::new(10.0, 10.0, 10.0, 10.0, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + VolumeOscillator::new(0, 5), + Err(Error::PeriodZero) + )); + assert!(matches!( + VolumeOscillator::new(5, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!( + VolumeOscillator::new(10, 10), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + VolumeOscillator::new(28, 14), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let vo = VolumeOscillator::new(14, 28).unwrap(); + assert_eq!(vo.periods(), (14, 28)); + assert_eq!(vo.name(), "VolumeOscillator"); + assert_eq!(vo.warmup_period(), 28); + } + + #[test] + fn constant_volume_yields_zero() { + // Both SMAs equal the constant volume, so (fast - slow) / slow = 0. + let mut vo = VolumeOscillator::new(3, 6).unwrap(); + let candles: Vec = (0..30i64).map(|i| c(500.0, i)).collect(); + for v in vo.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn zero_volume_window_yields_zero() { + // All bars carry zero volume — slow SMA is 0, defensive branch returns 0. + let mut vo = VolumeOscillator::new(2, 4).unwrap(); + let candles: Vec = (0..10i64).map(|i| c(0.0, i)).collect(); + let out = vo.batch(&candles); + assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn reference_value() { + // fast=2, slow=4 over volumes [10, 20, 30, 40, 50]: + // bar 4 (index 3): fast=(40+30)/2=35, slow=(10+20+30+40)/4=25, + // VO = 100·(35-25)/25 = 40. + let mut vo = VolumeOscillator::new(2, 4).unwrap(); + let candles = [c(10.0, 0), c(20.0, 1), c(30.0, 2), c(40.0, 3), c(50.0, 4)]; + let out = vo.batch(&candles); + assert!(out[0].is_none() && out[1].is_none() && out[2].is_none()); + assert_relative_eq!(out[3].unwrap(), 40.0, epsilon = 1e-9); + // bar 5 (index 4): fast=(50+40)/2=45, slow=(20+30+40+50)/4=35, + // VO = 100·(45-35)/35 = 1000/35. + assert_relative_eq!(out[4].unwrap(), 1000.0 / 35.0, epsilon = 1e-9); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80i64) + .map(|i| c(100.0 + ((i % 11) as f64) * 5.0, i)) + .collect(); + let mut a = VolumeOscillator::new(14, 28).unwrap(); + let mut b = VolumeOscillator::new(14, 28).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..60i64).map(|i| c(100.0 + (i as f64), i)).collect(); + let mut vo = VolumeOscillator::new(14, 28).unwrap(); + vo.batch(&candles); + assert!(vo.is_ready()); + vo.reset(); + assert!(!vo.is_ready()); + assert_eq!(vo.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/vzo.rs b/crates/wickra-core/src/indicators/vzo.rs new file mode 100644 index 00000000..62f7c688 --- /dev/null +++ b/crates/wickra-core/src/indicators/vzo.rs @@ -0,0 +1,218 @@ +//! Volume Zone Oscillator (Walid Khalil). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Walid Khalil's Volume Zone Oscillator — a normalised version of OBV-style +/// volume flow that swings within `[−100, 100]`. +/// +/// Each bar contributes a *signed volume*: `+volume` on an up day, `−volume` on +/// a down day, `0` on an unchanged close. The VZO is the ratio of an EMA of +/// that signed volume to an EMA of the absolute volume, scaled by `100`: +/// +/// ```text +/// R_t = sign(close_t − close_{t−1}) · volume_t +/// VP_t = EMA(R, period)_t (smoothed signed volume) +/// TV_t = EMA(volume, period)_t (smoothed absolute volume) +/// VZO_t = 100 · VP_t / TV_t +/// ``` +/// +/// Khalil's interpretation: `VZO > +60` overbought, `< −60` oversold, with the +/// zero line acting as a trend filter. The first bar only seeds the previous +/// close; both EMAs then need `period` samples to seed, so the first emission +/// lands at bar `period + 1`. A `TV_t == 0` (every bar had zero volume) +/// collapses the output to `0` instead of NaN. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Vzo}; +/// +/// let mut indicator = Vzo::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 50.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Vzo { + period: usize, + vp: Ema, + tv: Ema, + prev_close: Option, +} + +impl Vzo { + /// Construct a new VZO with the given EMA smoothing period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + vp: Ema::new(period)?, + tv: Ema::new(period)?, + prev_close: None, + }) + } + + /// Configured EMA smoothing period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for Vzo { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let signed_volume = match self.prev_close { + None => { + self.prev_close = Some(candle.close); + return None; + } + Some(prev) => { + if candle.close > prev { + candle.volume + } else if candle.close < prev { + -candle.volume + } else { + 0.0 + } + } + }; + self.prev_close = Some(candle.close); + let vp = self.vp.update(signed_volume); + let tv = self.tv.update(candle.volume); + let (vp_v, tv_v) = (vp?, tv?); + if tv_v == 0.0 { + // No volume in the smoothing window -> ratio undefined; report 0. + return Some(0.0); + } + Some(100.0 * vp_v / tv_v) + } + + fn reset(&mut self) { + self.vp.reset(); + self.tv.reset(); + self.prev_close = None; + } + + fn warmup_period(&self) -> usize { + // One seed bar plus the EMA seed. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.vp.is_ready() && self.tv.is_ready() + } + + fn name(&self) -> &'static str { + "VZO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(Vzo::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let v = Vzo::new(14).unwrap(); + assert_eq!(v.period(), 14); + assert_eq!(v.name(), "VZO"); + assert_eq!(v.warmup_period(), 15); + } + + #[test] + fn strictly_rising_series_saturates_to_plus_100() { + // Every bar is an up-day with identical volume -> signed_volume == volume + // on every bar -> VP and TV EMAs are equal -> ratio = 1 -> VZO = +100. + let candles: Vec = (0..60i64).map(|i| c(10.0 + i as f64, 100.0, i)).collect(); + let mut v = Vzo::new(5).unwrap(); + let out = v.batch(&candles); + let last = out.iter().filter_map(|x| *x).next_back().unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn strictly_falling_series_saturates_to_minus_100() { + let candles: Vec = (0..60i64).map(|i| c(200.0 - i as f64, 100.0, i)).collect(); + let mut v = Vzo::new(5).unwrap(); + let out = v.batch(&candles); + let last = out.iter().filter_map(|x| *x).next_back().unwrap(); + assert_relative_eq!(last, -100.0, epsilon = 1e-9); + } + + #[test] + fn flat_close_yields_zero() { + // signed_volume = 0 forever -> VP_EMA stays at 0 -> ratio = 0. + let candles: Vec = (0..40).map(|i| c(10.0, 100.0, i)).collect(); + let mut v = Vzo::new(5).unwrap(); + for x in v.batch(&candles).into_iter().flatten() { + assert_relative_eq!(x, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn zero_volume_window_yields_zero() { + // All bars carry zero volume -> tv_v == 0 -> defensive branch fires. + let candles: Vec = (0..20i64).map(|i| c(10.0 + i as f64, 0.0, i)).collect(); + let mut v = Vzo::new(3).unwrap(); + let out = v.batch(&candles); + let last = out.iter().filter_map(|x| *x).next_back().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..100i64) + .map(|i| { + let f = i as f64; + c( + 100.0 + (f * 0.3).sin() * 5.0, + 50.0 + (i % 7) as f64 * 10.0, + i, + ) + }) + .collect(); + let mut a = Vzo::new(14).unwrap(); + let mut b = Vzo::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40i64).map(|i| c(10.0 + i as f64, 100.0, i)).collect(); + let mut v = Vzo::new(5).unwrap(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 0570feb0..ed97ba3d 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -44,26 +44,28 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ - AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, Adl, Adx, AdxOutput, Adxr, - Alligator, AlligatorOutput, Alma, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands, - AtrBandsOutput, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, - BollingerBands, BollingerBandwidth, BollingerOutput, Cci, Cfo, ChaikinMoneyFlow, - ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, - ChandelierExitOutput, ChoppinessIndex, Cmo, ConnorsRsi, Coppock, Dema, Donchian, - DonchianOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement, ElderImpulse, Ema, - Evwma, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, GarmanKlassVolatility, - HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, Inertia, Jma, Kama, Keltner, - KeltnerOutput, Kst, KstOutput, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, - LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, - MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Obv, ParkinsonVolatility, PercentB, - Pgo, Pmo, Ppo, Psar, Roc, RogersSatchellVolatility, RollingVwap, Rsi, Rvi, RviVolatility, Rwi, - RwiOutput, Sma, Smi, Smma, StandardErrorBands, StandardErrorBandsOutput, StarcBands, - StarcBandsOutput, Stc, StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, - SuperTrendOutput, Tema, Tii, Trima, Trix, TrueRange, Tsi, TtmSqueeze, TtmSqueezeOutput, - TypicalPrice, UlcerIndex, UltimateOscillator, VerticalHorizontalFilter, Vidya, - VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, - WaveTrend, WaveTrendOutput, WeightedClose, WilliamsR, Wma, YangZhangVolatility, ZScore, - ZeroLagMacd, ZeroLagMacdOutput, Zlema, T3, + AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator, Adl, Adx, + AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, AnchoredVwap, Apo, Aroon, AroonOscillator, + AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AwesomeOscillator, + AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands, BollingerBandwidth, + BollingerOutput, Cci, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, + ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, + Cmo, ConnorsRsi, Coppock, Dema, DemandIndex, Donchian, DonchianOutput, DoubleBollinger, + DoubleBollingerOutput, Dpo, EaseOfMovement, ElderImpulse, Ema, Evwma, ForceIndex, + FractalChaosBands, FractalChaosBandsOutput, Frama, GarmanKlassVolatility, HistoricalVolatility, + Hma, HurstChannel, HurstChannelOutput, Inertia, Jma, Kama, Keltner, KeltnerOutput, Kst, + KstOutput, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, + LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, + MarketFacilitationIndex, MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Nvi, Obv, + ParkinsonVolatility, PercentB, Pgo, Pmo, Ppo, Psar, Pvi, Roc, RogersSatchellVolatility, + RollingVwap, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, Sma, Smi, Smma, StandardErrorBands, + StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StochRsi, Stochastic, + StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Tii, Trima, Trix, TrueRange, Tsi, Tsv, + TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, UltimateOscillator, + VerticalHorizontalFilter, Vidya, VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, + Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, + WeightedClose, WilliamsR, Wma, YangZhangVolatility, ZScore, ZeroLagMacd, ZeroLagMacdOutput, + Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 23fb2010..4a504950 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -19,12 +19,13 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use std::hint::black_box; use wickra::{ - AccelerationBands, Adxr, Alma, Atr, AtrBands, BatchExt, BollingerBands, Candle, - DoubleBollinger, Ema, FractalChaosBands, Frama, GarmanKlassVolatility, HurstChannel, Indicator, - Jma, Kst, LinRegChannel, MaEnvelope, MacdIndicator, McGinleyDynamic, Obv, ParkinsonVolatility, - Pgo, RogersSatchellVolatility, Rsi, Rvi, RviVolatility, Rwi, Sma, StandardErrorBands, - StarcBands, Stochastic, Tii, TtmSqueeze, Vidya, VwapStdDevBands, WaveTrend, Wma, - YangZhangVolatility, + AccelerationBands, AdOscillator, Adxr, Alma, AnchoredVwap, Atr, AtrBands, BatchExt, + BollingerBands, Candle, DemandIndex, DoubleBollinger, Ema, FractalChaosBands, Frama, + GarmanKlassVolatility, HurstChannel, Indicator, Jma, Kst, Kvo, LinRegChannel, MaEnvelope, + MacdIndicator, MarketFacilitationIndex, McGinleyDynamic, Nvi, Obv, ParkinsonVolatility, Pgo, + Pvi, RogersSatchellVolatility, Rsi, Rvi, RviVolatility, Rwi, Sma, StandardErrorBands, + StarcBands, Stochastic, Tii, Tsv, TtmSqueeze, Vidya, VolumeOscillator, VwapStdDevBands, Vzo, + WaveTrend, Wma, YangZhangVolatility, }; use wickra_data::csv::CandleReader; @@ -179,6 +180,27 @@ fn benches(c: &mut Criterion) { bench_candle_input(c, "stochastic", &candles, Stochastic::classic); bench_candle_input(c, "obv", &candles, Obv::new); + // --- Family 07: Volume --- + bench_candle_input(c, "kvo", &candles, Kvo::classic); + bench_candle_input(c, "volume_oscillator", &candles, || { + VolumeOscillator::new(14, 28).unwrap() + }); + bench_candle_input(c, "nvi", &candles, Nvi::new); + bench_candle_input(c, "pvi", &candles, Pvi::new); + bench_candle_input(c, "williams_ad", &candles, AdOscillator::new); + bench_candle_input(c, "anchored_vwap", &candles, AnchoredVwap::new); + bench_candle_input(c, "demand_index", &candles, || { + DemandIndex::new(10).unwrap() + }); + bench_candle_input(c, "tsv", &candles, || Tsv::new(18).unwrap()); + bench_candle_input(c, "vzo", &candles, || Vzo::new(14).unwrap()); + bench_candle_input( + c, + "market_facilitation_index", + &candles, + MarketFacilitationIndex::new, + ); + // --- Family 04: Volatility --- bench_scalar(c, "rvi_volatility", &closes, || { RviVolatility::new(10).unwrap() diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index 85806b2f..493fbe3a 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -23,14 +23,16 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - AccelerationBands, AcceleratorOscillator, Adl, Adx, Adxr, Alligator, Aroon, AroonOscillator, - Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, - BatchExt, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, - ChandelierExit, ChoppinessIndex, Donchian, EaseOfMovement, Evwma, ForceIndex, FractalChaosBands, - GarmanKlassVolatility, HurstChannel, Indicator, Inertia, Keltner, MassIndex, MedianPrice, Mfi, - Natr, Obv, ParkinsonVolatility, Pgo, Psar, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, Smi, - StarcBands, Stochastic, SuperTrend, TrueRange, TtmSqueeze, TypicalPrice, UltimateOscillator, - VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, WaveTrend, WeightedClose, WilliamsR, + AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, Adx, Adxr, Alligator, + AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, + AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Candle, Cci, ChaikinMoneyFlow, + ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, + DemandIndex, Donchian, EaseOfMovement, Evwma, ForceIndex, FractalChaosBands, + GarmanKlassVolatility, HurstChannel, Indicator, Inertia, Keltner, Kvo, MarketFacilitationIndex, + MassIndex, MedianPrice, Mfi, Natr, Nvi, Obv, ParkinsonVolatility, Pgo, Psar, Pvi, + RogersSatchellVolatility, RollingVwap, Rvi, Rwi, Smi, StarcBands, Stochastic, SuperTrend, + TrueRange, Tsv, TtmSqueeze, TypicalPrice, UltimateOscillator, VolumeOscillator, + VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsR, YangZhangVolatility, }; @@ -133,6 +135,16 @@ fuzz_target!(|data: Vec| { drive(|| ChaikinOscillator::new(3, 10).unwrap(), &candles); drive(|| ForceIndex::new(13).unwrap(), &candles); drive(|| EaseOfMovement::with_divisor(14, 1e8).unwrap(), &candles); + drive(|| Kvo::new(34, 55).unwrap(), &candles); + drive(|| VolumeOscillator::new(14, 28).unwrap(), &candles); + drive(Nvi::new, &candles); + drive(Pvi::new, &candles); + drive(AdOscillator::new, &candles); + drive(AnchoredVwap::new, &candles); + drive(|| DemandIndex::new(10).unwrap(), &candles); + drive(|| Tsv::new(18).unwrap(), &candles); + drive(|| Vzo::new(14).unwrap(), &candles); + drive(MarketFacilitationIndex::new, &candles); // --- Price transformations --- drive(TypicalPrice::new, &candles);