diff --git a/CHANGELOG.md b/CHANGELOG.md index 6295a9dd..681a45a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `RollingVWAP` is now exposed in Python, Node and WASM under that name + (previously the rolling-window VWAP existed only in the Rust core, even + though the README's volume-family table already advertised + `VWAP (cumulative + rolling)`). All four bindings now ship the same + cumulative `VWAP` plus the finite-window `RollingVWAP(period)`. The wiki page + `Indicator-Vwap.md` adds Python, Node and WASM examples and drops the + "Rust-only" caveat. - WASM binding now exposes the streaming `update()` method on every candle-input indicator: `Adx`, `WilliamsR`, `Cci`, `Mfi`, `Psar`, `Keltner`, `Donchian`, `Vwap`, `AwesomeOscillator`, `Aroon`, `Stochastic`, and `Obv`. Multi-output diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index d86ed8da..f9c7aff8 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -82,6 +82,7 @@ const candleScalar = { PSAR: { make: () => new wickra.PSAR(0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, MFI: { make: () => new wickra.MFI(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, VWAP: { make: () => new wickra.VWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, + RollingVWAP: { make: () => new wickra.RollingVWAP(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, AwesomeOscillator: { make: () => new wickra.AwesomeOscillator(5, 34), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, OBV: { make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, VWMA: { make: () => new wickra.VWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, diff --git a/bindings/node/index.js b/bindings/node/index.js index a8048aed..7e75b0f6 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -345,6 +345,7 @@ module.exports.PSAR = PSAR module.exports.Keltner = Keltner module.exports.Donchian = Donchian module.exports.VWAP = VWAP +module.exports.RollingVWAP = RollingVWAP module.exports.AwesomeOscillator = AwesomeOscillator module.exports.Aroon = Aroon module.exports.KAMA = KAMA diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index c4987474..27a0dc07 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -899,6 +899,69 @@ impl VwapNode { } } +#[napi(js_name = "RollingVWAP")] +pub struct RollingVwapNode { + inner: wc::RollingVwap, +} +#[napi] +impl RollingVwapNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::RollingVwap::new(period as usize).map_err(map_err)?, + }) + } + #[napi(getter)] + pub fn period(&self) -> u32 { + self.inner.period() as u32 + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } + #[napi] + 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(js_name = "AwesomeOscillator")] pub struct AoNode { inner: wc::AwesomeOscillator, diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 7407f6d3..6a4bd208 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -87,6 +87,7 @@ from ._wickra import ( # Volume OBV, VWAP, + RollingVWAP, ADL, VolumePriceTrend, ChaikinMoneyFlow, @@ -167,6 +168,7 @@ __all__ = [ # Volume "OBV", "VWAP", + "RollingVWAP", "ADL", "VolumePriceTrend", "ChaikinMoneyFlow", diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index e260c4fe..e9d1cf98 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -945,6 +945,22 @@ class VWAP: def is_ready(self) -> bool: ... def warmup_period(self) -> int: ... +class RollingVWAP: + def __init__(self, period: int) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + volume: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + @property + def period(self) -> int: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + class AwesomeOscillator: def __init__(self, fast: int = 5, slow: int = 34) -> None: ... def update(self, candle: CandleLike) -> Optional[float]: ... diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 6eb435fa..da432982 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1413,6 +1413,76 @@ impl PyVwap { } } +// ============================== Rolling VWAP ============================== + +#[pyclass(name = "RollingVWAP", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRollingVwap { + inner: wc::RollingVwap, +} + +#[pymethods] +impl PyRollingVwap { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::RollingVwap::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + 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!("RollingVWAP(period={})", self.inner.period()) + } +} + // ============================== Awesome Oscillator ============================== #[pyclass( @@ -4433,6 +4503,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/bindings/python/tests/test_streaming_vs_batch.py b/bindings/python/tests/test_streaming_vs_batch.py index fb9062b8..dbee1184 100644 --- a/bindings/python/tests/test_streaming_vs_batch.py +++ b/bindings/python/tests/test_streaming_vs_batch.py @@ -115,3 +115,23 @@ def test_obv_streaming_matches_batch(ohlc_series): rows.append(streamer.update((float(c), float(c), float(c), float(c), float(v), 0))) streamed = np.array([math.nan if x is None else x for x in rows], dtype=np.float64) assert _equal_with_nan(batch, streamed) + + +def test_rolling_vwap_streaming_matches_batch(ohlc_series): + # RollingVWAP(20) on the shared OHLC series. Provides finite-memory VWAP + # parity coverage now that the indicator is exposed across all bindings. + high, low, close = ohlc_series + volume = np.linspace(100.0, 200.0, num=close.size, dtype=np.float64) + batch = ta.RollingVWAP(20).batch(high, low, close, volume) + + streamer = ta.RollingVWAP(20) + rows = [] + for h, l, c, v in zip(high, low, close, volume): + rows.append(streamer.update((float(c), float(h), float(l), float(c), float(v), 0))) + streamed = np.array([math.nan if x is None else x for x in rows], dtype=np.float64) + assert _equal_with_nan(batch, streamed) + assert streamer.period == 20 + assert streamer.warmup_period() == 20 + assert streamer.is_ready() + streamer.reset() + assert not streamer.is_ready() diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 49005501..56b98a81 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1842,6 +1842,65 @@ impl WasmVwap { } } +#[wasm_bindgen(js_name = RollingVWAP)] +pub struct WasmRollingVwap { + inner: wc::RollingVwap, +} + +#[wasm_bindgen(js_class = RollingVWAP)] +impl WasmRollingVwap { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::RollingVwap::new(period).map_err(map_err)?, + }) + } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { + self.inner.period() + } + 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 { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(JsError::new( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let c = make_candle(high[i], low[i], close[i], 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 = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + #[wasm_bindgen(js_name = AwesomeOscillator)] pub struct WasmAo { inner: wc::AwesomeOscillator, @@ -2292,6 +2351,36 @@ mod tests { } } + #[allow(clippy::many_single_char_names)] + #[wasm_bindgen_test] + fn rolling_vwap_streaming_matches_batch_and_lifecycle() { + // R4: RollingVwap is now exposed in WASM (previously Rust-only despite + // the README listing it as a cross-language indicator). + let (h, l, c, v) = synthetic_ohlcv(50); + let batch = WasmRollingVwap::new(10) + .expect("valid") + .batch(&h, &l, &c, &v) + .expect("valid"); + let mut rv = WasmRollingVwap::new(10).expect("valid"); + assert_eq!(rv.warmup_period(), 10); + assert_eq!(rv.period(), 10); + assert!(!rv.is_ready()); + for i in 0..h.len() { + let s = rv + .update(h[i], l[i], c[i], v[i]) + .expect("valid") + .unwrap_or(f64::NAN); + assert!( + close_enough(s, batch.get_index(i as u32)), + "RollingVWAP at {i}" + ); + } + assert!(rv.is_ready()); + rv.reset(); + assert!(!rv.is_ready()); + assert!(WasmRollingVwap::new(0).is_err()); + } + #[wasm_bindgen_test] fn kama_exposes_warmup_period() { // R8: the KAMA wrapper was missing `warmupPeriod`; it now matches the diff --git a/docs/wiki/indicators/volume/Indicator-Vwap.md b/docs/wiki/indicators/volume/Indicator-Vwap.md index 9bcc1c97..734ae15d 100644 --- a/docs/wiki/indicators/volume/Indicator-Vwap.md +++ b/docs/wiki/indicators/volume/Indicator-Vwap.md @@ -178,8 +178,9 @@ fair-price benchmark instead of an unbounded session aggregate. | `period` | `usize` | (no default) | `> 0` | `RollingVwap::new` (`vwap.rs:89`) | `period == 0` returns `Error::PeriodZero`. `RollingVwap` is exposed in -Rust only — Python's `VWAP` / Node's `VWAP` correspond to the cumulative -form. +**all four bindings**: as `RollingVwap` in Rust and `RollingVWAP` in +Python, Node and WASM. The plain `VWAP` class in each binding remains the +cumulative form; `RollingVWAP` is the finite-window variant. ### Inputs / Outputs @@ -245,8 +246,54 @@ Hand check at `t = 3` with window `[10@1, 20@3, 30@1]`: At `t = 4` with window `[20@3, 30@1, 40@2]`: `(60 + 30 + 80) / (3+1+2) = 170/6 ≈ 28.333`. -(`RollingVwap` is currently exposed only in the Rust API; the Python -`VWAP` and Node `VWAP` classes correspond to the cumulative form.) +#### Python + +```python +import numpy as np +import wickra as ta + +high = np.array([10.0, 20.0, 30.0, 40.0]) +low = np.array([10.0, 20.0, 30.0, 40.0]) +close = np.array([10.0, 20.0, 30.0, 40.0]) +volume = np.array([ 1.0, 3.0, 1.0, 2.0]) + +rv = ta.RollingVWAP(3) +print(rv.batch(high, low, close, volume)) +# [nan, nan, 20.0, 28.333333333333332] +``` + +#### Node + +```js +const { RollingVWAP } = require('wickra'); + +const high = [10, 20, 30, 40]; +const low = [10, 20, 30, 40]; +const close = [10, 20, 30, 40]; +const volume = [ 1, 3, 1, 2]; + +const rv = new RollingVWAP(3); +console.log(rv.batch(high, low, close, volume)); +// [ NaN, NaN, 20, 28.333333333333332 ] +``` + +#### WASM + +```html + +``` ## Interpretation