feat: Family 07 Volume - 6 new volume-flow indicators (#45)

* feat(kvo): add Klinger Volume Oscillator

Stephen J. Klinger's trend-aware volume-force MACD. Each bar produces a 'volume force' (vf) signed by the local trend (+1 / -1 / carry) and scaled by the ratio of the current accumulation horizon to its previous trend. KVO = EMA(vf, fast) - EMA(vf, slow), classic (34, 55).

Rust core (Kvo) with 7 unit tests (rejects zero / fast>=slow, accessors, constant series collapses to 0, warmup lands at slow+1, batch == streaming, reset clears state), plus Python (PyKvo + KVO export), Node (KvoNode), and WASM (WasmKvo) bindings. Fuzz target adds Kvo to the candle-input sweep, bench adds the candle-input KVO benchmark, README counter 71 -> 72 + family table row, CHANGELOG [Unreleased].

* feat(volume-oscillator): add Volume Oscillator (VO)

Percent difference between a fast and a slow SMA of the bar volume: 100 * (SMA(vol, fast) - SMA(vol, slow)) / SMA(vol, slow). Default (14, 28). The line stays near zero in stable conditions; positive readings show rising short-term participation, negative readings show waning interest.

Rust core (VolumeOscillator) with 8 unit tests (period validation, accessors, constant volume == 0, zero-volume window defensive branch, two reference values verified algebraically, batch == streaming, reset), plus Python (PyVolumeOscillator + VolumeOscillator export), Node (VolumeOscillatorNode), and WASM (WasmVolumeOscillator) bindings. Fuzz target adds VolumeOscillator to the candle-input sweep, bench adds the volume_oscillator benchmark, README counter 72 -> 73 + family table row, CHANGELOG [Unreleased].

* feat(nvi-pvi): add Negative & Positive Volume Index

Paul Dysart's cumulative volume-flow indices, popularised by Norman Fosback in 'Stock Market Logic'. Both run from a 1000.0 baseline and only update on a specific direction of volume change:

- NVI updates on volume-contraction bars (volume_t < volume_{t-1}), absorbing the percent close change. Tracks the 'smart money' leg per Fosback.
- PVI updates on volume-expansion bars (volume_t > volume_{t-1}). Tracks the 'crowd' leg.

Both expose with_baseline(f64) for custom starting indexes. The NVI/PVI pair is listed as a single line in indicator-ideas/families/07-volume.md and shares the same lifecycle/test/binding surface, so they ship as one commit.

Rust core (Nvi, Pvi) with 9 unit tests each (accessors, baseline seed, volume direction branches, zero-prev-close guard, custom baseline, batch == streaming, reset), plus Python (PyNvi/PyPvi + NVI/PVI exports), Node (NviNode/PviNode), and WASM (WasmNvi/WasmPvi) bindings. Fuzz target adds Nvi+Pvi to the candle-input sweep, bench adds nvi+pvi entries, README counter 73 -> 75 + family table row, CHANGELOG [Unreleased].

* feat(family-07): add Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index

Finishes the volume-flow family with the remaining (new) entries from
indicator-ideas/families/07-volume.md.

Indicators added:

- Williams A/D (`WilliamsAD`): Larry Williams' volume-less cumulative
  accumulation/distribution line. Anchors each bar's contribution to
  the previous close via true-high/true-low (gap-aware).
- Anchored VWAP (`AnchoredVwap`): cumulative VWAP whose accumulation
  starts at a user-chosen anchor bar. Exposes `set_anchor()` (queued
  to the next `update`) for click-to-anchor workflows. Reset clears
  both state and pending-anchor flag.
- Demand Index (`DemandIndex`): James Sibbet's smoothed buying-vs-
  selling pressure, in the streaming-friendly textbook form
  `EMA(volume * close-return * (1 + range/close), period)`.
- Time Segmented Volume (`Tsv`): Don Worden's rolling window-sum of
  `(close_t - close_{t-1}) * volume_t`. Default `period = 18`.
- Volume Zone Oscillator (`Vzo`): Walid Khalil's normalised volume-flow
  oscillator bounded in `[-100, +100]`, defined as
  `100 * EMA(signed_volume) / EMA(volume)`.
- Market Facilitation Index (`MarketFacilitationIndex`): Bill Williams'
  per-bar `(high - low) / volume`. Returns `None` on zero-volume bars.

All six indicators ship with unit tests (`rejects_zero_period` where
applicable, `accessors_and_metadata`, constant-series behaviour,
batch == streaming equivalence, reset semantics, and reference-value
or saturation-extreme tests), Python / Node / WASM bindings, fuzz
coverage in `indicator_update_candle`, a `bench_candle_input` line per
indicator, README + CHANGELOG entries, and Python reference-value
tests in `test_new_indicators.py`.

The README indicator counter advances 75 -> 81.

* test(family-07): cover defensive cold paths + Default impls

- ad_oscillator: exercise `value()` after first emission.
- kvo: cover the `cm == 0.0` zero-OHLC defensive branch.
- nvi / pvi: exercise the Default impls.
This commit is contained in:
kingchenc
2026-05-25 19:15:22 +02:00
committed by GitHub
parent 6287bd48c1
commit 880a0e7430
23 changed files with 4155 additions and 38 deletions
+391
View File
@@ -1198,6 +1198,397 @@ impl WasmForceIndex {
}
}
#[wasm_bindgen(js_name = VolumeOscillator)]
pub struct WasmVolumeOscillator {
inner: wc::VolumeOscillator,
}
#[wasm_bindgen(js_class = VolumeOscillator)]
impl WasmVolumeOscillator {
#[wasm_bindgen(constructor)]
pub fn new(fast: usize, slow: usize) -> Result<WasmVolumeOscillator, JsError> {
Ok(Self {
inner: wc::VolumeOscillator::new(fast, slow).map_err(map_err)?,
})
}
pub fn update(&mut self, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(10.0, 10.0, 10.0, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(&mut self, volume: &[f64]) -> Result<Float64Array, JsError> {
let mut out = Vec::with_capacity(volume.len());
for &v in volume {
let c = make_candle(10.0, 10.0, 10.0, v)?;
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 = NVI)]
pub struct WasmNvi {
inner: wc::Nvi,
}
#[wasm_bindgen(js_class = NVI)]
impl WasmNvi {
#[wasm_bindgen(constructor)]
pub fn new(baseline: Option<f64>) -> WasmNvi {
Self {
inner: wc::Nvi::with_baseline(baseline.unwrap_or(1000.0)),
}
}
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(close[i], close[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 = PVI)]
pub struct WasmPvi {
inner: wc::Pvi,
}
#[wasm_bindgen(js_class = PVI)]
impl WasmPvi {
#[wasm_bindgen(constructor)]
pub fn new(baseline: Option<f64>) -> WasmPvi {
Self {
inner: wc::Pvi::with_baseline(baseline.unwrap_or(1000.0)),
}
}
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(close[i], close[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 = KVO)]
pub struct WasmKvo {
inner: wc::Kvo,
}
#[wasm_bindgen(js_class = KVO)]
impl WasmKvo {
#[wasm_bindgen(constructor)]
pub fn new(fast: usize, slow: usize) -> Result<WasmKvo, JsError> {
Ok(Self {
inner: wc::Kvo::new(fast, slow).map_err(map_err)?,
})
}
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> {
let n = high.len();
if low.len() != n || close.len() != n || volume.len() != n {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
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 = WilliamsAD)]
pub struct WasmAdOscillator {
inner: wc::AdOscillator,
}
#[wasm_bindgen(js_class = WilliamsAD)]
impl WasmAdOscillator {
#[wasm_bindgen(constructor)]
#[allow(clippy::new_without_default)]
pub fn new() -> WasmAdOscillator {
Self {
inner: wc::AdOscillator::new(),
}
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
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 = AnchoredVWAP)]
pub struct WasmAnchoredVwap {
inner: wc::AnchoredVwap,
}
#[wasm_bindgen(js_class = AnchoredVWAP)]
impl WasmAnchoredVwap {
#[wasm_bindgen(constructor)]
#[allow(clippy::new_without_default)]
pub fn new() -> WasmAnchoredVwap {
Self {
inner: wc::AnchoredVwap::new(),
}
}
#[wasm_bindgen(js_name = setAnchor)]
pub fn set_anchor(&mut self) {
self.inner.set_anchor();
}
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> {
let n = high.len();
if low.len() != n || close.len() != n || volume.len() != n {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
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 = DemandIndex)]
pub struct WasmDemandIndex {
inner: wc::DemandIndex,
}
#[wasm_bindgen(js_class = DemandIndex)]
impl WasmDemandIndex {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmDemandIndex, JsError> {
Ok(Self {
inner: wc::DemandIndex::new(period).map_err(map_err)?,
})
}
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> {
let n = high.len();
if low.len() != n || close.len() != n || volume.len() != n {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
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 = TSV)]
pub struct WasmTsv {
inner: wc::Tsv,
}
#[wasm_bindgen(js_class = TSV)]
impl WasmTsv {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmTsv, JsError> {
Ok(Self {
inner: wc::Tsv::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(close[i], close[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 = VZO)]
pub struct WasmVzo {
inner: wc::Vzo,
}
#[wasm_bindgen(js_class = VZO)]
impl WasmVzo {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmVzo, JsError> {
Ok(Self {
inner: wc::Vzo::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(close[i], close[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 = MarketFacilitationIndex)]
pub struct WasmMarketFacilitationIndex {
inner: wc::MarketFacilitationIndex,
}
#[wasm_bindgen(js_class = MarketFacilitationIndex)]
impl WasmMarketFacilitationIndex {
#[wasm_bindgen(constructor)]
#[allow(clippy::new_without_default)]
pub fn new() -> WasmMarketFacilitationIndex {
Self {
inner: wc::MarketFacilitationIndex::new(),
}
}
pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, low, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || volume.len() != n {
return Err(JsError::new("high, low, volume must be equal length"));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let c = make_candle(high[i], low[i], low[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 = EaseOfMovement)]
pub struct WasmEaseOfMovement {
inner: wc::EaseOfMovement,