feat(bindings): expose RollingVWAP in Python, Node and WASM (R4)

The rolling-window VWAP indicator (`wickra_core::RollingVwap`) was only
available in the Rust crate, even though the README's Volume-family
table already advertised "VWAP (cumulative + rolling)" as a cross-
language feature. Users on Python, Node or in the browser had to fall
back to the cumulative `VWAP` or re-implement the rolling variant
themselves.

This commit closes the gap end-to-end:

- Python: `wickra.RollingVWAP(period)` — same constructor / `update` /
  `batch` / `reset` / `is_ready` / `warmup_period` surface as `VWAP`,
  plus a `period` property and a typed `__repr__`. The `__init__.py`
  re-exports it and `__all__` lists it; the `.pyi` stub matches.
- Node: `RollingVWAP(period)` — napi class with the same lifecycle,
  exported from `index.js` and declared in `index.d.ts`.
- WASM: `RollingVWAP(period)` — wasm-bindgen class with the same
  `Float64Array` I/O as `VWAP`.

Tests added:

- Python: `test_rolling_vwap_streaming_matches_batch` — exercises
  `update == batch` plus the full lifecycle on the shared OHLC fixture.
- Node: `RollingVWAP` row in the `candleScalar` parity table — covered
  by the generic streaming-vs-batch + lifecycle harness.
- WASM: dedicated `wasm-bindgen-test` mirrors the Python test.

The wiki page `Indicator-Vwap.md` drops the "Rust-only" caveat and
gains Python / Node / WASM examples.
This commit is contained in:
kingchenc
2026-05-23 01:43:00 +02:00
parent 3a6b5ebae3
commit efcd6216c1
10 changed files with 322 additions and 5 deletions
+7
View File
@@ -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
@@ -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) },
+2 -1
View File
@@ -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
+63
View File
@@ -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<Self> {
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<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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,
@@ -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",
@@ -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]: ...
+71
View File
@@ -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<Self> {
Ok(Self {
inner: wc::RollingVwap::new(period).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
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<Bound<'py, PyArray1<f64>>> {
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::<PyKeltner>()?;
m.add_class::<PyDonchian>()?;
m.add_class::<PyVwap>()?;
m.add_class::<PyRollingVwap>()?;
m.add_class::<PyAo>()?;
m.add_class::<PyAroon>()?;
m.add_class::<PySmma>()?;
@@ -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()
+89
View File
@@ -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<WasmRollingVwap, JsError> {
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<Option<f64>, 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<Float64Array, JsError> {
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
+51 -4
View File
@@ -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
<script type="module">
import init, { RollingVWAP } from './pkg/wickra_wasm.js';
await init();
const rv = new RollingVWAP(3);
const out = rv.batch(
new Float64Array([10, 20, 30, 40]),
new Float64Array([10, 20, 30, 40]),
new Float64Array([10, 20, 30, 40]),
new Float64Array([ 1, 3, 1, 2]),
);
console.log(Array.from(out));
// [ NaN, NaN, 20, 28.333333333333332 ]
</script>
```
## Interpretation