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
+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