From 16c0639f0c46f08902e4cc08afb548018d500715 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 22 May 2026 18:17:38 +0200 Subject: [PATCH] F6: add Aroon Oscillator, Vortex and Mass Index Completes the F6 family (Trend strength) end to end: - Rust core: aroon_oscillator.rs (AroonUp - AroonDown, one-line trend gauge), vortex.rs (Vortex Indicator VI+/VI- with the VortexOutput struct), mass_index.rs (Dorsey's range-expansion sum of the EMA-of-range ratio). Each with a full Indicator impl, runnable doctest and reference / saturation / warmup / reset / batch==streaming tests. - Python: PyAroonOscillator / PyVortex / PyMassIndex PyO3 classes + module registration + .pyi stubs (defaults Aroon=14, Vortex=14, MassIndex=(9,25)). - Node: explicit AroonOscillatorNode, VortexNode (with VortexValue object) and MassIndexNode; index.d.ts and index.js updated. - WASM: WasmAroonOscillator, WasmVortex, WasmMassIndex. - Wiki: Indicator-AroonOscillator/Vortex/MassIndex.md plus rows in Indicators-Overview.md and entries in Home.md. cargo fmt + clippy (core/wickra/data/wasm/node) clean; 320 core tests, 25 data tests and 45 doctests green. --- bindings/node/index.js | 5 +- bindings/node/src/lib.rs | 169 ++++++++++++ bindings/python/python/wickra/__init__.pyi | 49 ++++ bindings/python/src/lib.rs | 211 +++++++++++++++ bindings/wasm/src/lib.rs | 117 ++++++++ .../src/indicators/aroon_oscillator.rs | 185 +++++++++++++ .../wickra-core/src/indicators/mass_index.rs | 219 +++++++++++++++ crates/wickra-core/src/indicators/mod.rs | 6 + crates/wickra-core/src/indicators/vortex.rs | 249 ++++++++++++++++++ crates/wickra-core/src/lib.rs | 10 +- docs/wiki/Home.md | 3 + docs/wiki/Indicators-Overview.md | 5 +- .../momentum/Indicator-AroonOscillator.md | 159 +++++++++++ .../momentum/Indicator-MassIndex.md | 173 ++++++++++++ .../indicators/momentum/Indicator-Vortex.md | 160 +++++++++++ 15 files changed, 1713 insertions(+), 7 deletions(-) create mode 100644 crates/wickra-core/src/indicators/aroon_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/mass_index.rs create mode 100644 crates/wickra-core/src/indicators/vortex.rs create mode 100644 docs/wiki/indicators/momentum/Indicator-AroonOscillator.md create mode 100644 docs/wiki/indicators/momentum/Indicator-MassIndex.md create mode 100644 docs/wiki/indicators/momentum/Indicator-Vortex.md diff --git a/bindings/node/index.js b/bindings/node/index.js index 16a9977f..26f1ceae 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, 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, 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 @@ -336,6 +336,9 @@ module.exports.UltimateOscillator = UltimateOscillator module.exports.PPO = PPO module.exports.DPO = DPO module.exports.Coppock = Coppock +module.exports.AroonOscillator = AroonOscillator +module.exports.Vortex = Vortex +module.exports.MassIndex = MassIndex 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 9a820503..6984d04d 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -1145,6 +1145,175 @@ impl PmoNode { // ============================== VWMA ============================== +// ============================== Aroon Oscillator ============================== + +#[napi(js_name = "AroonOscillator")] +pub struct AroonOscillatorNode { + inner: wc::AroonOscillator, +} + +#[napi] +impl AroonOscillatorNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::AroonOscillator::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, low, 0.0)?)) + } + #[napi] + pub fn batch(&mut self, high: Vec, low: Vec) -> napi::Result> { + if high.len() != low.len() { + return Err(NapiError::from_reason( + "high and low 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], low[i], 0.0)?) + .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 + } +} + +// ============================== Vortex ============================== + +/// Vortex Indicator pair: `VI+` and `VI-`. +#[napi(object)] +pub struct VortexValue { + pub plus: f64, + pub minus: f64, +} + +#[napi(js_name = "Vortex")] +pub struct VortexNode { + inner: wc::Vortex, +} + +#[napi] +impl VortexNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::Vortex::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| VortexValue { + plus: o.plus, + minus: o.minus, + })) + } + /// Returns `[plus0, minus0, plus1, minus1, ...]`, length `2 * n`. Warmup is NaN. + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + out[i * 2] = o.plus; + out[i * 2 + 1] = o.minus; + } + } + 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 + } +} + +// ============================== Mass Index ============================== + +#[napi(js_name = "MassIndex")] +pub struct MassIndexNode { + inner: wc::MassIndex, +} + +#[napi] +impl MassIndexNode { + #[napi(constructor)] + pub fn new(ema_period: u32, sum_period: u32) -> napi::Result { + Ok(Self { + inner: wc::MassIndex::new(ema_period as usize, sum_period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, low, 0.0)?)) + } + #[napi] + pub fn batch(&mut self, high: Vec, low: Vec) -> napi::Result> { + if high.len() != low.len() { + return Err(NapiError::from_reason( + "high and low 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], low[i], 0.0)?) + .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 + } +} + // ============================== StochRSI ============================== #[napi(js_name = "StochRSI")] diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index 9a0a908b..970a3d03 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -76,6 +76,55 @@ class TRIMA: @property def value(self) -> Optional[float]: ... +class AroonOscillator: + def __init__(self, period: int = 14) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: 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 Vortex: + def __init__(self, period: int = 14) -> None: ... + def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + ) -> NDArray[np.float64]: + """Returns shape ``(n, 2)`` with columns ``[plus, minus]``.""" + ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def period(self) -> int: ... + +class MassIndex: + def __init__(self, ema_period: int = 9, sum_period: int = 25) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def periods(self) -> Tuple[int, int]: ... + @property + def value(self) -> Optional[float]: ... + class PPO: def __init__(self, fast: int = 12, slow: int = 26) -> None: ... def update(self, value: float) -> Optional[float]: ... diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 89b316c0..a1f3f76c 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1519,6 +1519,214 @@ impl PyAroon { } } +// ============================== Aroon Oscillator ============================== + +#[pyclass(name = "AroonOscillator", module = "wickra._wickra")] +#[derive(Clone)] +struct PyAroonOscillator { + inner: wc::AroonOscillator, +} + +#[pymethods] +impl PyAroonOscillator { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::AroonOscillator::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 high + low columns (both 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: 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))?; + if h.len() != l.len() { + return Err(PyValueError::new_err("high and low must be equal length")); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 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!("AroonOscillator(period={})", self.inner.period()) + } +} + +// ============================== Vortex ============================== + +#[pyclass(name = "Vortex", module = "wickra._wickra")] +#[derive(Clone)] +struct PyVortex { + inner: wc::Vortex, +} + +#[pymethods] +impl PyVortex { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Vortex::new(period).map_err(map_err)?, + }) + } + /// Returns `(plus, minus)` or `None` during warmup. + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.plus, o.minus))) + } + /// Batch over high/low/close numpy columns. Returns shape `(n, 2)` for `[plus, minus]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: 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))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 2] = o.plus; + out[i * 2 + 1] = o.minus; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + 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!("Vortex(period={})", self.inner.period()) + } +} + +// ============================== Mass Index ============================== + +#[pyclass(name = "MassIndex", module = "wickra._wickra")] +#[derive(Clone)] +struct PyMassIndex { + inner: wc::MassIndex, +} + +#[pymethods] +impl PyMassIndex { + #[new] + #[pyo3(signature = (ema_period=9, sum_period=25))] + fn new(ema_period: usize, sum_period: usize) -> PyResult { + Ok(Self { + inner: wc::MassIndex::new(ema_period, sum_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 high + low columns (both 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: 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))?; + if h.len() != l.len() { + return Err(PyValueError::new_err("high and low must be equal length")); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + #[getter] + fn periods(&self) -> (usize, usize) { + self.inner.periods() + } + #[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 { + let (e, s) = self.inner.periods(); + format!("MassIndex(ema_period={e}, sum_period={s})") + } +} + // ============================== PPO ============================== #[pyclass(name = "PPO", module = "wickra._wickra")] @@ -2345,5 +2553,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 aee0f701..95c51c20 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -372,6 +372,123 @@ impl WasmUltimateOscillator { } } +#[wasm_bindgen(js_name = AroonOscillator)] +pub struct WasmAroonOscillator { + inner: wc::AroonOscillator, +} + +#[wasm_bindgen(js_class = AroonOscillator)] +impl WasmAroonOscillator { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::AroonOscillator::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64) -> Result, JsError> { + let c = make_candle(high, low, low, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + if high.len() != low.len() { + return Err(JsError::new("high and low must be equal length")); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let c = make_candle(high[i], low[i], low[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 = Vortex)] +pub struct WasmVortex { + inner: wc::Vortex, +} + +#[wasm_bindgen(js_class = Vortex)] +impl WasmVortex { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::Vortex::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"plus".into(), &o.plus.into()).ok(); + Reflect::set(&obj, &"minus".into(), &o.minus.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Returns `[plus0, minus0, plus1, minus1, ...]`, length `2 * n`. Warmup is NaN. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + 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![f64::NAN; n * 2]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + out[i * 2] = o.plus; + out[i * 2 + 1] = o.minus; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = MassIndex)] +pub struct WasmMassIndex { + inner: wc::MassIndex, +} + +#[wasm_bindgen(js_class = MassIndex)] +impl WasmMassIndex { + #[wasm_bindgen(constructor)] + pub fn new(ema_period: usize, sum_period: usize) -> Result { + Ok(Self { + inner: wc::MassIndex::new(ema_period, sum_period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64) -> Result, JsError> { + let c = make_candle(high, low, low, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + if high.len() != low.len() { + return Err(JsError::new("high and low must be equal length")); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let c = make_candle(high[i], low[i], low[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 = VWMA)] pub struct WasmVwma { inner: wc::Vwma, diff --git a/crates/wickra-core/src/indicators/aroon_oscillator.rs b/crates/wickra-core/src/indicators/aroon_oscillator.rs new file mode 100644 index 00000000..c5f360d0 --- /dev/null +++ b/crates/wickra-core/src/indicators/aroon_oscillator.rs @@ -0,0 +1,185 @@ +//! Aroon Oscillator. + +use crate::error::Result; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +use super::Aroon; + +/// Aroon Oscillator — the single-line difference `AroonUp − AroonDown`. +/// +/// The [`Aroon`] indicator reports two `[0, 100]` lines; the Aroon Oscillator +/// collapses them into one value in `[−100, 100]`: +/// +/// ```text +/// AroonOscillator = AroonUp − AroonDown +/// ``` +/// +/// Strongly positive means the most recent high is much fresher than the most +/// recent low (an up-trend); strongly negative is the mirror image. Readings +/// near zero mean neither extreme is recent — a range. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AroonOscillator}; +/// +/// let mut indicator = AroonOscillator::new(5).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + i as f64; +/// 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_eq!(last, Some(100.0)); // pure uptrend +/// ``` +#[derive(Debug, Clone)] +pub struct AroonOscillator { + aroon: Aroon, + last: Option, +} + +impl AroonOscillator { + /// Construct a new Aroon Oscillator with the given period. + /// + /// # Errors + /// + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + aroon: Aroon::new(period)?, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.aroon.period() + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for AroonOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let osc = self.aroon.update(candle).map(|o| o.up - o.down)?; + self.last = Some(osc); + Some(osc) + } + + fn reset(&mut self) { + self.aroon.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.aroon.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "AroonOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(close, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn new_rejects_zero_period() { + assert!(AroonOscillator::new(0).is_err()); + } + + #[test] + fn pure_uptrend_yields_plus_100() { + // Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0. + let mut osc = AroonOscillator::new(5).unwrap(); + let candles: Vec = (0..30) + .map(|i| { + let p = 100.0 + i as f64; + candle(p + 1.0, p - 1.0, p, i) + }) + .collect(); + for v in osc.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 100.0, epsilon = 1e-12); + } + } + + #[test] + fn pure_downtrend_yields_minus_100() { + let mut osc = AroonOscillator::new(5).unwrap(); + let candles: Vec = (0..30) + .map(|i| { + let p = 100.0 - i as f64; + candle(p + 1.0, p - 1.0, p, i) + }) + .collect(); + for v in osc.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, -100.0, epsilon = 1e-12); + } + } + + #[test] + fn output_stays_within_minus_100_and_100() { + let mut osc = AroonOscillator::new(14).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.25).sin() * 12.0; + candle(mid + 2.0, mid - 2.0, mid, i) + }) + .collect(); + for v in osc.batch(&candles).into_iter().flatten() { + assert!((-100.0..=100.0).contains(&v), "out of range: {v}"); + } + } + + #[test] + fn warmup_period_matches_aroon() { + let osc = AroonOscillator::new(7).unwrap(); + assert_eq!(osc.warmup_period(), 8); + } + + #[test] + fn reset_clears_state() { + let mut osc = AroonOscillator::new(5).unwrap(); + let candles: Vec = (0..20) + .map(|i| candle(100.0 + i as f64, 90.0, 95.0, i)) + .collect(); + osc.batch(&candles); + assert!(osc.is_ready()); + osc.reset(); + assert!(!osc.is_ready()); + assert_eq!(osc.update(candles[0]), 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 + 2.0, mid - 2.0, mid, i) + }) + .collect(); + let batch = AroonOscillator::new(14).unwrap().batch(&candles); + let mut b = AroonOscillator::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/mass_index.rs b/crates/wickra-core/src/indicators/mass_index.rs new file mode 100644 index 00000000..5ffb32f6 --- /dev/null +++ b/crates/wickra-core/src/indicators/mass_index.rs @@ -0,0 +1,219 @@ +//! Mass Index. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +use super::Ema; + +/// Mass Index — Donald Dorsey's range-expansion indicator. +/// +/// The Mass Index watches the high–low range, not direction. It smooths the +/// range with an EMA, smooths that again, takes the ratio of the two, and sums +/// the ratio over a window: +/// +/// ```text +/// range_t = high_t − low_t +/// ratio_t = EMA(range, ema_period) / EMA(EMA(range, ema_period), ema_period) +/// MassIndex = Σ ratio over sum_period +/// ``` +/// +/// When the range widens, the single EMA pulls ahead of the double EMA, the +/// ratio rises above `1`, and the sum climbs. Dorsey's "reversal bulge" is the +/// Mass Index rising above `27` and then falling back below `26.5` — a sign +/// that a range expansion is about to resolve into a trend reversal. With the +/// conventional `(ema_period = 9, sum_period = 25)` a flat-range market sits at +/// `25`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, MassIndex}; +/// +/// let mut indicator = MassIndex::new(9, 25).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + i as f64; +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct MassIndex { + ema_period: usize, + sum_period: usize, + ema1: Ema, + ema2: Ema, + /// Rolling window of the last `sum_period` EMA ratios. + window: VecDeque, + sum: f64, + last: Option, +} + +impl MassIndex { + /// Construct a new Mass Index with the EMA smoothing period and the sum + /// window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is `0`. + pub fn new(ema_period: usize, sum_period: usize) -> Result { + if ema_period == 0 || sum_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + ema_period, + sum_period, + ema1: Ema::new(ema_period)?, + ema2: Ema::new(ema_period)?, + window: VecDeque::with_capacity(sum_period), + sum: 0.0, + last: None, + }) + } + + /// The `(ema_period, sum_period)` pair. + pub const fn periods(&self) -> (usize, usize) { + (self.ema_period, self.sum_period) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for MassIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let single = self.ema1.update(range)?; + let double = self.ema2.update(single)?; + let ratio = if double == 0.0 { + // A zero-range market: no expansion, neutral ratio. + 1.0 + } else { + single / double + }; + if self.window.len() == self.sum_period { + self.sum -= self.window.pop_front().expect("window is non-empty"); + } + self.window.push_back(ratio); + self.sum += ratio; + if self.window.len() < self.sum_period { + return None; + } + self.last = Some(self.sum); + Some(self.sum) + } + + fn reset(&mut self) { + self.ema1.reset(); + self.ema2.reset(); + self.window.clear(); + self.sum = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // ema1 seeds at `ema_period`, ema2 at `2·ema_period − 1`, then the sum + // window needs `sum_period` ratios. + 2 * self.ema_period + self.sum_period - 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "MassIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// A candle with a fixed high–low range `span` centred on `mid`. + fn candle(mid: f64, span: f64, ts: i64) -> Candle { + Candle::new(mid, mid + span / 2.0, mid - span / 2.0, mid, 1.0, ts).unwrap() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(MassIndex::new(0, 25), Err(Error::PeriodZero))); + assert!(matches!(MassIndex::new(9, 0), Err(Error::PeriodZero))); + } + + #[test] + fn warmup_period_formula() { + let mi = MassIndex::new(9, 25).unwrap(); + assert_eq!(mi.warmup_period(), 2 * 9 + 25 - 2); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut mi = MassIndex::new(3, 4).unwrap(); + let warmup = mi.warmup_period(); // 2*3 + 4 - 2 = 8 + assert_eq!(warmup, 8); + let candles: Vec = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect(); + let out = mi.batch(&candles); + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn constant_range_sums_to_sum_period() { + // A constant high–low range makes both EMAs converge to the same + // value, so every ratio is 1 and the Mass Index equals `sum_period`. + let mut mi = MassIndex::new(3, 4).unwrap(); + let candles: Vec = (0..40).map(|i| candle(100.0 + i as f64, 2.0, i)).collect(); + for v in mi.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 4.0, epsilon = 1e-9); + } + } + + #[test] + fn zero_range_market_sums_to_sum_period() { + let mut mi = MassIndex::new(3, 4).unwrap(); + let candles: Vec = (0..40).map(|i| candle(100.0, 0.0, i)).collect(); + for v in mi.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 4.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut mi = MassIndex::new(3, 4).unwrap(); + let candles: Vec = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect(); + mi.batch(&candles); + assert!(mi.is_ready()); + mi.reset(); + assert!(!mi.is_ready()); + assert_eq!(mi.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + let span = 2.0 + (i as f64 * 0.3).sin().abs() * 3.0; + candle(100.0 + (i as f64 * 0.2).cos() * 5.0, span, i) + }) + .collect(); + let batch = MassIndex::new(9, 25).unwrap().batch(&candles); + let mut b = MassIndex::new(9, 25).unwrap(); + 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 a7f70825..9e2248a6 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -6,6 +6,7 @@ mod adx; mod aroon; +mod aroon_oscillator; mod atr; mod awesome_oscillator; mod bollinger; @@ -20,6 +21,7 @@ mod hma; mod kama; mod keltner; mod macd; +mod mass_index; mod mfi; mod mom; mod obv; @@ -38,6 +40,7 @@ mod trima; mod trix; mod tsi; mod ultimate_oscillator; +mod vortex; mod vwap; mod vwma; mod williams_r; @@ -46,6 +49,7 @@ mod zlema; pub use adx::{Adx, AdxOutput}; pub use aroon::{Aroon, AroonOutput}; +pub use aroon_oscillator::AroonOscillator; pub use atr::Atr; pub use awesome_oscillator::AwesomeOscillator; pub use bollinger::{BollingerBands, BollingerOutput}; @@ -60,6 +64,7 @@ pub use hma::Hma; pub use kama::Kama; pub use keltner::{Keltner, KeltnerOutput}; pub use macd::{MacdIndicator, MacdOutput}; +pub use mass_index::MassIndex; pub use mfi::Mfi; pub use mom::Mom; pub use obv::Obv; @@ -78,6 +83,7 @@ pub use trima::Trima; pub use trix::Trix; pub use tsi::Tsi; pub use ultimate_oscillator::UltimateOscillator; +pub use vortex::{Vortex, VortexOutput}; pub use vwap::{RollingVwap, Vwap}; pub use vwma::Vwma; pub use williams_r::WilliamsR; diff --git a/crates/wickra-core/src/indicators/vortex.rs b/crates/wickra-core/src/indicators/vortex.rs new file mode 100644 index 00000000..06f158eb --- /dev/null +++ b/crates/wickra-core/src/indicators/vortex.rs @@ -0,0 +1,249 @@ +//! Vortex Indicator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Vortex Indicator output: the two directional movement lines. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VortexOutput { + /// `VI+` — strength of upward (positive) vortex movement. + pub plus: f64, + /// `VI−` — strength of downward (negative) vortex movement. + pub minus: f64, +} + +/// Vortex Indicator — Botes & Siepman's pair of oscillators (`VI+`, `VI−`) that +/// capture the relationship between two consecutive bars. +/// +/// Two "vortex movements" measure how far price travelled against the opposite +/// extreme of the previous bar; each is normalised by the summed true range: +/// +/// ```text +/// VM+_t = |high_t − low_{t−1}| +/// VM−_t = |low_t − high_{t−1}| +/// VI+ = Σ VM+ over n / Σ TR over n +/// VI− = Σ VM− over n / Σ TR over n +/// ``` +/// +/// `VI+` crossing above `VI−` is a bullish signal, the reverse a bearish one; +/// the wider the gap, the stronger the trend. A fully flat window (zero true +/// range) reports `(0, 0)`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Vortex}; +/// +/// let mut indicator = Vortex::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + i as f64; +/// 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)] +pub struct Vortex { + period: usize, + prev: Option, + /// Rolling window of `(VM+, VM−, TR)` triples. + window: VecDeque<(f64, f64, f64)>, + sum_vm_plus: f64, + sum_vm_minus: f64, + sum_tr: f64, + last: Option, +} + +impl Vortex { + /// Construct a new Vortex Indicator 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, + prev: None, + window: VecDeque::with_capacity(period), + sum_vm_plus: 0.0, + sum_vm_minus: 0.0, + sum_tr: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Vortex { + type Input = Candle; + type Output = VortexOutput; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev else { + // The first bar has no predecessor to measure against. + self.prev = Some(candle); + return None; + }; + let vm_plus = (candle.high - prev.low).abs(); + let vm_minus = (candle.low - prev.high).abs(); + let tr = candle.true_range(Some(prev.close)); + self.prev = Some(candle); + + if self.window.len() == self.period { + let (old_p, old_m, old_tr) = self.window.pop_front().expect("window is non-empty"); + self.sum_vm_plus -= old_p; + self.sum_vm_minus -= old_m; + self.sum_tr -= old_tr; + } + self.window.push_back((vm_plus, vm_minus, tr)); + self.sum_vm_plus += vm_plus; + self.sum_vm_minus += vm_minus; + self.sum_tr += tr; + + if self.window.len() < self.period { + return None; + } + let out = if self.sum_tr == 0.0 { + // A perfectly flat window has no range to normalise against. + VortexOutput { + plus: 0.0, + minus: 0.0, + } + } else { + VortexOutput { + plus: self.sum_vm_plus / self.sum_tr, + minus: self.sum_vm_minus / self.sum_tr, + } + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.prev = None; + self.window.clear(); + self.sum_vm_plus = 0.0; + self.sum_vm_minus = 0.0; + self.sum_tr = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // The first VM/TR triple needs a previous bar, then the window fills. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "Vortex" + } +} + +#[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, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Vortex::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn reference_values() { + // Vortex(2) over three explicit candles (high, low, close): + // c1 = (10, 8, 9), c2 = (12, 9, 11), c3 = (13, 11, 12). + // bar 2: VM+ = |12-8| = 4, VM- = |9-10| = 1, TR = 3. + // bar 3: VM+ = |13-9| = 4, VM- = |11-12| = 1, TR = 2. + // window sums: VM+ = 8, VM- = 2, TR = 5 -> VI+ = 1.6, VI- = 0.4. + let candles = [ + candle(9.0, 10.0, 8.0, 9.0, 0), + candle(10.0, 12.0, 9.0, 11.0, 1), + candle(12.0, 13.0, 11.0, 12.0, 2), + ]; + let mut v = Vortex::new(2).unwrap(); + let out = v.batch(&candles); + assert_eq!(v.warmup_period(), 3); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + let o = out[2].unwrap(); + assert_relative_eq!(o.plus, 1.6, epsilon = 1e-12); + assert_relative_eq!(o.minus, 0.4, epsilon = 1e-12); + } + + #[test] + fn perfectly_flat_market_yields_zero() { + let mut v = Vortex::new(5).unwrap(); + let candles: Vec = (0..20).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect(); + for o in v.batch(&candles).into_iter().flatten() { + assert_relative_eq!(o.plus, 0.0, epsilon = 1e-12); + assert_relative_eq!(o.minus, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn outputs_are_non_negative() { + let mut v = Vortex::new(14).unwrap(); + let candles: Vec = (0..120) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0; + candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i) + }) + .collect(); + for o in v.batch(&candles).into_iter().flatten() { + assert!(o.plus >= 0.0 && o.minus >= 0.0, "negative VI: {o:?}"); + } + } + + #[test] + fn reset_clears_state() { + let mut v = Vortex::new(5).unwrap(); + let candles: Vec = (0..20) + .map(|i| candle(100.0, 102.0, 98.0, 101.0, i)) + .collect(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.35).sin() * 9.0; + candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i) + }) + .collect(); + let batch = Vortex::new(14).unwrap().batch(&candles); + let mut b = Vortex::new(14).unwrap(); + 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 07529e4a..6d3dd005 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -44,11 +44,11 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ - Adx, AdxOutput, Aroon, AroonOutput, Atr, AwesomeOscillator, BollingerBands, BollingerOutput, - Cci, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, Ema, Hma, Kama, Keltner, KeltnerOutput, - MacdIndicator, MacdOutput, Mfi, Mom, Obv, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, - StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix, Tsi, UltimateOscillator, Vwap, Vwma, - WilliamsR, Wma, Zlema, T3, + Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AwesomeOscillator, BollingerBands, + BollingerOutput, Cci, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, Ema, Hma, Kama, + Keltner, KeltnerOutput, MacdIndicator, MacdOutput, MassIndex, Mfi, Mom, Obv, Pmo, Ppo, Psar, + Roc, RollingVwap, Rsi, Sma, Smma, StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix, + Tsi, UltimateOscillator, 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 bd994911..1951d1b7 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -107,6 +107,9 @@ Rust / Python / Node examples. They are grouped by family, mirroring the - [Indicator-Ppo.md](indicators/momentum/Indicator-Ppo.md) - [Indicator-Dpo.md](indicators/momentum/Indicator-Dpo.md) - [Indicator-Coppock.md](indicators/momentum/Indicator-Coppock.md) +- [Indicator-AroonOscillator.md](indicators/momentum/Indicator-AroonOscillator.md) +- [Indicator-Vortex.md](indicators/momentum/Indicator-Vortex.md) +- [Indicator-MassIndex.md](indicators/momentum/Indicator-MassIndex.md) **Volatility** — envelope width and per-bar dispersion measures. diff --git a/docs/wiki/Indicators-Overview.md b/docs/wiki/Indicators-Overview.md index b5f12f93..d3dc1d7f 100644 --- a/docs/wiki/Indicators-Overview.md +++ b/docs/wiki/Indicators-Overview.md @@ -1,6 +1,6 @@ # Indicators Overview -Wickra ships 39 indicators, organised in source under the four classical +Wickra ships 42 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 @@ -112,6 +112,9 @@ Centered on zero or driven by raw price differences; no fixed cap. | Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive | |-----------|-----------|-------|--------|-------|----------|--------|-----------| | `Adx` | Wilder's directional system: `+DI`, `−DI` (each `[0, 100]`) and `ADX` trend-strength index. | `Candle` | `(plus_di, minus_di, adx)` | each in `[0, 100]` | `period = 14` (Python) | `2·period` | [Indicator-Adx.md](indicators/momentum/Indicator-Adx.md) | +| `AroonOscillator` | `AroonUp − AroonDown`; the two Aroon lines as one trend gauge. | `Candle` | `f64` | `[−100, 100]` | `period = 14` (Python) | `period + 1` | [Indicator-AroonOscillator.md](indicators/momentum/Indicator-AroonOscillator.md) | +| `Vortex` | Vortex Indicator `VI+` / `VI−`; crossings mark trend onset. | `Candle` | `(plus, minus)` | each `>= 0` | `period = 14` (Python) | `period + 1` | [Indicator-Vortex.md](indicators/momentum/Indicator-Vortex.md) | +| `MassIndex` | Dorsey's range-expansion sum of the EMA-of-range ratio. | `Candle` | `f64` | `> 0` (around `sum_period`) | `(ema_period=9, sum_period=25)` (Python) | `2·ema_period + sum_period − 2` | [Indicator-MassIndex.md](indicators/momentum/Indicator-MassIndex.md) | ## Volatility diff --git a/docs/wiki/indicators/momentum/Indicator-AroonOscillator.md b/docs/wiki/indicators/momentum/Indicator-AroonOscillator.md new file mode 100644 index 00000000..8240a46d --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-AroonOscillator.md @@ -0,0 +1,159 @@ +# AroonOscillator + +> Aroon Oscillator — the single-line difference `AroonUp − AroonDown`, +> condensing the two Aroon lines into one trend gauge. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum (trend strength) | +| Sub-category | Bounded oscillators | +| Input type | `Candle` (uses `high`, `low`) | +| Output type | `f64` | +| Output range | `[−100, 100]` | +| Default parameters | `period = 14` (Python) | +| Warmup period | `period + 1` | +| Interpretation | Positive = up-trend, negative = down-trend, near zero = range. | + +## Formula + +``` +AroonOscillator = AroonUp − AroonDown +``` + +where [`Aroon`](Indicator-Aroon.md) reports two `[0, 100]` lines measuring +how recently the window's highest high and lowest low occurred. Their +difference lives in `[−100, 100]`: strongly positive means the most recent +high is much fresher than the most recent low (an up-trend); strongly +negative is the mirror image; near zero means neither extreme is recent. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------------|-------------|-------------| +| `period` | `usize` | `14` (Python) | `>= 1` | Aroon lookback window. `0` errors with `Error::PeriodZero`. | + +The Python binding defaults `period` to `14`. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/aroon_oscillator.rs`: + +```rust +impl Indicator for AroonOscillator { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`AroonOscillator` is a **candle-input** indicator: it reads `high` and +`low`. In Python the streaming `update` accepts a 6-tuple or a dict; the +batch helper takes `high` and `low` numpy arrays. Node and WASM expose +`update(high, low)` and `batch(high, low)`. + +## Warmup + +`AroonOscillator::new(period).warmup_period() == period + 1` — identical +to the underlying `Aroon`, which needs a `period + 1`-bar window before +the first reading. + +## Edge cases + +- **Pure trend.** A series of fresh highs gives `AroonUp = 100`, + `AroonDown = 0`, so the oscillator is `+100`; a series of fresh lows is + `−100` (`pure_uptrend_yields_plus_100` / + `pure_downtrend_yields_minus_100` pin this). +- **Bounds.** The output is always within `[−100, 100]` + (`output_stays_within_minus_100_and_100` pins this). +- **Candle validation.** `Candle::new` rejects invalid bars before + `update` ever sees them. +- **Reset.** `osc.reset()` clears the underlying Aroon window. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, AroonOscillator}; + +fn main() -> Result<(), Box> { + let mut osc = AroonOscillator::new(5)?; + // 30 bars, each a fresh high. + let candles: Vec = (0..30) + .map(|i| { + let p = 100.0 + f64::from(i); + Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap() + }) + .collect(); + let out = osc.batch(&candles); + println!("last = {:?}", out.last().unwrap()); + Ok(()) +} +``` + +Output: + +``` +last = Some(100.0) +``` + +Every bar is a fresh high and never a fresh low, so the oscillator pins at +`+100`. This matches the `pure_uptrend_yields_plus_100` test in +`crates/wickra-core/src/indicators/aroon_oscillator.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +osc = ta.AroonOscillator(14) +high = np.arange(100.0, 140.0) +low = high - 2.0 +print(osc.batch(high, low)[-1]) # steady uptrend -> 100 +``` + +Output: + +``` +100.0 +``` + +### Node + +```javascript +const ta = require('wickra'); +const osc = new ta.AroonOscillator(14); +const high = Array.from({ length: 40 }, (_, i) => 100 + i); +const low = high.map((h) => h - 2); +console.log(osc.batch(high, low).at(-1)); // 100 +``` + +## Interpretation + +`AroonOscillator` is a compact trend gauge. The two canonical reads are +the zero-line cross (`AroonUp` overtaking `AroonDown` or vice versa — a +trend change) and the magnitude (values pinned near `±100` confirm a +strong, uninterrupted trend; values oscillating near zero confirm a +range). Use it where the two-line `Aroon` is more detail than you need. + +## Common pitfalls + +- **Feeding it scalar prices.** It needs `high`/`low`; it takes a + `Candle`, not an `f64`. +- **Expecting the `[0, 100]` Aroon scale.** The oscillator is signed and + spans `[−100, 100]`. + +## References + +Tushar Chande's Aroon system (1995); the oscillator is the standard +`AroonUp − AroonDown` difference. + +## See also + +- [Indicator-Aroon.md](Indicator-Aroon.md) — the two-line indicator this + collapses. +- [Indicator-Adx.md](Indicator-Adx.md) — another trend-strength gauge. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/momentum/Indicator-MassIndex.md b/docs/wiki/indicators/momentum/Indicator-MassIndex.md new file mode 100644 index 00000000..bfc96547 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-MassIndex.md @@ -0,0 +1,173 @@ +# MassIndex + +> Mass Index — Donald Dorsey's range-expansion indicator: it watches the +> high–low range widen and contract to anticipate reversals. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum (trend strength) | +| Sub-category | Range expansion | +| Input type | `Candle` (uses `high`, `low`) | +| Output type | `f64` | +| Output range | `> 0`, oscillates around `sum_period` | +| Default parameters | `(ema_period = 9, sum_period = 25)` (Python) | +| Warmup period | `2·ema_period + sum_period − 2` | +| Interpretation | A rise above `27` then fall below `26.5` flags a reversal. | + +## Formula + +``` +range_t = high_t − low_t +single_t = EMA(range, ema_period)_t +double_t = EMA(single, ema_period)_t +ratio_t = single_t / double_t +MassIndex = Σ ratio over sum_period +``` + +The Mass Index ignores direction entirely — it tracks **volatility shape**. +When the high–low range widens, the single EMA pulls ahead of the double +EMA, the ratio climbs above `1`, and the windowed sum rises. Dorsey's +"reversal bulge" is the classic pattern: the Mass Index rising above `27` +and then falling back below `26.5` warns that a range expansion is about +to resolve — often into a trend reversal. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|--------------|---------|---------------|-------------|-------------| +| `ema_period` | `usize` | `9` (Python) | `>= 1` | Period of both EMAs in the cascade. `0` errors with `Error::PeriodZero`. | +| `sum_period` | `usize` | `25` (Python) | `>= 1` | Length of the summation window. | + +The Python binding defaults the pair to `(9, 25)`. The `periods` property +returns `(ema_period, sum_period)`. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/mass_index.rs`: + +```rust +impl Indicator for MassIndex { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`MassIndex` is a **candle-input** indicator: it reads `high` and `low`. In +Python the streaming `update` accepts a 6-tuple or a dict; the batch +helper takes `high` and `low` numpy arrays. Node and WASM expose +`update(high, low)` and `batch(high, low)`. + +## Warmup + +`warmup_period() == 2·ema_period + sum_period − 2`. The first EMA seeds at +input `ema_period`; the second EMA, stacked on it, seeds at +`2·ema_period − 1`; the summation window then needs `sum_period` ratios. +For the default `(9, 25)` that is `41` bars. + +## Edge cases + +- **Constant range.** When every bar has the same high–low range, both + EMAs converge to the same value, every ratio is `1`, and the Mass Index + equals `sum_period` (`constant_range_sums_to_sum_period` pins this). +- **Zero-range market.** A flat market (`high == low`) drives both EMAs to + `0`; the `0 / 0` is guarded with the neutral ratio `1`, so the Mass + Index again equals `sum_period` + (`zero_range_market_sums_to_sum_period` pins this). +- **Candle validation.** `Candle::new` rejects invalid bars upstream. +- **Reset.** `mi.reset()` clears both EMAs, the window and the sum. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, MassIndex}; + +fn main() -> Result<(), Box> { + let mut mi = MassIndex::new(3, 4)?; + // Constant high-low range of 2.0; the Mass Index settles at sum_period. + let candles: Vec = (0..40) + .map(|i| { + let mid = 100.0 + f64::from(i); + Candle::new(mid, mid + 1.0, mid - 1.0, mid, 1.0, i64::from(i)).unwrap() + }) + .collect(); + let out = mi.batch(&candles); + println!("warmup_period = {}", mi.warmup_period()); + println!("last = {:?}", out.last().unwrap()); + Ok(()) +} +``` + +Output: + +``` +warmup_period = 8 +last = Some(4.0) +``` + +A constant range makes every ratio `1`, so the sum equals `sum_period` +(`4`). This matches the `constant_range_sums_to_sum_period` test in +`crates/wickra-core/src/indicators/mass_index.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +mi = ta.MassIndex() # (ema_period=9, sum_period=25) +mid = np.arange(100.0, 160.0) +high = mid + 1.0 +low = mid - 1.0 +print(mi.batch(high, low)[-1]) # constant range -> 25 +``` + +Output: + +``` +25.0 +``` + +### Node + +```javascript +const ta = require('wickra'); +const mi = new ta.MassIndex(9, 25); +const mid = Array.from({ length: 60 }, (_, i) => 100 + i); +const high = mid.map((m) => m + 1); +const low = mid.map((m) => m - 1); +console.log(mi.batch(high, low).at(-1)); // 25 +``` + +## Interpretation + +`MassIndex` is a *reversal-warning* tool, not a direction tool — it never +tells you which way price will go, only that a turn is likely. The textbook +use is the "reversal bulge" on the default `(9, 25)` settings: watch for +the index to push above `27`, then act when it drops back under `26.5`, +using a directional indicator (a moving average, ADX) to pick the side. + +## Common pitfalls + +- **Expecting a direction.** The Mass Index is direction-blind; always + pair it with a trend indicator. +- **Feeding it scalar prices.** It needs `high`/`low`; it takes a + `Candle`, not an `f64`. + +## References + +Donald Dorsey, "The Mass Index", *Technical Analysis of Stocks & +Commodities* (1992). The double-EMA-of-range construction and the `(9, +25)` defaults follow Dorsey's original. + +## See also + +- [Indicator-Atr.md](../volatility/Indicator-Atr.md) — directional-free + volatility in price units. +- [Indicator-BollingerBands.md](../volatility/Indicator-BollingerBands.md) + — another range-expansion lens. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/momentum/Indicator-Vortex.md b/docs/wiki/indicators/momentum/Indicator-Vortex.md new file mode 100644 index 00000000..70800cc3 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Vortex.md @@ -0,0 +1,160 @@ +# Vortex + +> Vortex Indicator — a pair of oscillators (`VI+`, `VI−`) whose crossings +> identify the start of a new trend. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum (trend strength) | +| Sub-category | Directional | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `VortexOutput { plus, minus }` | +| Output range | each line `>= 0`, typically around `1.0` | +| Default parameters | `period = 14` (Python) | +| Warmup period | `period + 1` | +| Interpretation | `VI+` above `VI−` = up-trend; the cross marks the turn. | + +## Formula + +``` +VM+_t = |high_t − low_{t−1}| (positive vortex movement) +VM−_t = |low_t − high_{t−1}| (negative vortex movement) +TR_t = true range +VI+ = Σ VM+ over period / Σ TR over period +VI− = Σ VM− over period / Σ TR over period +``` + +Each vortex movement measures how far this bar reached against the +*opposite* extreme of the previous bar; dividing the running sums by the +running true range normalises both lines to a comparable scale around +`1.0`. `VI+` crossing above `VI−` signals a new up-trend; the reverse, a +down-trend. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------------|-------------|-------------| +| `period` | `usize` | `14` (Python) | `>= 1` | Summation window. `0` errors with `Error::PeriodZero`. | + +The Python binding defaults `period` to `14`. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/vortex.rs`: + +```rust +pub struct VortexOutput { pub plus: f64, pub minus: f64 } + +impl Indicator for Vortex { + type Input = Candle; + type Output = VortexOutput; +} +``` + +`Vortex` is a **candle-input** indicator reading `high`, `low` and +`close`. The streaming `update` returns `VortexOutput` (Rust), +`(plus, minus)` (Python), or `{ plus, minus }` (Node/WASM). The batch +helper returns one row per input — a `(n, 2)` numpy array in Python, a +flat `[plus, minus, …]` array of length `2·n` in Node/WASM, with `NaN` +during warmup. + +## Warmup + +`Vortex::new(period).warmup_period() == period + 1`. The first VM/TR +triple needs a previous bar, so it forms on bar 2; the summation window +then needs `period` triples — the first output lands on input +`period + 1`. + +## Edge cases + +- **Flat market.** A window with zero total true range cannot be + normalised; both lines are reported as `0.0` + (`perfectly_flat_market_yields_zero` pins this). +- **Non-negative.** Both `VI+` and `VI−` are sums of absolute values over + a non-negative range, so neither is ever negative + (`outputs_are_non_negative` pins this). +- **Candle validation.** `Candle::new` rejects invalid bars upstream. +- **Reset.** `vortex.reset()` clears the previous bar, the window and the + three running sums. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, Vortex}; + +fn main() -> Result<(), Box> { + let candles = [ + Candle::new(9.0, 10.0, 8.0, 9.0, 1.0, 0)?, + Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 1)?, + Candle::new(12.0, 13.0, 11.0, 12.0, 1.0, 2)?, + ]; + let mut v = Vortex::new(2)?; + let out = v.batch(&candles); + println!("{:?}", out[2]); + Ok(()) +} +``` + +Output: + +``` +Some(VortexOutput { plus: 1.6, minus: 0.4 }) +``` + +Over the two formed bars `Σ VM+ = 8`, `Σ VM− = 2`, `Σ TR = 5`, giving +`VI+ = 1.6` and `VI− = 0.4`. This matches the `reference_values` test in +`crates/wickra-core/src/indicators/vortex.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +v = ta.Vortex(14) +high = np.array([10.0, 12.0, 13.0]) +low = np.array([8.0, 9.0, 11.0]) +close = np.array([9.0, 11.0, 12.0]) +# v.batch(high, low, close) -> (3, 2) array of [plus, minus], NaN during warmup +print(v.update((9.0, 10.0, 8.0, 9.0, 1.0, 0))) +``` + +### Node + +```javascript +const ta = require('wickra'); +const v = new ta.Vortex(14); +console.log(v.update(12, 9, 11)); // { plus, minus } or null during warmup +``` + +## Interpretation + +`Vortex` is a trend-onset detector. The signal is the **crossing**: when +`VI+` rises above `VI−`, a new up-trend is starting; when `VI−` rises +above `VI+`, a down-trend. The gap between the lines measures conviction — +a wide, widening gap is a strong trend, converging lines warn of a stall. +Unlike a lagging moving-average cross, the vortex movements react to the +*reach* of each bar, so the cross tends to fire early. + +## Common pitfalls + +- **Reading the lines in isolation.** A `VI+` of `1.1` means nothing on + its own — what matters is its position relative to `VI−`. +- **Feeding it scalar prices.** It needs `high`/`low`/`close`. + +## References + +Etienne Botes and Douglas Siepman, "The Vortex Indicator", *Technical +Analysis of Stocks & Commodities* (2010). The `VM±` / true-range +definition here follows their original. + +## See also + +- [Indicator-Adx.md](Indicator-Adx.md) — Wilder's directional system. +- [Indicator-Atr.md](../volatility/Indicator-Atr.md) — the true range + Vortex normalises against. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.