diff --git a/bindings/node/index.js b/bindings/node/index.js index b7af04e7..624d9ad4 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, 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, 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 @@ -324,6 +324,9 @@ module.exports.ROC = ROC module.exports.TRIX = TRIX module.exports.SMMA = SMMA module.exports.TRIMA = TRIMA +module.exports.ZLEMA = ZLEMA +module.exports.T3 = T3 +module.exports.VWMA = VWMA 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 e208526a..b08d696a 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -104,6 +104,7 @@ node_scalar_indicator!(RocNode, "ROC", wc::Roc); node_scalar_indicator!(TrixNode, "TRIX", wc::Trix); node_scalar_indicator!(SmmaNode, "SMMA", wc::Smma); node_scalar_indicator!(TrimaNode, "TRIMA", wc::Trima); +node_scalar_indicator!(ZlemaNode, "ZLEMA", wc::Zlema); // ============================== MACD ============================== @@ -1027,3 +1028,90 @@ impl KamaNode { flatten(self.inner.batch(&prices)) } } + +// ============================== T3 ============================== + +#[napi(js_name = "T3")] +pub struct T3Node { + inner: wc::T3, +} + +#[napi] +impl T3Node { + #[napi(constructor)] + pub fn new(period: u32, v: f64) -> napi::Result { + Ok(Self { + inner: wc::T3::new(period as usize, v).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[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 + } +} + +// ============================== VWMA ============================== + +#[napi(js_name = "VWMA")] +pub struct VwmaNode { + inner: wc::Vwma, +} + +#[napi] +impl VwmaNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::Vwma::new(period as usize).map_err(map_err)?, + }) + } + #[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 + } +} diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index 0f8aeec6..35dac190 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -76,6 +76,50 @@ class TRIMA: @property def value(self) -> Optional[float]: ... +class ZLEMA: + def __init__(self, period: int) -> None: ... + def update(self, value: float) -> Optional[float]: ... + def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def period(self) -> int: ... + @property + def lag(self) -> int: ... + @property + def value(self) -> Optional[float]: ... + +class T3: + def __init__(self, period: int, v: float = 0.7) -> None: ... + def update(self, value: float) -> Optional[float]: ... + def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def period(self) -> int: ... + @property + def volume_factor(self) -> float: ... + @property + def value(self) -> Optional[float]: ... + +class VWMA: + def __init__(self, period: int) -> 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 period(self) -> int: ... + @property + def value(self) -> Optional[float]: ... + class RSI: def __init__(self, period: int = 14) -> None: ... def update(self, value: float) -> Optional[float]: ... diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index b5328d44..2e84768d 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1519,6 +1519,188 @@ impl PyAroon { } } +// ============================== ZLEMA ============================== + +#[pyclass(name = "ZLEMA", module = "wickra._wickra")] +#[derive(Clone)] +struct PyZlema { + inner: wc::Zlema, +} + +#[pymethods] +impl PyZlema { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Zlema::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn lag(&self) -> usize { + self.inner.lag() + } + #[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 { + format!("ZLEMA(period={})", self.inner.period()) + } +} + +// ============================== T3 ============================== + +#[pyclass(name = "T3", module = "wickra._wickra")] +#[derive(Clone)] +struct PyT3 { + inner: wc::T3, +} + +#[pymethods] +impl PyT3 { + #[new] + #[pyo3(signature = (period, v=0.7))] + fn new(period: usize, v: f64) -> PyResult { + Ok(Self { + inner: wc::T3::new(period, v).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn volume_factor(&self) -> f64 { + self.inner.volume_factor() + } + #[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 { + format!( + "T3(period={}, v={})", + self.inner.period(), + self.inner.volume_factor() + ) + } +} + +// ============================== VWMA ============================== + +#[pyclass(name = "VWMA", module = "wickra._wickra")] +#[derive(Clone)] +struct PyVwma { + inner: wc::Vwma, +} + +#[pymethods] +impl PyVwma { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Vwma::new(period).map_err(map_err)?, + }) + } + 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 period(&self) -> usize { + self.inner.period() + } + #[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 { + format!("VWMA(period={})", self.inner.period()) + } +} + // ============================== SMMA ============================== #[pyclass(name = "SMMA", module = "wickra._wickra")] @@ -1653,5 +1835,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; 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 f7afcbb8..03088dfc 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -77,6 +77,8 @@ wasm_scalar_indicator!(WasmRoc, "ROC", wc::Roc, period: usize); wasm_scalar_indicator!(WasmTrix, "TRIX", wc::Trix, period: usize); wasm_scalar_indicator!(WasmSmma, "SMMA", wc::Smma, period: usize); wasm_scalar_indicator!(WasmTrima, "TRIMA", wc::Trima, period: usize); +wasm_scalar_indicator!(WasmZlema, "ZLEMA", wc::Zlema, period: usize); +wasm_scalar_indicator!(WasmT3, "T3", wc::T3, period: usize, v: f64); // ---------- KAMA (three params) ---------- @@ -324,6 +326,39 @@ impl WasmObv { } } +#[wasm_bindgen(js_name = VWMA)] +pub struct WasmVwma { + inner: wc::Vwma, +} + +#[wasm_bindgen(js_class = VWMA)] +impl WasmVwma { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::Vwma::new(period).map_err(map_err)?, + }) + } + 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 = ADX)] pub struct WasmAdx { inner: wc::Adx, diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 62ab9f78..f8e8ebdb 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -25,12 +25,15 @@ mod rsi; mod sma; mod smma; mod stochastic; +mod t3; mod tema; mod trima; mod trix; mod vwap; +mod vwma; mod williams_r; mod wma; +mod zlema; pub use adx::{Adx, AdxOutput}; pub use aroon::{Aroon, AroonOutput}; @@ -53,9 +56,12 @@ pub use rsi::Rsi; pub use sma::Sma; pub use smma::Smma; pub use stochastic::{Stochastic, StochasticOutput}; +pub use t3::T3; pub use tema::Tema; pub use trima::Trima; pub use trix::Trix; pub use vwap::{RollingVwap, Vwap}; +pub use vwma::Vwma; pub use williams_r::WilliamsR; pub use wma::Wma; +pub use zlema::Zlema; diff --git a/crates/wickra-core/src/indicators/t3.rs b/crates/wickra-core/src/indicators/t3.rs new file mode 100644 index 00000000..574de795 --- /dev/null +++ b/crates/wickra-core/src/indicators/t3.rs @@ -0,0 +1,261 @@ +//! Tillson T3 Moving Average. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::Ema; + +/// Tillson's T3 — a six-fold cascaded EMA recombined with a *volume factor* `v`. +/// +/// T3 is the generalised DEMA applied three times. Tim Tillson's expansion of +/// that triple application over six chained EMAs (`e1 … e6`, each of the same +/// `period`) gives the closed form used here: +/// +/// ```text +/// c1 = −v³ +/// c2 = 3v² + 3v³ +/// c3 = −6v² − 3v − 3v³ +/// c4 = 1 + 3v + v³ + 3v² +/// T3 = c1·e6 + c2·e5 + c3·e4 + c4·e3 +/// ``` +/// +/// The volume factor `v ∈ [0, 1]` controls the lag/smoothness trade-off: +/// `v = 0` collapses T3 to the plain triple-cascaded EMA `e3`, while the +/// conventional `v = 0.7` adds a hump that sharpens the response to turns. +/// The coefficients always sum to `1`, so a constant series maps to itself. +/// +/// The first output lands after `6·period − 5` inputs — the index at which the +/// sixth cascaded EMA seeds. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, T3}; +/// +/// let mut indicator = T3::new(5, 0.7).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct T3 { + period: usize, + v: f64, + c1: f64, + c2: f64, + c3: f64, + c4: f64, + e1: Ema, + e2: Ema, + e3: Ema, + e4: Ema, + e5: Ema, + e6: Ema, + current: Option, +} + +impl T3 { + /// Construct a new T3 with the given `period` and volume factor `v`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`, or + /// [`Error::InvalidPeriod`] if `v` is non-finite or outside `[0.0, 1.0]`. + pub fn new(period: usize, v: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !v.is_finite() || !(0.0..=1.0).contains(&v) { + return Err(Error::InvalidPeriod { + message: "T3 volume factor must be a finite value in [0.0, 1.0]", + }); + } + let v2 = v * v; + let v3 = v2 * v; + Ok(Self { + period, + v, + c1: -v3, + c2: 3.0 * v2 + 3.0 * v3, + c3: -6.0 * v2 - 3.0 * v - 3.0 * v3, + c4: 1.0 + 3.0 * v + v3 + 3.0 * v2, + e1: Ema::new(period)?, + e2: Ema::new(period)?, + e3: Ema::new(period)?, + e4: Ema::new(period)?, + e5: Ema::new(period)?, + e6: Ema::new(period)?, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured volume factor `v`. + pub const fn volume_factor(&self) -> f64 { + self.v + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for T3 { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; the cascade is not advanced. + return self.current; + } + let e1 = self.e1.update(input)?; + let e2 = self.e2.update(e1)?; + let e3 = self.e3.update(e2)?; + let e4 = self.e4.update(e3)?; + let e5 = self.e5.update(e4)?; + let e6 = self.e6.update(e5)?; + let out = self.c1 * e6 + self.c2 * e5 + self.c3 * e4 + self.c4 * e3; + self.current = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.e1.reset(); + self.e2.reset(); + self.e3.reset(); + self.e4.reset(); + self.e5.reset(); + self.e6.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + 6 * self.period - 5 + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "T3" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(T3::new(0, 0.7), Err(Error::PeriodZero))); + } + + #[test] + fn new_rejects_out_of_range_volume_factor() { + assert!(matches!(T3::new(5, -0.1), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(T3::new(5, 1.5), Err(Error::InvalidPeriod { .. }))); + assert!(matches!( + T3::new(5, f64::NAN), + Err(Error::InvalidPeriod { .. }) + )); + assert!(T3::new(5, 0.0).is_ok()); + assert!(T3::new(5, 1.0).is_ok()); + } + + #[test] + fn coefficients_sum_to_one() { + // c1 + c2 + c3 + c4 == 1 for any v, so a constant series is preserved. + for &v in &[0.0, 0.3, 0.7, 1.0] { + let t3 = T3::new(5, v).unwrap(); + assert_relative_eq!(t3.c1 + t3.c2 + t3.c3 + t3.c4, 1.0, epsilon = 1e-12); + } + } + + #[test] + fn first_emission_at_warmup_period() { + let mut t3 = T3::new(4, 0.7).unwrap(); + assert_eq!(t3.warmup_period(), 6 * 4 - 5); + let out = t3.batch(&(1..=60).map(f64::from).collect::>()); + for v in out.iter().take(t3.warmup_period() - 1) { + assert!(v.is_none()); + } + assert!(out[t3.warmup_period() - 1].is_some()); + } + + #[test] + fn constant_series_yields_the_constant() { + let mut t3 = T3::new(6, 0.7).unwrap(); + let out = t3.batch(&[50.0; 80]); + let last = out.iter().rev().flatten().next().unwrap(); + assert_relative_eq!(*last, 50.0, epsilon = 1e-9); + } + + #[test] + fn zero_volume_factor_collapses_to_triple_cascaded_ema() { + // With v = 0 the coefficients are c1=c2=c3=0, c4=1, so T3 == e3, + // the third stage of the EMA cascade. + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 9.0) + .collect(); + let mut t3 = T3::new(5, 0.0).unwrap(); + let got = t3.batch(&prices); + + let mut e1 = Ema::new(5).unwrap(); + let mut e2 = Ema::new(5).unwrap(); + let mut e3 = Ema::new(5).unwrap(); + let want: Vec> = prices + .iter() + .map(|p| { + e1.update(*p) + .and_then(|a| e2.update(a)) + .and_then(|b| e3.update(b)) + }) + .collect(); + + for i in (t3.warmup_period() - 1)..prices.len() { + assert_relative_eq!(got[i].unwrap(), want[i].unwrap(), epsilon = 1e-9); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut t3 = T3::new(4, 0.7).unwrap(); + let out = t3.batch(&(1..=60).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(t3.update(f64::NAN), last); + assert_eq!(t3.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut t3 = T3::new(4, 0.7).unwrap(); + t3.batch(&(1..=60).map(f64::from).collect::>()); + assert!(t3.is_ready()); + t3.reset(); + assert!(!t3.is_ready()); + assert_eq!(t3.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 7.0) + .collect(); + let batch = T3::new(7, 0.7).unwrap().batch(&prices); + let mut b = T3::new(7, 0.7).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/vwma.rs b/crates/wickra-core/src/indicators/vwma.rs new file mode 100644 index 00000000..39e1e824 --- /dev/null +++ b/crates/wickra-core/src/indicators/vwma.rs @@ -0,0 +1,238 @@ +//! Volume-Weighted Moving Average. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume-Weighted Moving Average over a rolling window of `period` candles. +/// +/// Each close is weighted by its own bar volume: +/// +/// ```text +/// VWMA_t = Σ(close_i · volume_i) / Σ(volume_i) over the last `period` bars +/// ``` +/// +/// High-volume bars pull the average toward their close, so VWMA reacts to +/// price moves that the market actually participated in and largely ignores +/// thin, low-conviction bars. +/// +/// If every candle in the window has zero volume the weighted mean is +/// undefined; the indicator then falls back to the **unweighted** mean of the +/// `period` closes, so the output is always finite. The first output lands +/// after exactly `period` candles. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Vwma}; +/// +/// let mut indicator = Vwma::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 1.0, p - 1.0, p, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Vwma { + period: usize, + /// Rolling window of `(close, volume)` pairs, oldest at the front. + window: VecDeque<(f64, f64)>, + sum_pv: f64, + sum_v: f64, + sum_close: f64, + current: Option, +} + +impl Vwma { + /// Construct a new VWMA with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum_pv: 0.0, + sum_v: 0.0, + sum_close: 0.0, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Vwma { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let close = candle.close; + let volume = candle.volume; + if self.window.len() == self.period { + let (old_close, old_volume) = self.window.pop_front().expect("window is non-empty"); + self.sum_pv -= old_close * old_volume; + self.sum_v -= old_volume; + self.sum_close -= old_close; + } + self.window.push_back((close, volume)); + self.sum_pv += close * volume; + self.sum_v += volume; + self.sum_close += close; + if self.window.len() < self.period { + return None; + } + let value = if self.sum_v > 0.0 { + self.sum_pv / self.sum_v + } else { + // Degenerate window: every bar had zero volume. Fall back to the + // plain mean of the closes so the output stays finite. + self.sum_close / self.period as f64 + }; + self.current = Some(value); + Some(value) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum_pv = 0.0; + self.sum_v = 0.0; + self.sum_close = 0.0; + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "VWMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Build a flat candle with a given close and volume. + fn candle(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Vwma::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn reference_value() { + // VWMA(2): (10·1 + 20·3) / (1 + 3) = 70 / 4 = 17.5. + let mut vwma = Vwma::new(2).unwrap(); + assert_eq!(vwma.update(candle(10.0, 1.0, 0)), None); + assert_relative_eq!( + vwma.update(candle(20.0, 3.0, 1)).unwrap(), + 17.5, + epsilon = 1e-12 + ); + // Window slides: (20·3 + 30·1) / (3 + 1) = 90 / 4 = 22.5. + assert_relative_eq!( + vwma.update(candle(30.0, 1.0, 2)).unwrap(), + 22.5, + epsilon = 1e-12 + ); + } + + #[test] + fn zero_volume_window_falls_back_to_unweighted_mean() { + let mut vwma = Vwma::new(2).unwrap(); + assert_eq!(vwma.update(candle(10.0, 0.0, 0)), None); + // Both bars have zero volume: fall back to mean(10, 20) = 15. + assert_relative_eq!( + vwma.update(candle(20.0, 0.0, 1)).unwrap(), + 15.0, + epsilon = 1e-12 + ); + } + + #[test] + fn constant_series_yields_the_constant() { + let mut vwma = Vwma::new(5).unwrap(); + let candles: Vec = (0..30).map(|i| candle(42.0, 3.0, i)).collect(); + let out = vwma.batch(&candles); + for x in out.iter().skip(4).flatten() { + assert_relative_eq!(*x, 42.0, epsilon = 1e-12); + } + } + + #[test] + fn high_volume_bar_pulls_the_average() { + // A heavy bar at a higher close drags VWMA above the simple mean. + let mut vwma = Vwma::new(3).unwrap(); + vwma.update(candle(10.0, 1.0, 0)); + vwma.update(candle(10.0, 1.0, 1)); + let v = vwma.update(candle(20.0, 100.0, 2)).unwrap(); + let simple_mean = (10.0 + 10.0 + 20.0) / 3.0; + assert!( + v > simple_mean, + "{v} should exceed simple mean {simple_mean}" + ); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut vwma = Vwma::new(4).unwrap(); + assert_eq!(vwma.warmup_period(), 4); + for i in 0..3 { + assert_eq!(vwma.update(candle(10.0, 1.0, i)), None); + } + assert!(vwma.update(candle(10.0, 1.0, 3)).is_some()); + } + + #[test] + fn reset_clears_state() { + let mut vwma = Vwma::new(3).unwrap(); + let candles: Vec = (0..10).map(|i| candle(10.0 + i as f64, 2.0, i)).collect(); + vwma.batch(&candles); + assert!(vwma.is_ready()); + vwma.reset(); + assert!(!vwma.is_ready()); + assert_eq!(vwma.update(candle(10.0, 1.0, 0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50_i64) + .map(|i| { + let c = 100.0 + (i as f64 * 0.3).sin() * 8.0; + candle(c, 1.0 + (i % 7) as f64, i) + }) + .collect(); + let batch = Vwma::new(8).unwrap().batch(&candles); + let mut b = Vwma::new(8).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/zlema.rs b/crates/wickra-core/src/indicators/zlema.rs new file mode 100644 index 00000000..d3bdc7ed --- /dev/null +++ b/crates/wickra-core/src/indicators/zlema.rs @@ -0,0 +1,189 @@ +//! Zero-Lag Exponential Moving Average. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::Ema; + +/// Zero-Lag Exponential Moving Average (Ehlers & Way). +/// +/// A standard EMA applied to a *de-lagged* price series. The de-lagged input +/// is `2·price_t − price_{t−lag}` with `lag = (period − 1) / 2`; adding that +/// momentum term to the current price cancels most of the EMA's group delay, +/// so the average tracks turns far more tightly than a plain [`Ema`]. +/// +/// The first output lands after exactly `lag + period` inputs: `lag` inputs +/// are needed before the de-lagged series is defined, then `period` de-lagged +/// values seed the inner EMA. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Zlema}; +/// +/// let mut indicator = Zlema::new(10).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Zlema { + period: usize, + lag: usize, + /// Rolling buffer of the last `lag + 1` raw inputs, oldest at the front. + window: VecDeque, + ema: Ema, +} + +impl Zlema { + /// Construct a new ZLEMA with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let lag = (period - 1) / 2; + Ok(Self { + period, + lag, + window: VecDeque::with_capacity(lag + 1), + ema: Ema::new(period)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Lag offset `(period − 1) / 2` used to de-lag the price series. + pub const fn lag(&self) -> usize { + self.lag + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.ema.value() + } +} + +impl Indicator for Zlema { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; state is left untouched. + return self.ema.value(); + } + if self.window.len() == self.lag + 1 { + self.window.pop_front(); + } + self.window.push_back(input); + if self.window.len() < self.lag + 1 { + return None; + } + let lagged = *self.window.front().expect("window is non-empty"); + let de_lagged = 2.0f64.mul_add(input, -lagged); + self.ema.update(de_lagged) + } + + fn reset(&mut self) { + self.window.clear(); + self.ema.reset(); + } + + fn warmup_period(&self) -> usize { + self.lag + self.period + } + + fn is_ready(&self) -> bool { + self.ema.is_ready() + } + + fn name(&self) -> &'static str { + "ZLEMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Zlema::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn lag_is_half_of_period_minus_one() { + assert_eq!(Zlema::new(3).unwrap().lag(), 1); + assert_eq!(Zlema::new(10).unwrap().lag(), 4); + assert_eq!(Zlema::new(1).unwrap().lag(), 0); + } + + #[test] + fn reference_values() { + // ZLEMA(3): lag = 1, de_lagged_t = 2·xt − x_{t-1}, then EMA(3). + // [1,2,3,4,5] -> de-lagged [_, 3, 4, 5, 6]; EMA(3) seeds at the third + // de-lagged value: mean(3,4,5) = 4.0; next = 0.5·6 + 0.5·4 = 5.0. + let mut zlema = Zlema::new(3).unwrap(); + let out = zlema.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert_eq!(zlema.warmup_period(), 4); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_eq!(out[2], None); + assert_relative_eq!(out[3].unwrap(), 4.0, epsilon = 1e-12); + assert_relative_eq!(out[4].unwrap(), 5.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_the_constant() { + // De-lagging a constant gives the same constant (2c − c = c). + let mut zlema = Zlema::new(7).unwrap(); + let out = zlema.batch(&[33.0; 60]); + for x in out.iter().skip(zlema.warmup_period() - 1).flatten() { + assert_relative_eq!(*x, 33.0, epsilon = 1e-9); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut zlema = Zlema::new(3).unwrap(); + let out = zlema.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let last = out[4]; + assert!(last.is_some()); + assert_eq!(zlema.update(f64::NAN), last); + assert_eq!(zlema.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut zlema = Zlema::new(5).unwrap(); + zlema.batch(&(1..=40).map(f64::from).collect::>()); + assert!(zlema.is_ready()); + zlema.reset(); + assert!(!zlema.is_ready()); + assert_eq!(zlema.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=60) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0) + .collect(); + let batch = Zlema::new(9).unwrap().batch(&prices); + let mut b = Zlema::new(9).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index b06960aa..6b59c999 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -47,7 +47,7 @@ pub use indicators::{ Adx, AdxOutput, Aroon, AroonOutput, Atr, AwesomeOscillator, BollingerBands, BollingerOutput, Cci, Dema, Donchian, DonchianOutput, Ema, Hma, Kama, Keltner, KeltnerOutput, MacdIndicator, MacdOutput, Mfi, Obv, Psar, Roc, RollingVwap, Rsi, Sma, Smma, Stochastic, StochasticOutput, - Tema, Trima, Trix, Vwap, WilliamsR, Wma, + Tema, Trima, Trix, 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 7e219e14..2c9111ab 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -81,6 +81,9 @@ Rust / Python / Node examples. They are grouped by family, mirroring the - [Indicator-Kama.md](indicators/trend/Indicator-Kama.md) - [Indicator-Smma.md](indicators/trend/Indicator-Smma.md) - [Indicator-Trima.md](indicators/trend/Indicator-Trima.md) +- [Indicator-Zlema.md](indicators/trend/Indicator-Zlema.md) +- [Indicator-T3.md](indicators/trend/Indicator-T3.md) +- [Indicator-Vwma.md](indicators/trend/Indicator-Vwma.md) **Momentum** — measure the rate of price change rather than the level. diff --git a/docs/wiki/Indicators-Overview.md b/docs/wiki/Indicators-Overview.md index 720073a0..388346e2 100644 --- a/docs/wiki/Indicators-Overview.md +++ b/docs/wiki/Indicators-Overview.md @@ -1,6 +1,6 @@ # Indicators Overview -Wickra ships 27 indicators, organised in source under the four classical +Wickra ships 30 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 @@ -36,6 +36,7 @@ benchmarks against fancier averages. | `Sma` | Equal-weighted rolling mean over `period` closes. | `f64` | `f64` | unbounded (price scale) | `period` (no default in core; Python defaults vary by binding) | `period` | [Indicator-Sma.md](indicators/trend/Indicator-Sma.md) | | `Wma` | Linear weights `1, 2, …, period` so the newest bar matters most. | `f64` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Wma.md](indicators/trend/Indicator-Wma.md) | | `Trima` | A `period`-window SMA applied twice; triangular weights centred on the middle bar. | `f64` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Trima.md](indicators/trend/Indicator-Trima.md) | +| `Vwma` | Rolling mean of closes weighted by each bar's volume. | `Candle` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Vwma.md](indicators/trend/Indicator-Vwma.md) | ### Exponential family @@ -48,6 +49,8 @@ you stack more EMAs, but so does responsiveness to noise. | `Dema` | Mulloy's `2·EMA − EMA(EMA)`; removes first-order EMA lag. | `f64` | `f64` | unbounded (price scale) | `period` | `2·period − 1` | [Indicator-Dema.md](indicators/trend/Indicator-Dema.md) | | `Tema` | Mulloy's `3·EMA − 3·EMA(EMA) + EMA(EMA(EMA))`; removes more lag than DEMA. | `f64` | `f64` | unbounded (price scale) | `period` | `3·period − 2` | [Indicator-Tema.md](indicators/trend/Indicator-Tema.md) | | `Smma` | Wilder's RMA: an SMA-seeded exponential average with the slow `1/period` factor. | `f64` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Smma.md](indicators/trend/Indicator-Smma.md) | +| `Zlema` | EMA of the de-lagged series `2·price − price[lag]`; near-zero group delay. | `f64` | `f64` | unbounded (price scale) | `period` | `lag + period` | [Indicator-Zlema.md](indicators/trend/Indicator-Zlema.md) | +| `T3` | Tillson's six-EMA cascade recombined with a volume factor `v`. | `f64` | `f64` | unbounded (price scale) | `(period, v=0.7)` (Python) | `6·period − 5` | [Indicator-T3.md](indicators/trend/Indicator-T3.md) | `Trix` is also built from a triple-smoothed EMA, but it is a *momentum oscillator* — it emits the rate of change of that EMA, not a price-scale diff --git a/docs/wiki/indicators/trend/Indicator-T3.md b/docs/wiki/indicators/trend/Indicator-T3.md new file mode 100644 index 00000000..98ea380f --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-T3.md @@ -0,0 +1,172 @@ +# T3 + +> Tillson T3 — a six-fold cascaded EMA recombined with a volume factor `v` +> to give a smooth, low-lag trend line. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Exponential family | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | `period` required; `v = 0.7` (Python default) | +| Warmup period | `6·period − 5` | +| Interpretation | Smooth trend line with less lag than a same-period EMA. | + +## Formula + +T3 is the *generalised DEMA* (`GD`) applied three times. Tim Tillson's +expansion of `GD(GD(GD(price)))` over six chained EMAs — `e1 … e6`, each +of the same `period`, where `e2 = EMA(e1)`, `e3 = EMA(e2)`, … — is: + +``` +v2 = v², v3 = v³ +c1 = −v3 +c2 = 3·v2 + 3·v3 +c3 = −6·v2 − 3·v − 3·v3 +c4 = 1 + 3·v + v3 + 3·v2 +T3 = c1·e6 + c2·e5 + c3·e4 + c4·e3 +``` + +The four coefficients always sum to `1`, so a constant price series maps +to itself. The volume factor `v` controls the lag/overshoot trade-off: +`v = 0` collapses T3 to the plain triple-cascaded EMA `e3`; the +conventional `v = 0.7` adds a corrective hump that sharpens turns. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|----------------|-------------|-------------| +| `period` | `usize` | none | `>= 1` | Length of every EMA in the cascade. `period = 0` errors with `Error::PeriodZero`. | +| `v` | `f64` | `0.7` (Python) | `[0.0, 1.0]`| Volume factor. Non-finite or out-of-range values error with `Error::InvalidPeriod`. | + +The Python binding defaults `v` to `0.7` via `#[pyo3(signature = (period, v=0.7))]`; +`period` is always explicit. The Node and WASM constructors take both +arguments explicitly. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/t3.rs`: + +```rust +impl Indicator for T3 { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +A single `f64` close in, an `Option` out. Python maps this to +`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` / +`Array` (NaN warmup). + +## Warmup + +`T3::new(period, v).warmup_period() == 6·period − 5`. Each stage of the +SMA-seeded EMA cascade adds `period − 1` bars of delay: `e1` seeds at +input `period`, `e2` at `2·period − 1`, …, `e6` at `6·period − 5`. T3 +emits its first value once `e6` is ready, since the output formula needs +`e3` through `e6`. + +## Edge cases + +- **Constant series.** Because `c1 + c2 + c3 + c4 = 1` for any `v`, a flat + input series produces a flat output equal to the constant + (`coefficients_sum_to_one` and `constant_series_yields_the_constant` + pin this). +- **`v = 0`.** The coefficients become `c1 = c2 = c3 = 0`, `c4 = 1`, so + `T3` is exactly the third stage of the EMA cascade + (`zero_volume_factor_collapses_to_triple_cascaded_ema` pins this). +- **NaN / infinity inputs.** Non-finite inputs are silently dropped — the + cascade is not advanced — and the previous valid value is returned. +- **Reset.** `t3.reset()` clears all six EMAs and the cached value. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, T3}; + +fn main() -> Result<(), Box> { + let prices: Vec = (1..=40).map(f64::from).collect(); + let mut t3 = T3::new(3, 0.7)?; + let out = t3.batch(&prices); + println!("warmup_period = {}", t3.warmup_period()); + println!("first ready index = {:?}", out.iter().position(Option::is_some)); + Ok(()) +} +``` + +Output: + +``` +warmup_period = 13 +first ready index = Some(12) +``` + +`T3(3, 0.7)` warms up after `6·3 − 5 = 13` inputs, so the first non-`None` +output sits at index `12`. On a pure ramp the output then tracks the input +trend with a smooth, near-constant offset. + +### Python + +```python +import numpy as np +import wickra as ta + +t3 = ta.T3(5) # v defaults to 0.7 +prices = np.linspace(100.0, 140.0, 60) +out = t3.batch(prices) +print("warmup_period =", t3.warmup_period()) +print("ready values:", np.count_nonzero(~np.isnan(out))) +``` + +Output: + +``` +warmup_period = 25 +ready values: 36 +``` + +### Node + +```javascript +const ta = require('wickra'); +const t3 = new ta.T3(5, 0.7); +const prices = Array.from({ length: 60 }, (_, i) => 100 + i); +console.log('warmupPeriod:', t3.warmupPeriod()); +console.log('last:', t3.batch(prices).at(-1)); +``` + +## Interpretation + +`T3` is a "best of both" trend line — close to `Tema` in lag reduction but +visibly smoother, because the six-EMA cascade filters noise the +three-EMA `Tema` lets through. Use it as a single trend filter or as the +slow leg of a crossover where you want a clean line. Raise `v` toward `1` +for sharper turns (more overshoot), lower it toward `0` for maximum +smoothness (`v = 0` is just a triple EMA). + +## Common pitfalls + +- **Treating `v` as optional outside Python.** Only the Python binding + defaults `v` to `0.7`; the Rust, Node and WASM constructors require it. +- **Underestimating warmup.** `6·period − 5` grows fast — a `T3(20)` needs + `115` bars before its first value. + +## References + +Tim Tillson, "Better Moving Averages", *Technical Analysis of Stocks & +Commodities* (1998). The six-EMA expansion and coefficient formulas here +match Tillson's published derivation and TA-Lib's `T3`. + +## See also + +- [Indicator-Tema.md](Indicator-Tema.md) — the three-EMA relative. +- [Indicator-Dema.md](Indicator-Dema.md) — the two-EMA relative. +- [Indicator-Zlema.md](Indicator-Zlema.md) — low-lag average via de-lagging. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/trend/Indicator-Vwma.md b/docs/wiki/indicators/trend/Indicator-Vwma.md new file mode 100644 index 00000000..391b1cef --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-Vwma.md @@ -0,0 +1,176 @@ +# VWMA + +> Volume-Weighted Moving Average — a rolling mean of closes where each bar +> is weighted by its own traded volume. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Volume-weighted averages | +| Input type | `Candle` (uses `close` and `volume`) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | `period` is required (no default in either binding) | +| Warmup period | `period` | +| Interpretation | Trend line that leans toward high-conviction (high-volume) bars. | + +## Formula + +``` +VWMA_t = Σ(close_i · volume_i) / Σ(volume_i) over the last `period` bars +``` + +A heavy bar pulls the average toward its close; a thin bar barely moves +it. Both the numerator (`Σ price·volume`) and denominator (`Σ volume`) +are maintained as O(1) rolling sums, so `update` is O(1) regardless of +`period`. + +If **every** bar in the window has zero volume the weighted mean is +undefined (`0 / 0`). VWMA then falls back to the plain unweighted mean of +the `period` closes, so the output is always finite and defined. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------|-------------|-------------| +| `period` | `usize` | none | `>= 1` | Rolling window length in bars. `period = 0` errors with `Error::PeriodZero`. | + +There is no Python `#[pyo3(signature = …)]` default for `VWMA`, so +`wickra.VWMA(period)` requires the period explicitly. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/vwma.rs`: + +```rust +impl Indicator for Vwma { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`VWMA` is a **candle-input** indicator: it reads `close` and `volume` from +each `Candle`. 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 + +`Vwma::new(period).warmup_period() == period`. The first `period − 1` +candles fill the rolling window; the `period`-th `update()` produces the +first weighted mean. + +## Edge cases + +- **Constant closes.** Closes all equal to `c` give `VWMA = c` regardless + of the volumes (`Σ c·v / Σ v = c`), and the zero-volume fallback also + yields `c` (`constant_series_yields_the_constant` pins this). +- **Zero-volume window.** If every bar in the window has `volume = 0`, + VWMA returns the unweighted mean of the `period` closes + (`zero_volume_window_falls_back_to_unweighted_mean` pins this). +- **Candle validation.** `Candle::new` already rejects NaN/infinite fields + and negative volume, so `update` never sees an invalid bar — there is no + separate non-finite guard. +- **Reset.** `vwma.reset()` clears the window and all three rolling sums. + +## Examples + +### Rust + +```rust +use wickra::{Candle, Indicator, Vwma}; + +fn main() -> Result<(), Box> { + let mut vwma = Vwma::new(2)?; + // (close, volume): (10, 1) then (20, 3). + let a = Candle::new(10.0, 10.0, 10.0, 10.0, 1.0, 0)?; + let b = Candle::new(20.0, 20.0, 20.0, 20.0, 3.0, 1)?; + println!("{:?}", vwma.update(a)); + println!("{:?}", vwma.update(b)); + Ok(()) +} +``` + +Output: + +``` +None +Some(17.5) +``` + +The window holds two bars: `(10·1 + 20·3) / (1 + 3) = 70 / 4 = 17.5`. The +heavier bar at `20` dominates, so the result sits well above the simple +mean of `15`. This matches the `reference_value` test in +`crates/wickra-core/src/indicators/vwma.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +vwma = ta.VWMA(2) +close = np.array([10.0, 20.0, 30.0]) +volume = np.array([1.0, 3.0, 1.0]) +print(vwma.batch(close, volume)) +print("warmup_period =", vwma.warmup_period()) +``` + +Output: + +``` +[ nan 17.5 22.5] +warmup_period = 2 +``` + +### Node + +```javascript +const ta = require('wickra'); +const vwma = new ta.VWMA(2); +console.log(vwma.batch([10, 20, 30], [1, 3, 1])); +console.log('warmupPeriod:', vwma.warmupPeriod()); +``` + +Output: + +``` +[ NaN, 17.5, 22.5 ] +warmupPeriod: 2 +``` + +## Interpretation + +`Vwma` is a trend line that respects participation. Compared with an +equal-weighted `Sma` of the same period, it reacts faster to moves backed +by heavy volume and lags moves on thin volume. The classic read is the +`Vwma`-vs-`Sma` relationship: `Vwma` above `Sma` means recent strength was +volume-backed (more trustworthy); `Vwma` below `Sma` means the up-moves +came on light volume. It is a session-independent cousin of +[`Vwap`](../volume/Indicator-Vwap.md) — VWAP weights by volume since the +start of the stream, VWMA over a fixed rolling window. + +## Common pitfalls + +- **Feeding it scalar prices.** `VWMA` needs volume; it takes a `Candle`, + not an `f64`. Use `Sma`/`Wma` for a pure price series. +- **Assuming a zero-volume window is an error.** It is not — VWMA falls + back to the unweighted mean. If that fallback matters to you, screen the + window's total volume yourself. + +## References + +The volume-weighted moving average is a standard volume-weighted rolling +mean; the rolling-sum formulation here matches the common pandas +implementation `(close*volume).rolling(n).sum() / volume.rolling(n).sum()`, +with an explicit zero-volume fallback added for robustness. + +## See also + +- [Indicator-Sma.md](Indicator-Sma.md) — the equal-weighted counterpart. +- [Indicator-Vwap.md](../volume/Indicator-Vwap.md) — volume-weighted price + since the start of the stream. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/trend/Indicator-Zlema.md b/docs/wiki/indicators/trend/Indicator-Zlema.md new file mode 100644 index 00000000..02cbc516 --- /dev/null +++ b/docs/wiki/indicators/trend/Indicator-Zlema.md @@ -0,0 +1,167 @@ +# ZLEMA + +> Zero-Lag Exponential Moving Average — an EMA fed a de-lagged price series +> so it tracks turns with almost no group delay. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend | +| Sub-category | Exponential family | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded; tracks the input price scale | +| Default parameters | `period` is required (no default in either binding) | +| Warmup period | `lag + period` where `lag = (period − 1) / 2` | +| Interpretation | Low-lag trend line; crossings of price react far sooner than a plain EMA. | + +## Formula + +``` +lag = (period − 1) / 2 (integer division) +de_lagged_t = 2·price_t − price_{t−lag} +ZLEMA_t = EMA_period(de_lagged)_t +``` + +The trick (Ehlers & Way, 2010): `price_t − price_{t−lag}` is a momentum +term. Adding it to the current price *over-shoots* in the direction of the +recent move by exactly enough to cancel the EMA's lag. The inner EMA then +smooths that de-lagged series with the usual `α = 2 / (period + 1)`. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------|-------------|-------------| +| `period` | `usize` | none | `>= 1` | EMA length. `period = 0` errors with `Error::PeriodZero`. The lag offset is derived as `(period − 1) / 2`. | + +There is no Python `#[pyo3(signature = …)]` default for `ZLEMA`, so +`wickra.ZLEMA(period)` requires the period explicitly. The derived `lag` +is exposed as a read-only property. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/zlema.rs`: + +```rust +impl Indicator for Zlema { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +A single `f64` close in, an `Option` out. Python maps this to +`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` / +`Array` (NaN warmup). + +## Warmup + +`Zlema::new(period).warmup_period() == lag + period`. The de-lagged series +is undefined until `lag` prior inputs exist, so it produces its first +value on input `lag + 1`; the inner EMA then needs `period` de-lagged +values to seed. The first non-`None` output therefore lands on input +`lag + period`. + +## Edge cases + +- **Constant series.** De-lagging a constant gives the same constant + (`2c − c = c`), so `ZLEMA` of a flat series is flat + (`constant_series_yields_the_constant` pins this). +- **NaN / infinity inputs.** Non-finite inputs are silently dropped: the + rolling lag buffer is not advanced and the inner EMA is not fed, so the + previous valid value (if any) is returned. +- **`period = 1`.** `lag = 0`, the de-lagged series equals the raw price, + and `ZLEMA(1)` degenerates to a pass-through. +- **Reset.** `zlema.reset()` clears the lag buffer and the inner EMA. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Zlema}; + +fn main() -> Result<(), Box> { + let mut zlema = Zlema::new(3)?; + let out: Vec> = zlema.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + println!("{:?}", out); + println!("lag = {}, warmup_period = {}", zlema.lag(), zlema.warmup_period()); + Ok(()) +} +``` + +Output: + +``` +[None, None, None, Some(4.0), Some(5.0)] +lag = 1, warmup_period = 4 +``` + +`ZLEMA(3)` has `lag = 1`. The de-lagged series of `[1,2,3,4,5]` is +`[_, 3, 4, 5, 6]`; `EMA(3)` of that seeds at `mean(3,4,5) = 4.0`, then +`0.5·6 + 0.5·4 = 5.0`. This matches the `reference_values` test in +`crates/wickra-core/src/indicators/zlema.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +zlema = ta.ZLEMA(3) +print(zlema.batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0]))) +print("lag =", zlema.lag, "warmup_period =", zlema.warmup_period()) +``` + +Output: + +``` +[nan nan nan 4. 5.] +lag = 1 warmup_period = 4 +``` + +### Node + +```javascript +const ta = require('wickra'); +const zlema = new ta.ZLEMA(3); +console.log(zlema.batch([1, 2, 3, 4, 5])); +console.log('warmupPeriod:', zlema.warmupPeriod()); +``` + +Output: + +``` +[ NaN, NaN, NaN, 4, 5 ] +warmupPeriod: 4 +``` + +## Interpretation + +`Zlema` is a low-lag trend line. Use it where an `Ema` would lag too much +into a reversal — for example as the fast leg of a crossover system, or +as a trailing reference that should react quickly. The momentum injection +that removes the lag also makes `Zlema` overshoot on sharp spikes, so it +is noisier than the `Ema` it is built on; pair it with a slower filter if +whipsaws are a concern. + +## Common pitfalls + +- **Expecting `Ema`-identical values.** `Zlema` is deliberately *not* an + `Ema` — it leads price. The two only coincide for `period = 1`. +- **Forgetting the extra warmup.** Warmup is `lag + period`, not `period`; + budget `(period − 1) / 2` extra bars before the first output. + +## References + +John Ehlers and Ric Way, "Zero Lag (Well, Almost)", *Technical Analysis +of Stocks & Commodities* (2010). The implementation here uses the standard +`lag = (period − 1) / 2` and an SMA-seeded inner EMA. + +## See also + +- [Indicator-Ema.md](Indicator-Ema.md) — the inner average ZLEMA de-lags. +- [Indicator-Hma.md](Indicator-Hma.md) — another low-lag average, via WMAs. +- [Indicator-T3.md](Indicator-T3.md) — low-lag average via a six-EMA cascade. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.