From 3a6b5ebae3bb79ced2e2037c15e635f0a2cf34e6 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 23 May 2026 01:34:54 +0200 Subject: [PATCH] feat(wasm): expose streaming update/isReady/warmupPeriod for 12 candle indicators (R3, R8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve WASM classes previously exposed only `batch()` (and not even `reset()` for ten of them): ADX, WilliamsR, CCI, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, Stochastic, OBV. Browser consumers wanting per-tick updates had to replay `batch()` on every new candle — the opposite of the library's streaming-first promise. Each class now exposes: - `update(...)` — per-tick streaming update with the same column inputs as `batch()`. Single-output indicators return `Option`. Multi- output indicators (ADX, Keltner, Donchian, Aroon, Stochastic) return a named JS object (`{ plusDi, minusDi, adx }`, `{ upper, middle, lower }`, `{ up, down }`, `{ k, d }`) once warm, or `null` during warmup. This matches the existing `SuperTrend` convention so JS code can treat all multi-output WASM indicators uniformly. - `reset()`, `isReady()`, `warmupPeriod()` — bring the lifecycle API to full parity with Python and Node. `WasmKama` also gains the previously missing `warmupPeriod()` (R8). A single new `wasm-bindgen-test` exercises every newly wired class against a deterministic 40-bar synthetic OHLCV stream, asserting that streaming `update` matches `batch` value-by-value and that the lifecycle contract behaves the same as the core indicator. --- CHANGELOG.md | 15 ++ bindings/wasm/src/lib.rs | 538 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 553 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c259739..6295a9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- 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 + indicators (`Adx`, `Keltner`, `Donchian`, `Aroon`, `Stochastic`) return a + named JS object (`{ plusDi, minusDi, adx }`, `{ upper, middle, lower }`, + `{ up, down }`, `{ k, d }`) once warm, or `null` during warmup — matching the + existing `SuperTrend` convention. Each class also gains `reset()`, `isReady()` + and `warmupPeriod()`, bringing the WASM surface to full parity with Python + and Node so browser-side streaming code no longer has to replay `batch()` + on every tick. `WasmKama` gains the previously missing `warmupPeriod()`. +- New `wasm-bindgen` integration test exercises `update == batch` plus the full + lifecycle (`reset` / `isReady` / `warmupPeriod`) for all twelve newly wired + classes against a deterministic 40-bar synthetic OHLCV stream. + ### Security - Upgrade `pyo3` (0.22 → 0.28) and `numpy` (0.22 → 0.28) in the Python binding. Fixes [RUSTSEC-2025-0020](https://rustsec.org/advisories/RUSTSEC-2025-0020) — diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 71dcfae9..49005501 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -127,6 +127,10 @@ impl WasmKama { 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() + } } // ---------- MACD ---------- @@ -307,6 +311,27 @@ impl WasmStoch { pub fn reset(&mut self) { self.inner.reset(); } + /// Streaming update. Returns `{ k, d }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"k".into(), &o.k.into()).ok(); + Reflect::set(&obj, &"d".into(), &o.d.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + #[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 = OBV)] @@ -342,6 +367,18 @@ impl WasmObv { pub fn reset(&mut self) { self.inner.reset(); } + pub fn update(&mut self, close: f64, volume: f64) -> Result, JsError> { + let c = make_candle(close, close, close, volume)?; + Ok(self.inner.update(c)) + } + #[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 = UltimateOscillator)] @@ -1403,6 +1440,31 @@ impl WasmAdx { } Ok(Float64Array::from(out.as_slice())) } + /// Streaming update. Returns `{ plusDi, minusDi, adx }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"plusDi".into(), &o.plus_di.into()).ok(); + Reflect::set(&obj, &"minusDi".into(), &o.minus_di.into()).ok(); + Reflect::set(&obj, &"adx".into(), &o.adx.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + 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 = WilliamsR)] @@ -1434,6 +1496,21 @@ impl WasmWilliamsR { } Ok(Float64Array::from(out.as_slice())) } + 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 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 = CCI)] @@ -1465,6 +1542,21 @@ impl WasmCci { } Ok(Float64Array::from(out.as_slice())) } + 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 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 = MFI)] @@ -1499,6 +1591,27 @@ impl WasmMfi { } Ok(Float64Array::from(out.as_slice())) } + 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 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 = PSAR)] @@ -1530,6 +1643,21 @@ impl WasmPsar { } Ok(Float64Array::from(out.as_slice())) } + 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 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 = Keltner)] @@ -1570,6 +1698,31 @@ impl WasmKeltner { } Ok(Float64Array::from(out.as_slice())) } + /// Streaming update. Returns `{ upper, middle, lower }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok(); + Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok(); + Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + 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 = Donchian)] @@ -1601,6 +1754,31 @@ impl WasmDonchian { } Ok(Float64Array::from(out.as_slice())) } + /// Streaming update. Returns `{ upper, middle, lower }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64) -> Result { + let c = make_candle(high, low, low, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok(); + Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok(); + Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + 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 = VWAP)] @@ -1641,6 +1819,27 @@ impl WasmVwap { } Ok(Float64Array::from(out.as_slice())) } + 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 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)] @@ -1667,6 +1866,21 @@ impl WasmAo { } Ok(Float64Array::from(out.as_slice())) } + pub fn update(&mut self, high: f64, low: f64) -> Result, JsError> { + let c = make_candle(high, low, low, 0.0)?; + Ok(self.inner.update(c)) + } + 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 = Aroon)] @@ -1698,6 +1912,30 @@ impl WasmAroon { } Ok(Float64Array::from(out.as_slice())) } + /// Streaming update. Returns `{ up, down }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64) -> Result { + let c = make_candle(high, low, low, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"up".into(), &o.up.into()).ok(); + Reflect::set(&obj, &"down".into(), &o.down.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + 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() + } } #[cfg(test)] @@ -1764,4 +2002,304 @@ mod tests { .batch(&[1.0, 2.0], &[1.0, 2.0], &[1.0, 2.0], &[1.0]) .is_err()); } + + // ---------- Streaming-API coverage for the candle-input indicators + // (Adx, WilliamsR, Cci, Mfi, Psar, Keltner, Donchian, Vwap, AwesomeOscillator, + // Aroon, Stochastic, Obv). Verifies that the per-tick `update` produces + // exactly the same per-row values as `batch` and that the lifecycle methods + // (`reset`, `isReady`, `warmupPeriod`) honour the same contract as Python / + // Node and as the other already-wired WASM classes. + + /// Deterministic OHLCV stream long enough to clear every indicator's warmup + /// (the longest in this group is `Adx(14)` at `2*period = 28` bars). + fn synthetic_ohlcv(n: usize) -> (Vec, Vec, Vec, Vec) { + let mut high = Vec::with_capacity(n); + let mut low = Vec::with_capacity(n); + let mut close = Vec::with_capacity(n); + let mut volume = Vec::with_capacity(n); + for i in 0..n { + let t = i as f64; + let mid = 100.0 + (t * 0.17).sin() * 5.0 + t * 0.05; + let spread = 1.0 + (t * 0.31).cos().abs(); + high.push(mid + spread); + low.push(mid - spread); + close.push(mid + (t * 0.07).sin() * 0.5); + volume.push(1_000.0 + (t * 0.21).sin().abs() * 500.0); + } + (high, low, close, volume) + } + + fn close_enough(a: f64, b: f64) -> bool { + if a.is_nan() { + b.is_nan() + } else { + (a - b).abs() < 1e-9 + } + } + + // Single test deliberately exercises every newly wired class so the lifecycle + // contract (`update == batch` + `is_ready` + `warmup_period` + `reset`) lives + // in one auditable place. `h`/`l`/`c`/`v` mirror the OHLCV column names used + // by the production API and the rest of the test suite. + #[allow(clippy::too_many_lines, clippy::many_single_char_names)] + #[wasm_bindgen_test] + fn candle_input_streaming_matches_batch_and_lifecycle_is_consistent() { + let (h, l, c, v) = synthetic_ohlcv(40); + + // --- ADX (multi: { plusDi, minusDi, adx }) --- + let batch = WasmAdx::new(7) + .expect("valid") + .batch(&h, &l, &c) + .expect("valid"); + let mut adx = WasmAdx::new(7).expect("valid"); + assert!(!adx.is_ready()); + assert_eq!( + adx.warmup_period(), + wc::Adx::new(7).expect("valid").warmup_period() + ); + for i in 0..h.len() { + let stream = adx.update(h[i], l[i], c[i]).expect("valid"); + let bp = batch.get_index((i * 3) as u32); + let bm = batch.get_index((i * 3 + 1) as u32); + let ba = batch.get_index((i * 3 + 2) as u32); + if stream.is_null() { + assert!(bp.is_nan() && bm.is_nan() && ba.is_nan(), "row {i}"); + } else { + let obj: &Object = stream.unchecked_ref(); + let p = Reflect::get(obj, &"plusDi".into()) + .unwrap() + .as_f64() + .unwrap(); + let m = Reflect::get(obj, &"minusDi".into()) + .unwrap() + .as_f64() + .unwrap(); + let a = Reflect::get(obj, &"adx".into()).unwrap().as_f64().unwrap(); + assert!(close_enough(p, bp), "ADX plusDi diverges at {i}"); + assert!(close_enough(m, bm), "ADX minusDi diverges at {i}"); + assert!(close_enough(a, ba), "ADX value diverges at {i}"); + } + } + assert!(adx.is_ready()); + adx.reset(); + assert!(!adx.is_ready()); + + // --- WilliamsR (single) --- + let batch = WasmWilliamsR::new(14) + .expect("valid") + .batch(&h, &l, &c) + .expect("valid"); + let mut wr = WasmWilliamsR::new(14).expect("valid"); + for i in 0..h.len() { + let s = wr + .update(h[i], l[i], c[i]) + .expect("valid") + .unwrap_or(f64::NAN); + assert!( + close_enough(s, batch.get_index(i as u32)), + "WilliamsR at {i}" + ); + } + assert!(wr.is_ready()); + assert_eq!( + wr.warmup_period(), + wc::WilliamsR::new(14).expect("valid").warmup_period() + ); + wr.reset(); + assert!(!wr.is_ready()); + + // --- CCI (single) --- + let batch = WasmCci::new(14) + .expect("valid") + .batch(&h, &l, &c) + .expect("valid"); + let mut cci = WasmCci::new(14).expect("valid"); + for i in 0..h.len() { + let s = cci + .update(h[i], l[i], c[i]) + .expect("valid") + .unwrap_or(f64::NAN); + assert!(close_enough(s, batch.get_index(i as u32)), "CCI at {i}"); + } + + // --- MFI (single, with volume) --- + let batch = WasmMfi::new(14) + .expect("valid") + .batch(&h, &l, &c, &v) + .expect("valid"); + let mut mfi = WasmMfi::new(14).expect("valid"); + for i in 0..h.len() { + let s = mfi + .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)), "MFI at {i}"); + } + + // --- PSAR (single) --- + let batch = WasmPsar::new(0.02, 0.02, 0.2) + .expect("valid") + .batch(&h, &l, &c) + .expect("valid"); + let mut psar = WasmPsar::new(0.02, 0.02, 0.2).expect("valid"); + for i in 0..h.len() { + let s = psar + .update(h[i], l[i], c[i]) + .expect("valid") + .unwrap_or(f64::NAN); + assert!(close_enough(s, batch.get_index(i as u32)), "PSAR at {i}"); + } + + // --- Keltner (multi: { upper, middle, lower }) --- + let batch = WasmKeltner::new(10, 10, 2.0) + .expect("valid") + .batch(&h, &l, &c) + .expect("valid"); + let mut kc = WasmKeltner::new(10, 10, 2.0).expect("valid"); + for i in 0..h.len() { + let stream = kc.update(h[i], l[i], c[i]).expect("valid"); + let bu = batch.get_index((i * 3) as u32); + let bm = batch.get_index((i * 3 + 1) as u32); + let bl = batch.get_index((i * 3 + 2) as u32); + if stream.is_null() { + assert!(bu.is_nan() && bm.is_nan() && bl.is_nan(), "Keltner row {i}"); + } else { + let obj: &Object = stream.unchecked_ref(); + let u = Reflect::get(obj, &"upper".into()) + .unwrap() + .as_f64() + .unwrap(); + let m = Reflect::get(obj, &"middle".into()) + .unwrap() + .as_f64() + .unwrap(); + let lo = Reflect::get(obj, &"lower".into()) + .unwrap() + .as_f64() + .unwrap(); + assert!(close_enough(u, bu), "Keltner upper at {i}"); + assert!(close_enough(m, bm), "Keltner middle at {i}"); + assert!(close_enough(lo, bl), "Keltner lower at {i}"); + } + } + + // --- Donchian (multi: { upper, middle, lower }) --- + let batch = WasmDonchian::new(10) + .expect("valid") + .batch(&h, &l) + .expect("valid"); + let mut dc = WasmDonchian::new(10).expect("valid"); + for i in 0..h.len() { + let stream = dc.update(h[i], l[i]).expect("valid"); + let bu = batch.get_index((i * 3) as u32); + let bm = batch.get_index((i * 3 + 1) as u32); + let bl = batch.get_index((i * 3 + 2) as u32); + if stream.is_null() { + assert!( + bu.is_nan() && bm.is_nan() && bl.is_nan(), + "Donchian row {i}" + ); + } else { + let obj: &Object = stream.unchecked_ref(); + let u = Reflect::get(obj, &"upper".into()) + .unwrap() + .as_f64() + .unwrap(); + let m = Reflect::get(obj, &"middle".into()) + .unwrap() + .as_f64() + .unwrap(); + let lo = Reflect::get(obj, &"lower".into()) + .unwrap() + .as_f64() + .unwrap(); + assert!(close_enough(u, bu), "Donchian upper at {i}"); + assert!(close_enough(m, bm), "Donchian middle at {i}"); + assert!(close_enough(lo, bl), "Donchian lower at {i}"); + } + } + + // --- VWAP (single, cumulative, with volume) --- + let batch = WasmVwap::new().batch(&h, &l, &c, &v).expect("valid"); + let mut vwap = WasmVwap::new(); + for i in 0..h.len() { + let s = vwap + .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)), "VWAP at {i}"); + } + + // --- AwesomeOscillator (single) --- + let batch = WasmAo::new(5, 34) + .expect("valid") + .batch(&h, &l) + .expect("valid"); + let mut ao = WasmAo::new(5, 34).expect("valid"); + for i in 0..h.len() { + let s = ao.update(h[i], l[i]).expect("valid").unwrap_or(f64::NAN); + assert!(close_enough(s, batch.get_index(i as u32)), "AO at {i}"); + } + + // --- Aroon (multi: { up, down }) --- + let batch = WasmAroon::new(14) + .expect("valid") + .batch(&h, &l) + .expect("valid"); + let mut aroon = WasmAroon::new(14).expect("valid"); + for i in 0..h.len() { + let stream = aroon.update(h[i], l[i]).expect("valid"); + let bu = batch.get_index((i * 2) as u32); + let bd = batch.get_index((i * 2 + 1) as u32); + if stream.is_null() { + assert!(bu.is_nan() && bd.is_nan(), "Aroon row {i}"); + } else { + let obj: &Object = stream.unchecked_ref(); + let u = Reflect::get(obj, &"up".into()).unwrap().as_f64().unwrap(); + let d = Reflect::get(obj, &"down".into()).unwrap().as_f64().unwrap(); + assert!(close_enough(u, bu), "Aroon up at {i}"); + assert!(close_enough(d, bd), "Aroon down at {i}"); + } + } + + // --- Stochastic (multi: { k, d }) --- + let batch = WasmStoch::new(14, 3) + .expect("valid") + .batch(&h, &l, &c) + .expect("valid"); + let mut st = WasmStoch::new(14, 3).expect("valid"); + for i in 0..h.len() { + let stream = st.update(h[i], l[i], c[i]).expect("valid"); + let bk = batch.get_index((i * 2) as u32); + let bd = batch.get_index((i * 2 + 1) as u32); + if stream.is_null() { + assert!(bk.is_nan() && bd.is_nan(), "Stoch row {i}"); + } else { + let obj: &Object = stream.unchecked_ref(); + let k = Reflect::get(obj, &"k".into()).unwrap().as_f64().unwrap(); + let d = Reflect::get(obj, &"d".into()).unwrap().as_f64().unwrap(); + assert!(close_enough(k, bk), "Stoch k at {i}"); + assert!(close_enough(d, bd), "Stoch d at {i}"); + } + } + + // --- OBV (single, with volume; no warmup) --- + let batch = WasmObv::new().batch(&c, &v).expect("valid"); + let mut obv = WasmObv::new(); + for i in 0..c.len() { + let s = obv.update(c[i], v[i]).expect("valid").unwrap_or(f64::NAN); + assert!(close_enough(s, batch.get_index(i as u32)), "OBV at {i}"); + } + } + + #[wasm_bindgen_test] + fn kama_exposes_warmup_period() { + // R8: the KAMA wrapper was missing `warmupPeriod`; it now matches the + // core indicator's value (which is `er_period + 1`). + let kama = WasmKama::new(10, 2, 30).expect("valid"); + assert_eq!( + kama.warmup_period(), + wc::Kama::new(10, 2, 30).expect("valid").warmup_period() + ); + } }