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:
@@ -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) },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]: ...
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user