diff --git a/bindings/node/index.js b/bindings/node/index.js index 9c7f9880..5807bba2 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -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, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, ADL, VolumePriceTrend, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -345,6 +345,8 @@ module.exports.UlcerIndex = UlcerIndex module.exports.HistoricalVolatility = HistoricalVolatility module.exports.BollingerBandwidth = BollingerBandwidth module.exports.PercentB = PercentB +module.exports.ADL = ADL +module.exports.VolumePriceTrend = VolumePriceTrend module.exports.MACD = MACD module.exports.BollingerBands = BollingerBands module.exports.ATR = ATR diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 970dce30..3ac2dc89 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -1147,6 +1147,130 @@ impl PmoNode { // ============================== VWMA ============================== +// ============================== ADL ============================== + +#[napi(js_name = "ADL")] +pub struct AdlNode { + inner: wc::Adl, +} + +impl Default for AdlNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl AdlNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::Adl::new(), + } + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, volume)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + 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] + 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 + } +} + +// ============================== Volume-Price Trend ============================== + +#[napi(js_name = "VolumePriceTrend")] +pub struct VolumePriceTrendNode { + inner: wc::VolumePriceTrend, +} + +impl Default for VolumePriceTrendNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl VolumePriceTrendNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::VolumePriceTrend::new(), + } + } + #[napi] + pub fn update(&mut self, close: f64, volume: f64) -> napi::Result> { + Ok(self.inner.update(cnd(close, close, close, volume)?)) + } + #[napi] + pub fn batch(&mut self, close: Vec, volume: Vec) -> napi::Result> { + if close.len() != volume.len() { + return Err(NapiError::from_reason( + "close and volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd(close[i], close[i], close[i], volume[i])?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[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 + } +} + // ============================== Bollinger Bandwidth ============================== #[napi(js_name = "BollingerBandwidth")] diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index 2bd17d39..8d0ed8bb 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -76,6 +76,36 @@ class TRIMA: @property def value(self) -> Optional[float]: ... +class ADL: + def __init__(self) -> 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]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def value(self) -> Optional[float]: ... + +class VolumePriceTrend: + def __init__(self) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + close: NDArray[np.float64], + volume: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def value(self) -> Optional[float]: ... + class BollingerBandwidth: def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ... def update(self, value: float) -> Optional[float]: ... diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 60550a35..816f2664 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1519,6 +1519,140 @@ impl PyAroon { } } +// ============================== ADL ============================== + +#[pyclass(name = "ADL", module = "wickra._wickra")] +#[derive(Clone)] +struct PyAdl { + inner: wc::Adl, +} + +#[pymethods] +impl PyAdl { + #[new] + fn new() -> Self { + Self { + inner: wc::Adl::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: high, low, close, volume (all equal length). + 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>> { + 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_bound(py)) + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + 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 { + "ADL()".to_string() + } +} + +// ============================== Volume-Price Trend ============================== + +#[pyclass(name = "VolumePriceTrend", module = "wickra._wickra")] +#[derive(Clone)] +struct PyVolumePriceTrend { + inner: wc::VolumePriceTrend, +} + +#[pymethods] +impl PyVolumePriceTrend { + #[new] + fn new() -> Self { + Self { + inner: wc::VolumePriceTrend::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy close + volume arrays (both 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + 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 c.len() != v.len() { + return Err(PyValueError::new_err( + "close and volume must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + 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 { + "VolumePriceTrend()".to_string() + } +} + // ============================== Bollinger Bandwidth ============================== #[pyclass(name = "BollingerBandwidth", module = "wickra._wickra")] @@ -2911,5 +3045,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 0bd13215..054d6f07 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -377,6 +377,99 @@ impl WasmUltimateOscillator { } } +#[wasm_bindgen(js_name = ADL)] +pub struct WasmAdl { + inner: wc::Adl, +} + +impl Default for WasmAdl { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = ADL)] +impl WasmAdl { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmAdl { + Self { + inner: wc::Adl::new(), + } + } + 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 batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + ) -> Result { + 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 = VolumePriceTrend)] +pub struct WasmVolumePriceTrend { + inner: wc::VolumePriceTrend, +} + +impl Default for WasmVolumePriceTrend { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = VolumePriceTrend)] +impl WasmVolumePriceTrend { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmVolumePriceTrend { + Self { + inner: wc::VolumePriceTrend::new(), + } + } + pub fn update(&mut self, close: f64, volume: f64) -> Result, JsError> { + let c = make_candle(close, close, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result { + 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 = NATR)] pub struct WasmNatr { inner: wc::Natr, diff --git a/crates/wickra-core/src/indicators/adl.rs b/crates/wickra-core/src/indicators/adl.rs new file mode 100644 index 00000000..f325f456 --- /dev/null +++ b/crates/wickra-core/src/indicators/adl.rs @@ -0,0 +1,185 @@ +//! Accumulation/Distribution Line. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Accumulation/Distribution Line — Marc Chaikin's cumulative volume-flow +/// indicator. +/// +/// Each bar contributes a *money-flow volume*: the bar's volume weighted by +/// where the close fell within the bar's range. +/// +/// ```text +/// MFM_t = ((close − low) − (high − close)) / (high − low) (the money-flow multiplier, −1..+1) +/// MFV_t = MFM_t · volume_t +/// ADL_t = ADL_{t−1} + MFV_t +/// ``` +/// +/// A close near the high makes the multiplier near `+1` (accumulation), near +/// the low near `−1` (distribution). The running total is unbounded and drifts +/// with cumulative volume — what matters is its slope and its divergence from +/// price. A bar with `high == low` contributes `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Adl}; +/// +/// let mut indicator = Adl::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Adl { + total: f64, + has_emitted: bool, +} + +impl Adl { + /// Construct a new Accumulation/Distribution Line starting at zero. + pub const fn new() -> Self { + Self { + total: 0.0, + has_emitted: false, + } + } + + /// Current cumulative value if at least one candle has been ingested. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.total) + } else { + None + } + } +} + +impl Indicator for Adl { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let mfv = if range == 0.0 { + // A zero-range bar carries no positional information. + 0.0 + } else { + let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range; + mfm * candle.volume + }; + self.total += mfv; + self.has_emitted = true; + Some(self.total) + } + + fn reset(&mut self) { + self.total = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "ADL" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn reference_values() { + // bar 1: close at high -> MFM = +1 -> MFV = +100; ADL = 100. + // bar 2: h=12 l=8 c=9 -> MFM = ((9-8)-(12-9))/4 = -0.5 -> MFV = -100; + // ADL = 100 - 100 = 0. + let mut adl = Adl::new(); + let out = adl.batch(&[ + candle(8.0, 10.0, 8.0, 10.0, 100.0, 0), + candle(10.0, 12.0, 8.0, 9.0, 200.0, 1), + ]); + assert_relative_eq!(out[0].unwrap(), 100.0, epsilon = 1e-12); + assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12); + } + + #[test] + fn emits_from_first_candle() { + let mut adl = Adl::new(); + assert_eq!(adl.warmup_period(), 1); + assert!(adl.update(candle(8.0, 10.0, 8.0, 9.0, 50.0, 0)).is_some()); + } + + #[test] + fn close_at_high_accumulates_full_volume() { + // Every bar closes at its high: MFM = +1, so ADL grows by `volume`. + let mut adl = Adl::new(); + let mut expected = 0.0; + for i in 0..10 { + let c = candle(8.0, 10.0, 8.0, 10.0, 25.0, i); + expected += 25.0; + assert_relative_eq!(adl.update(c).unwrap(), expected, epsilon = 1e-9); + } + } + + #[test] + fn zero_range_bar_contributes_nothing() { + let mut adl = Adl::new(); + adl.update(candle(8.0, 10.0, 8.0, 10.0, 100.0, 0)); + let before = adl.value().unwrap(); + // A flat candle (high == low) adds zero. + let after = adl.update(candle(9.0, 9.0, 9.0, 9.0, 999.0, 1)).unwrap(); + assert_relative_eq!(after, before, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut adl = Adl::new(); + adl.batch(&[ + candle(8.0, 10.0, 8.0, 9.0, 100.0, 0), + candle(9.0, 11.0, 9.0, 10.0, 100.0, 1), + ]); + assert!(adl.is_ready()); + adl.reset(); + assert!(!adl.is_ready()); + assert_eq!(adl.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle( + mid, + mid + 2.0, + mid - 2.0, + mid + 0.5, + 10.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let batch = Adl::new().batch(&candles); + let mut b = Adl::new(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index de3ac42d..654f73d5 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -4,6 +4,7 @@ //! volume) but every public name is also re-exported flat from this module and //! from the crate root for convenience. +mod adl; mod adx; mod aroon; mod aroon_oscillator; @@ -47,12 +48,14 @@ mod tsi; mod ulcer_index; mod ultimate_oscillator; mod vortex; +mod vpt; mod vwap; mod vwma; mod williams_r; mod wma; mod zlema; +pub use adl::Adl; pub use adx::{Adx, AdxOutput}; pub use aroon::{Aroon, AroonOutput}; pub use aroon_oscillator::AroonOscillator; @@ -96,6 +99,7 @@ pub use tsi::Tsi; pub use ulcer_index::UlcerIndex; pub use ultimate_oscillator::UltimateOscillator; pub use vortex::{Vortex, VortexOutput}; +pub use vpt::VolumePriceTrend; pub use vwap::{RollingVwap, Vwap}; pub use vwma::Vwma; pub use williams_r::WilliamsR; diff --git a/crates/wickra-core/src/indicators/vpt.rs b/crates/wickra-core/src/indicators/vpt.rs new file mode 100644 index 00000000..6005811d --- /dev/null +++ b/crates/wickra-core/src/indicators/vpt.rs @@ -0,0 +1,179 @@ +//! Volume-Price Trend. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume-Price Trend — a cumulative volume line weighted by percentage price +/// change. +/// +/// VPT is a close relative of [`Obv`](crate::Obv), but instead of adding the +/// full bar volume on every up-close it adds volume scaled by the *size* of +/// the move: +/// +/// ```text +/// VPT_t = VPT_{t−1} + volume_t · (close_t − close_{t−1}) / close_{t−1} +/// ``` +/// +/// A big move on heavy volume moves the line far; a small move on the same +/// volume barely nudges it. The running total is unbounded — its slope and its +/// divergence from price are what carry the signal. The first bar establishes +/// the baseline at `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumePriceTrend}; +/// +/// let mut indicator = VolumePriceTrend::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct VolumePriceTrend { + prev_close: Option, + total: f64, + has_emitted: bool, +} + +impl VolumePriceTrend { + /// Construct a new Volume-Price Trend starting at zero. + pub const fn new() -> Self { + Self { + prev_close: None, + total: 0.0, + has_emitted: false, + } + } + + /// Current cumulative value if at least one candle has been ingested. + pub const fn value(&self) -> Option { + if self.has_emitted { + Some(self.total) + } else { + None + } + } +} + +impl Indicator for VolumePriceTrend { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let Some(prev) = self.prev_close else { + // The first candle establishes the baseline at 0. + self.prev_close = Some(candle.close); + return Some(self.total); + }; + let roc = if prev == 0.0 { + // Undefined ratio against a zero previous close. + 0.0 + } else { + (candle.close - prev) / prev + }; + self.total += candle.volume * roc; + self.prev_close = Some(candle.close); + Some(self.total) + } + + fn reset(&mut self) { + self.prev_close = None; + self.total = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "VPT" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn reference_values() { + // closes [10, 11, 9], volumes [100, 200, 300]: + // bar 1: baseline 0. + // bar 2: VPT += 200 · (11-10)/10 = 20 -> 20. + // bar 3: VPT += 300 · (9-11)/11 = -600/11 -> 20 - 600/11. + let mut vpt = VolumePriceTrend::new(); + let out = vpt.batch(&[ + candle(10.0, 100.0, 0), + candle(11.0, 200.0, 1), + candle(9.0, 300.0, 2), + ]); + assert_relative_eq!(out[0].unwrap(), 0.0, epsilon = 1e-12); + assert_relative_eq!(out[1].unwrap(), 20.0, epsilon = 1e-12); + assert_relative_eq!(out[2].unwrap(), 20.0 - 600.0 / 11.0, epsilon = 1e-12); + } + + #[test] + fn emits_from_first_candle_at_zero() { + let mut vpt = VolumePriceTrend::new(); + assert_eq!(vpt.warmup_period(), 1); + assert_eq!(vpt.update(candle(100.0, 50.0, 0)), Some(0.0)); + } + + #[test] + fn constant_close_keeps_line_flat() { + // No price change -> no contribution regardless of volume. + let mut vpt = VolumePriceTrend::new(); + let candles: Vec = (0..20).map(|i| candle(100.0, 500.0, i)).collect(); + for v in vpt.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut vpt = VolumePriceTrend::new(); + vpt.batch(&[ + candle(10.0, 100.0, 0), + candle(11.0, 100.0, 1), + candle(12.0, 100.0, 2), + ]); + assert!(vpt.is_ready()); + vpt.reset(); + assert!(!vpt.is_ready()); + assert_eq!(vpt.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .map(|i| { + candle( + 100.0 + (i as f64 * 0.3).sin() * 8.0, + 10.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let batch = VolumePriceTrend::new().batch(&candles); + let mut b = VolumePriceTrend::new(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index a490ea0d..fa50a017 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -44,12 +44,13 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ - Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AwesomeOscillator, BollingerBands, - BollingerBandwidth, BollingerOutput, Cci, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, - Ema, HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, MacdIndicator, MacdOutput, - MassIndex, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, - StdDev, StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix, Tsi, UlcerIndex, - UltimateOscillator, Vortex, VortexOutput, Vwap, Vwma, WilliamsR, Wma, Zlema, T3, + Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AwesomeOscillator, + BollingerBands, BollingerBandwidth, BollingerOutput, Cci, Cmo, Coppock, Dema, Donchian, + DonchianOutput, Dpo, Ema, HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, + MacdIndicator, MacdOutput, MassIndex, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, + RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix, + Tsi, UlcerIndex, UltimateOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, + WilliamsR, Wma, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 5d21ab8b..f836588d 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -129,6 +129,8 @@ Rust / Python / Node examples. They are grouped by family, mirroring the - [Indicator-Obv.md](indicators/volume/Indicator-Obv.md) - [Indicator-Vwap.md](indicators/volume/Indicator-Vwap.md) +- [Indicator-Adl.md](indicators/volume/Indicator-Adl.md) +- [Indicator-VolumePriceTrend.md](indicators/volume/Indicator-VolumePriceTrend.md) ## See also diff --git a/docs/wiki/Indicators-Overview.md b/docs/wiki/Indicators-Overview.md index 36d4bfb5..ae3f1193 100644 --- a/docs/wiki/Indicators-Overview.md +++ b/docs/wiki/Indicators-Overview.md @@ -1,6 +1,6 @@ # Indicators Overview -Wickra ships 48 indicators, organised in source under the four classical +Wickra ships 50 indicators, organised in source under the four classical families — trend, momentum, volatility, volume — that map directly to the directory structure of `crates/wickra-core/src/indicators/`. The same family labels are used here, plus a second-level grouping that reflects how the @@ -160,6 +160,8 @@ Volume indicators all take `Candle` input because they need `close` and |---------------|-----------|-------|--------|-------|----------|--------|-----------| | `Obv` | On-Balance Volume: cumulative signed volume driven by close-vs-prior-close sign. | `Candle` | `f64` | unbounded (drifts with cumulative volume) | (no parameters) | `1` | [Indicator-Obv.md](indicators/volume/Indicator-Obv.md) | | `Vwap` | Cumulative volume-weighted average price from the start of the stream (intraday reset is your responsibility). | `Candle` | `f64` | unbounded (price scale) | (no parameters) | `1` | [Indicator-Vwap.md](indicators/volume/Indicator-Vwap.md) | +| `Adl` | Accumulation/Distribution Line; cumulative range-weighted volume. | `Candle` | `f64` | unbounded (drifts with volume) | (no parameters) | `1` | [Indicator-Adl.md](indicators/volume/Indicator-Adl.md) | +| `VolumePriceTrend` | Cumulative `volume · ROC`; volume flow weighted by percentage move. | `Candle` | `f64` | unbounded (drifts with volume) | (no parameters) | `1` | [Indicator-VolumePriceTrend.md](indicators/volume/Indicator-VolumePriceTrend.md) | ### Rolling diff --git a/docs/wiki/indicators/volume/Indicator-Adl.md b/docs/wiki/indicators/volume/Indicator-Adl.md new file mode 100644 index 00000000..1d89debc --- /dev/null +++ b/docs/wiki/indicators/volume/Indicator-Adl.md @@ -0,0 +1,161 @@ +# ADL + +> Accumulation/Distribution Line — a cumulative volume-flow line that +> weights each bar's volume by where its close fell within the range. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volume | +| Sub-category | Cumulative | +| Input type | `Candle` (uses `high`, `low`, `close`, `volume`) | +| Output type | `f64` | +| Output range | unbounded (drifts with cumulative volume) | +| Default parameters | none (no parameters) | +| Warmup period | `1` | +| Interpretation | Running buying/selling pressure; slope and divergence matter. | + +## Formula + +``` +MFM_t = ((close − low) − (high − close)) / (high − low) (money-flow multiplier, −1..+1) +MFV_t = MFM_t · volume_t (money-flow volume) +ADL_t = ADL_{t−1} + MFV_t +``` + +The money-flow multiplier asks *where in the bar's range did price +close?* A close on the high gives `+1` (full accumulation), on the low +`−1` (full distribution), in the middle `0`. Scaling by volume and +running the cumulative total gives a line whose **slope** reflects +sustained buying or selling pressure. A bar with `high == low` carries no +positional information and contributes `0`. + +## Parameters + +`ADL` takes **no parameters** — `Adl::new()` in Rust, `wickra.ADL()` in +Python, `new ta.ADL()` in Node. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/adl.rs`: + +```rust +impl Indicator for Adl { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`ADL` is a **candle-input** indicator: it reads `high`, `low`, `close` and +`volume`. In Python the streaming `update` accepts a 6-tuple or a dict; +the batch helper takes `high`, `low`, `close`, `volume` numpy arrays. Node +and WASM expose `update(high, low, close, volume)` and the matching +`batch`. + +## Warmup + +`Adl::new().warmup_period() == 1`. ADL is cumulative — it emits a value +from the very first candle. + +## Edge cases + +- **Zero-range bar.** A bar with `high == low` contributes `0` to the line + (`zero_range_bar_contributes_nothing` pins this). +- **Close at the high.** Every bar closing on its high has `MFM = +1`, so + ADL grows by exactly `volume` each bar + (`close_at_high_accumulates_full_volume` pins this). +- **Candle validation.** `Candle::new` rejects invalid bars upstream. +- **Reset.** `adl.reset()` returns the running total to `0`. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, Adl}; + +fn main() -> Result<(), Box> { + let mut adl = Adl::new(); + let out = adl.batch(&[ + Candle::new(8.0, 10.0, 8.0, 10.0, 100.0, 0)?, // close at high + Candle::new(10.0, 12.0, 8.0, 9.0, 200.0, 1)?, + ]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[Some(100.0), Some(0.0)] +``` + +Bar 1 closes at its high (`MFM = +1`), adding `+100`. Bar 2 has +`MFM = ((9−8)−(12−9))/4 = −0.5`, adding `−100`, so the line returns to +`0`. This matches the `reference_values` test in +`crates/wickra-core/src/indicators/adl.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +adl = ta.ADL() +high = np.array([10.0, 12.0]) +low = np.array([8.0, 8.0]) +close = np.array([10.0, 9.0]) +volume = np.array([100.0, 200.0]) +print(adl.batch(high, low, close, volume)) +``` + +Output: + +``` +[100. 0.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const adl = new ta.ADL(); +console.log(adl.batch([10, 12], [8, 8], [10, 9], [100, 200])); +``` + +Output: + +``` +[ 100, 0 ] +``` + +## Interpretation + +`Adl` is read by slope and by divergence, never by absolute level (the +total drifts arbitrarily with cumulative volume). A rising ADL confirms +that an up-move is backed by accumulation; a *falling* ADL while price +rises is a bearish divergence — the rally is not being bought into. +[`ChaikinOscillator`](Indicator-ChaikinOscillator.md) is the standard way +to turn the ADL into a bounded, tradeable oscillator. + +## Common pitfalls + +- **Reading the absolute value.** Only the slope and divergences are + meaningful; the level depends on where you started the stream. +- **Feeding it scalar prices.** It needs the full OHLCV bar. + +## References + +Marc Chaikin's Accumulation/Distribution Line; the money-flow-multiplier +formulation here matches the standard definition (StockCharts, TA-Lib's +`AD`). + +## See also + +- [Indicator-Obv.md](Indicator-Obv.md) — cumulative *signed* volume. +- [Indicator-ChaikinOscillator.md](Indicator-ChaikinOscillator.md) — an + oscillator built on the ADL. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volume/Indicator-VolumePriceTrend.md b/docs/wiki/indicators/volume/Indicator-VolumePriceTrend.md new file mode 100644 index 00000000..498abc31 --- /dev/null +++ b/docs/wiki/indicators/volume/Indicator-VolumePriceTrend.md @@ -0,0 +1,161 @@ +# VolumePriceTrend + +> Volume-Price Trend (VPT) — a cumulative volume line where each bar's +> contribution is scaled by its percentage price change. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volume | +| Sub-category | Cumulative | +| Input type | `Candle` (uses `close`, `volume`) | +| Output type | `f64` | +| Output range | unbounded (drifts with cumulative volume) | +| Default parameters | none (no parameters) | +| Warmup period | `1` | +| Interpretation | Running volume flow; slope and divergence matter. | + +## Formula + +``` +VPT_t = VPT_{t−1} + volume_t · (close_t − close_{t−1}) / close_{t−1} +``` + +VPT is a close relative of [`Obv`](Indicator-Obv.md). Where OBV adds the +*entire* bar volume on any up-close, VPT adds volume scaled by the **size** +of the move: a 2 % gain on a given volume moves the line twice as far as a +1 % gain on the same volume. That makes VPT more sensitive to the +conviction behind a move. The first bar establishes the baseline at `0`. + +## Parameters + +`VolumePriceTrend` takes **no parameters** — `VolumePriceTrend::new()` in +Rust, `wickra.VolumePriceTrend()` in Python, `new ta.VolumePriceTrend()` +in Node. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/vpt.rs`: + +```rust +impl Indicator for VolumePriceTrend { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`VolumePriceTrend` is a **candle-input** indicator: it reads `close` and +`volume`. In Python the streaming `update` accepts a 6-tuple or a dict; +the batch helper takes `close` and `volume` numpy arrays. Node and WASM +expose `update(close, volume)` and `batch(close, volume)`. + +## Warmup + +`warmup_period() == 1`. VPT is cumulative — it emits the baseline `0` from +the first candle, then accumulates from the second onward. + +## Edge cases + +- **Constant close.** With no price change every bar contributes `0`, so + the line stays flat regardless of volume + (`constant_close_keeps_line_flat` pins this). +- **First bar.** The first candle has no previous close; VPT emits the + baseline `0.0` (`emits_from_first_candle_at_zero` pins this). +- **Zero previous close.** A percentage change against a `0.0` prior + close is undefined and is treated as `0`. +- **Candle validation.** `Candle::new` rejects invalid bars upstream. +- **Reset.** `vpt.reset()` returns the running total to `0`. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, VolumePriceTrend}; + +fn main() -> Result<(), Box> { + let mut vpt = VolumePriceTrend::new(); + // closes 10 -> 11 -> 9, volumes 100, 200, 300. + let out = vpt.batch(&[ + Candle::new(10.0, 10.0, 10.0, 10.0, 100.0, 0)?, + Candle::new(11.0, 11.0, 11.0, 11.0, 200.0, 1)?, + Candle::new(9.0, 9.0, 9.0, 9.0, 300.0, 2)?, + ]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[Some(0.0), Some(20.0), Some(-34.54545454545455)] +``` + +Bar 1 is the baseline `0`. Bar 2 adds `200 · (11−10)/10 = 20`. Bar 3 adds +`300 · (9−11)/11 = −600/11`, leaving `20 − 600/11 ≈ −34.545`. This matches +the `reference_values` test in `crates/wickra-core/src/indicators/vpt.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +vpt = ta.VolumePriceTrend() +close = np.array([10.0, 11.0, 9.0]) +volume = np.array([100.0, 200.0, 300.0]) +print(vpt.batch(close, volume)) +``` + +Output: + +``` +[ 0. 20. -34.54545455] +``` + +### Node + +```javascript +const ta = require('wickra'); +const vpt = new ta.VolumePriceTrend(); +console.log(vpt.batch([10, 11, 9], [100, 200, 300])); +``` + +Output: + +``` +[ 0, 20, -34.54545454545455 ] +``` + +## Interpretation + +`VolumePriceTrend` is read like OBV — by **slope** and by **divergence**, +never by absolute level. A VPT rising in step with price confirms the +trend is volume-supported; VPT flattening or falling while price climbs +is a bearish divergence warning that the move lacks participation. Versus +OBV, VPT gives proportionally more weight to large moves and less to a +string of tiny up-closes, so it tracks the *magnitude* of conviction, not +just its direction. + +## Common pitfalls + +- **Reading the absolute value.** Only slope and divergences carry + meaning; the level depends on the stream's start point. +- **Expecting OBV-identical behaviour.** VPT scales by percentage change, + so the two lines diverge — especially across large single-bar moves. + +## References + +The Volume-Price Trend (also "Price-Volume Trend") is a standard +cumulative volume study; the `volume · ROC` accumulation here matches the +common definition. + +## See also + +- [Indicator-Obv.md](Indicator-Obv.md) — cumulative signed volume, the + closest relative. +- [Indicator-Adl.md](Indicator-Adl.md) — cumulative range-weighted volume. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.