From e452d35a27c3fc3da141bb8681587686eb0f4de9 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 22 May 2026 20:57:52 +0200 Subject: [PATCH] F13a: add Accelerator Oscillator, Balance of Power, Choppiness Index and Vertical Horizontal Filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the eight indicators that fill out the new family taxonomy. - Rust core: accelerator_oscillator.rs (AcceleratorOscillator — AO minus a short SMA of itself), balance_of_power.rs (BalanceOfPower — per-bar (close-open)/(high-low)), choppiness_index.rs (ChoppinessIndex — summed true range over the high-low span, log-scaled) and vertical_horizontal_filter.rs (VerticalHorizontalFilter — net move over total move). Each with a full Indicator impl, runnable doctest and reference / property / warmup / reset / batch==streaming tests. - Python / Node / WASM: classes wired through all three bindings (BalanceOfPower carries an explicit open column; VHF rides the scalar macros) plus .pyi stubs and __init__.py / __all__ entries. - Wiki: four new Indicator-*.md pages. The eight-family taxonomy restructure (Overview / Home / README / folder layout) lands in F13c once F13b's four indicators are in. cargo fmt + clippy (core/wickra/data/wasm/node) clean; 481 core tests, 25 data tests and 70 doctests green. --- bindings/node/index.js | 6 +- bindings/node/src/lib.rs | 182 +++++++++++++ bindings/python/python/wickra/__init__.py | 8 + bindings/python/python/wickra/__init__.pyi | 55 ++++ bindings/python/src/lib.rs | 251 ++++++++++++++++++ bindings/wasm/src/lib.rs | 130 +++++++++ .../src/indicators/accelerator_oscillator.rs | 194 ++++++++++++++ .../src/indicators/balance_of_power.rs | 168 ++++++++++++ .../src/indicators/choppiness_index.rs | 220 +++++++++++++++ crates/wickra-core/src/indicators/mod.rs | 8 + .../indicators/vertical_horizontal_filter.rs | 202 ++++++++++++++ crates/wickra-core/src/lib.rs | 20 +- .../Indicator-AcceleratorOscillator.md | 144 ++++++++++ .../momentum/Indicator-BalanceOfPower.md | 138 ++++++++++ .../momentum/Indicator-ChoppinessIndex.md | 146 ++++++++++ .../Indicator-VerticalHorizontalFilter.md | 140 ++++++++++ 16 files changed, 2001 insertions(+), 11 deletions(-) create mode 100644 crates/wickra-core/src/indicators/accelerator_oscillator.rs create mode 100644 crates/wickra-core/src/indicators/balance_of_power.rs create mode 100644 crates/wickra-core/src/indicators/choppiness_index.rs create mode 100644 crates/wickra-core/src/indicators/vertical_horizontal_filter.rs create mode 100644 docs/wiki/indicators/momentum/Indicator-AcceleratorOscillator.md create mode 100644 docs/wiki/indicators/momentum/Indicator-BalanceOfPower.md create mode 100644 docs/wiki/indicators/momentum/Indicator-ChoppinessIndex.md create mode 100644 docs/wiki/indicators/momentum/Indicator-VerticalHorizontalFilter.md diff --git a/bindings/node/index.js b/bindings/node/index.js index 976a0837..64702bbb 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, MOM, CMO, DPO, StdDev, UlcerIndex, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -330,6 +330,7 @@ module.exports.CMO = CMO module.exports.DPO = DPO module.exports.StdDev = StdDev module.exports.UlcerIndex = UlcerIndex +module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter module.exports.MACD = MACD module.exports.BollingerBands = BollingerBands module.exports.ATR = ATR @@ -364,6 +365,9 @@ module.exports.MedianPrice = MedianPrice module.exports.WeightedClose = WeightedClose module.exports.LinearRegression = LinearRegression module.exports.LinRegSlope = LinRegSlope +module.exports.AcceleratorOscillator = AcceleratorOscillator +module.exports.BalanceOfPower = BalanceOfPower +module.exports.ChoppinessIndex = ChoppinessIndex module.exports.BollingerBandwidth = BollingerBandwidth module.exports.PercentB = PercentB module.exports.NATR = NATR diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index daa8e4ba..a905c674 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -110,6 +110,11 @@ node_scalar_indicator!(CmoNode, "CMO", wc::Cmo); node_scalar_indicator!(DpoNode, "DPO", wc::Dpo); node_scalar_indicator!(StdDevNode, "StdDev", wc::StdDev); node_scalar_indicator!(UlcerIndexNode, "UlcerIndex", wc::UlcerIndex); +node_scalar_indicator!( + VerticalHorizontalFilterNode, + "VerticalHorizontalFilter", + wc::VerticalHorizontalFilter +); // ============================== MACD ============================== @@ -2034,6 +2039,183 @@ impl LinRegSlopeNode { } } +// ============================== Accelerator Oscillator ============================== + +#[napi(js_name = "AcceleratorOscillator")] +pub struct AcceleratorOscillatorNode { + inner: wc::AcceleratorOscillator, +} + +#[napi] +impl AcceleratorOscillatorNode { + #[napi(constructor)] + pub fn new(ao_fast: u32, ao_slow: u32, signal_period: u32) -> napi::Result { + Ok(Self { + inner: wc::AcceleratorOscillator::new( + ao_fast as usize, + ao_slow as usize, + signal_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 + } +} + +// ============================== Balance of Power ============================== + +#[napi(js_name = "BalanceOfPower")] +pub struct BalanceOfPowerNode { + inner: wc::BalanceOfPower, +} + +impl Default for BalanceOfPowerNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl BalanceOfPowerNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::BalanceOfPower::new(), + } + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(candle)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "open, high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(open.len()); + for i in 0..open.len() { + let candle = + wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).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 + } +} + +// ============================== Choppiness Index ============================== + +#[napi(js_name = "ChoppinessIndex")] +pub struct ChoppinessIndexNode { + inner: wc::ChoppinessIndex, +} + +#[napi] +impl ChoppinessIndexNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::ChoppinessIndex::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)?)) + } + #[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 mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 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 + } +} + // ============================== Bollinger Bandwidth ============================== #[napi(js_name = "BollingerBandwidth")] diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index d65eb202..0b08f7e7 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -62,6 +62,10 @@ from ._wickra import ( AroonOscillator, Vortex, MassIndex, + AcceleratorOscillator, + BalanceOfPower, + ChoppinessIndex, + VerticalHorizontalFilter, # Volatility BollingerBands, ATR, @@ -134,6 +138,10 @@ __all__ = [ "AroonOscillator", "Vortex", "MassIndex", + "AcceleratorOscillator", + "BalanceOfPower", + "ChoppinessIndex", + "VerticalHorizontalFilter", # Volatility "BollingerBands", "ATR", diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index b5c3dcbb..9ecc689b 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -295,6 +295,61 @@ class LinRegSlope: @property def period(self) -> int: ... +class AcceleratorOscillator: + def __init__( + self, ao_fast: int = 5, ao_slow: int = 34, signal_period: int = 5 + ) -> 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 params(self) -> Tuple[int, int, int]: ... + +class BalanceOfPower: + def __init__(self) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + open: NDArray[np.float64], + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + +class ChoppinessIndex: + 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], + close: 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: ... + +class VerticalHorizontalFilter: + def __init__(self, period: int = 28) -> 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: ... + class BollingerBandwidth: def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ... def update(self, value: float) -> Optional[float]: ... diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index ad08aca7..bea5a364 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -3856,6 +3856,253 @@ impl PyLinRegSlope { } } +// ============================== Accelerator Oscillator ============================== + +#[pyclass(name = "AcceleratorOscillator", module = "wickra._wickra")] +#[derive(Clone)] +struct PyAcceleratorOscillator { + inner: wc::AcceleratorOscillator, +} + +#[pymethods] +impl PyAcceleratorOscillator { + #[new] + #[pyo3(signature = (ao_fast=5, ao_slow=34, signal_period=5))] + fn new(ao_fast: usize, ao_slow: usize, signal_period: usize) -> PyResult { + Ok(Self { + inner: wc::AcceleratorOscillator::new(ao_fast, ao_slow, signal_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 columns high, low (both 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 params(&self) -> (usize, usize, usize) { + self.inner.params() + } + 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 (f, s, sig) = self.inner.params(); + format!("AcceleratorOscillator(ao_fast={f}, ao_slow={s}, signal_period={sig})") + } +} + +// ============================== Balance of Power ============================== + +#[pyclass(name = "BalanceOfPower", module = "wickra._wickra")] +#[derive(Clone)] +struct PyBalanceOfPower { + inner: wc::BalanceOfPower, +} + +#[pymethods] +impl PyBalanceOfPower { + #[new] + fn new() -> Self { + Self { + inner: wc::BalanceOfPower::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns open, high, low, close (all equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + 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 o.len() != h.len() || h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "open, high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(o.len()); + for i in 0..o.len() { + let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + 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 { + "BalanceOfPower()".to_string() + } +} + +// ============================== Choppiness Index ============================== + +#[pyclass(name = "ChoppinessIndex", module = "wickra._wickra")] +#[derive(Clone)] +struct PyChoppinessIndex { + inner: wc::ChoppinessIndex, +} + +#[pymethods] +impl PyChoppinessIndex { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::ChoppinessIndex::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 columns high, low, close (all equal length). + 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 mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 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() + } + 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!("ChoppinessIndex(period={})", self.inner.period()) + } +} + +// ============================== Vertical Horizontal Filter ============================== + +#[pyclass(name = "VerticalHorizontalFilter", module = "wickra._wickra")] +#[derive(Clone)] +struct PyVerticalHorizontalFilter { + inner: wc::VerticalHorizontalFilter, +} + +#[pymethods] +impl PyVerticalHorizontalFilter { + #[new] + #[pyo3(signature = (period=28))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::VerticalHorizontalFilter::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() + } + 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!("VerticalHorizontalFilter(period={})", self.inner.period()) + } +} + // ============================== Module ============================== #[pymodule] @@ -3924,5 +4171,9 @@ 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::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 52b5711c..179e7dd9 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -94,6 +94,7 @@ wasm_scalar_indicator!(WasmBollingerBandwidth, "BollingerBandwidth", wc::Bolling wasm_scalar_indicator!(WasmPercentB, "PercentB", wc::PercentB, period: usize, multiplier: f64); wasm_scalar_indicator!(WasmLinearRegression, "LinearRegression", wc::LinearRegression, period: usize); wasm_scalar_indicator!(WasmLinRegSlope, "LinRegSlope", wc::LinRegSlope, period: usize); +wasm_scalar_indicator!(WasmVerticalHorizontalFilter, "VerticalHorizontalFilter", wc::VerticalHorizontalFilter, period: usize); // ---------- KAMA (three params) ---------- @@ -970,6 +971,135 @@ impl WasmWeightedClose { } } +#[wasm_bindgen(js_name = AcceleratorOscillator)] +pub struct WasmAcceleratorOscillator { + inner: wc::AcceleratorOscillator, +} + +#[wasm_bindgen(js_class = AcceleratorOscillator)] +impl WasmAcceleratorOscillator { + #[wasm_bindgen(constructor)] + pub fn new( + ao_fast: usize, + ao_slow: usize, + signal_period: usize, + ) -> Result { + Ok(Self { + inner: wc::AcceleratorOscillator::new(ao_fast, ao_slow, signal_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 = BalanceOfPower)] +pub struct WasmBalanceOfPower { + inner: wc::BalanceOfPower, +} + +impl Default for WasmBalanceOfPower { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = BalanceOfPower)] +impl WasmBalanceOfPower { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmBalanceOfPower { + Self { + inner: wc::BalanceOfPower::new(), + } + } + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> Result, JsError> { + let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = open.len(); + if high.len() != n || low.len() != n || close.len() != n { + return Err(JsError::new("open, high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + 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 = ChoppinessIndex)] +pub struct WasmChoppinessIndex { + inner: wc::ChoppinessIndex, +} + +#[wasm_bindgen(js_class = ChoppinessIndex)] +impl WasmChoppinessIndex { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::ChoppinessIndex::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, JsError> { + let c = make_candle(high, low, close, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn 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::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + #[wasm_bindgen(js_name = NATR)] pub struct WasmNatr { inner: wc::Natr, diff --git a/crates/wickra-core/src/indicators/accelerator_oscillator.rs b/crates/wickra-core/src/indicators/accelerator_oscillator.rs new file mode 100644 index 00000000..16d98447 --- /dev/null +++ b/crates/wickra-core/src/indicators/accelerator_oscillator.rs @@ -0,0 +1,194 @@ +//! Accelerator Oscillator (Bill Williams). + +use crate::error::Result; +use crate::indicators::awesome_oscillator::AwesomeOscillator; +use crate::indicators::sma::Sma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Accelerator Oscillator — Bill Williams' gauge of *momentum's acceleration*. +/// +/// ```text +/// AO = SMA(median, fast) − SMA(median, slow) (the Awesome Oscillator) +/// AC = AO − SMA(AO, signal) +/// ``` +/// +/// Where the [`AwesomeOscillator`](crate::AwesomeOscillator) tracks momentum, +/// the Accelerator tracks the *change* in momentum: it is the AO minus a short +/// moving average of itself. Because acceleration leads speed, `AC` tends to +/// turn before the `AO` does. Bill Williams' classic configuration is the +/// `(5, 34)` AO with a `5`-period signal average. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, AcceleratorOscillator}; +/// +/// let mut indicator = AcceleratorOscillator::classic(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct AcceleratorOscillator { + ao: AwesomeOscillator, + signal: Sma, + ao_fast: usize, + ao_slow: usize, + signal_period: usize, +} + +impl AcceleratorOscillator { + /// Construct an Accelerator Oscillator with explicit AO and signal periods. + /// + /// # Errors + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) for a zero + /// period and [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if the + /// AO `fast` period is not strictly below `slow`. + pub fn new(ao_fast: usize, ao_slow: usize, signal_period: usize) -> Result { + Ok(Self { + ao: AwesomeOscillator::new(ao_fast, ao_slow)?, + signal: Sma::new(signal_period)?, + ao_fast, + ao_slow, + signal_period, + }) + } + + /// Bill Williams' classic configuration: `AO(5, 34)` with a `5`-period signal. + pub fn classic() -> Self { + Self::new(5, 34, 5).expect("classic Accelerator Oscillator params are valid") + } + + /// Configured `(ao_fast, ao_slow, signal_period)`. + pub const fn params(&self) -> (usize, usize, usize) { + (self.ao_fast, self.ao_slow, self.signal_period) + } +} + +impl Indicator for AcceleratorOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let ao = self.ao.update(candle)?; + let signal = self.signal.update(ao)?; + Some(ao - signal) + } + + fn reset(&mut self) { + self.ao.reset(); + self.signal.reset(); + } + + fn warmup_period(&self) -> usize { + // The AO emits at candle `ao_slow`; the signal SMA then needs + // `signal_period` AO values. + self.ao_slow + self.signal_period - 1 + } + + fn is_ready(&self) -> bool { + self.signal.is_ready() + } + + fn name(&self) -> &'static str { + "AcceleratorOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn constant_series_yields_zero() { + // A flat market gives AO = 0, so its signal average and AC are 0 too. + let candles: Vec = (0..80).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ac = AcceleratorOscillator::classic(); + for v in ac.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn matches_independent_ao_and_signal() { + let candles: Vec = (0..90) + .map(|i| { + let m = 100.0 + (i as f64 * 0.2).sin() * 6.0; + c(m + 1.5, m - 1.5, m + 0.3, i) + }) + .collect(); + let mut ac = AcceleratorOscillator::classic(); + let mut ao = AwesomeOscillator::classic(); + let mut signal = Sma::new(5).unwrap(); + for (i, candle) in candles.iter().enumerate() { + let got = ac.update(*candle); + match ao.update(*candle) { + Some(ao_val) => match signal.update(ao_val) { + Some(sig) => { + assert_relative_eq!(got.unwrap(), ao_val - sig, epsilon = 1e-9); + } + None => assert!(got.is_none(), "i={i}"), + }, + None => assert!(got.is_none(), "i={i}"), + } + } + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ac = AcceleratorOscillator::classic(); + let out = ac.batch(&candles); + assert_eq!(ac.warmup_period(), 38); + for (i, v) in out.iter().enumerate().take(37) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[37].is_some(), "first value lands at warmup_period - 1"); + } + + #[test] + fn rejects_invalid_params() { + assert!(AcceleratorOscillator::new(0, 34, 5).is_err()); + assert!(AcceleratorOscillator::new(5, 34, 0).is_err()); + assert!(AcceleratorOscillator::new(34, 5, 5).is_err()); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ac = AcceleratorOscillator::classic(); + ac.batch(&candles); + assert!(ac.is_ready()); + ac.reset(); + assert!(!ac.is_ready()); + assert_eq!(ac.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..90) + .map(|i| { + let m = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(m + 1.5, m - 1.5, m + 0.5, i) + }) + .collect(); + let mut a = AcceleratorOscillator::classic(); + let mut b = AcceleratorOscillator::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/balance_of_power.rs b/crates/wickra-core/src/indicators/balance_of_power.rs new file mode 100644 index 00000000..1e7a5f59 --- /dev/null +++ b/crates/wickra-core/src/indicators/balance_of_power.rs @@ -0,0 +1,168 @@ +//! Balance of Power. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Balance of Power — where the close settled within the bar's range relative +/// to the open. +/// +/// ```text +/// BOP = (close − open) / (high − low) +/// ``` +/// +/// The result lives in `[−1, +1]`: `+1` is a bar that opened on its low and +/// closed on its high (buyers in full control), `−1` the mirror image. It is +/// a stateless per-bar reading — a quick gauge of intrabar conviction. A +/// zero-range bar carries no information and yields `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, BalanceOfPower}; +/// +/// let mut indicator = BalanceOfPower::new(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct BalanceOfPower { + has_emitted: bool, +} + +impl BalanceOfPower { + /// Construct a new Balance of Power transform. + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for BalanceOfPower { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + let range = candle.high - candle.low; + let bop = if range == 0.0 { + // A zero-range bar carries no directional information. + 0.0 + } else { + (candle.close - candle.open) / range + }; + Some(bop) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "BalanceOfPower" + } +} + +#[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 reference_value() { + // (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5. + let mut bop = BalanceOfPower::new(); + assert_relative_eq!( + bop.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(), + 0.5, + epsilon = 1e-12 + ); + } + + #[test] + fn close_on_high_after_open_on_low_is_plus_one() { + let mut bop = BalanceOfPower::new(); + // open == low, close == high -> BOP = +1. + assert_relative_eq!( + bop.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(), + 1.0, + epsilon = 1e-12 + ); + } + + #[test] + fn stays_within_unit_range() { + let candles: Vec = (0..100) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.2).sin() * 8.0; + let close = mid + (i as f64 * 0.5).cos() * 2.0; + candle(mid, mid + 3.0, mid - 3.0, close, i) + }) + .collect(); + let mut bop = BalanceOfPower::new(); + for v in bop.batch(&candles).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "BOP {v} outside [-1, 1]"); + } + } + + #[test] + fn zero_range_bar_yields_zero() { + let mut bop = BalanceOfPower::new(); + assert_relative_eq!( + bop.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(), + 0.0, + epsilon = 1e-12 + ); + } + + #[test] + fn emits_from_first_candle() { + let mut bop = BalanceOfPower::new(); + assert_eq!(bop.warmup_period(), 1); + assert!(!bop.is_ready()); + assert!(bop.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some()); + assert!(bop.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut bop = BalanceOfPower::new(); + bop.update(candle(10.0, 11.0, 9.0, 10.0, 0)); + assert!(bop.is_ready()); + bop.reset(); + assert!(!bop.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 2.0, base - 2.0, base + 1.0, i) + }) + .collect(); + let mut a = BalanceOfPower::new(); + let mut b = BalanceOfPower::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/choppiness_index.rs b/crates/wickra-core/src/indicators/choppiness_index.rs new file mode 100644 index 00000000..04b1b304 --- /dev/null +++ b/crates/wickra-core/src/indicators/choppiness_index.rs @@ -0,0 +1,220 @@ +//! Choppiness Index. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Choppiness Index — is the market trending or just chopping sideways? +/// +/// ```text +/// CI = 100 · log10( Σ(TR, n) / (highest_high(n) − lowest_low(n)) ) / log10(n) +/// ``` +/// +/// The ratio compares the *distance price actually travelled* (the summed true +/// range) with the *net ground it covered* (the high-low span of the window). +/// A clean trend travels almost exactly its span, so the ratio is near `1` and +/// `CI` near `0`; a choppy market criss-crosses far more than its span, so the +/// ratio is large and `CI` climbs toward `100`. The conventional reading is +/// `CI > 61.8` ranging, `CI < 38.2` trending. A perfectly flat window yields +/// `100` by convention. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ChoppinessIndex}; +/// +/// let mut indicator = ChoppinessIndex::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ChoppinessIndex { + period: usize, + log_n: f64, + prev_close: Option, + tr_window: VecDeque, + tr_sum: f64, + highs: VecDeque, + lows: VecDeque, +} + +impl ChoppinessIndex { + /// Construct a new Choppiness Index over `period` bars. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`] if `period < 2` — the `log10(period)` + /// denominator is zero for `period == 1` and undefined for `period == 0`. + pub fn new(period: usize) -> Result { + if period < 2 { + return Err(Error::InvalidPeriod { + message: "choppiness index needs period >= 2", + }); + } + Ok(Self { + period, + log_n: (period as f64).log10(), + prev_close: None, + tr_window: VecDeque::with_capacity(period), + tr_sum: 0.0, + highs: VecDeque::with_capacity(period), + lows: VecDeque::with_capacity(period), + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for ChoppinessIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let tr = candle.true_range(self.prev_close); + self.prev_close = Some(candle.close); + + if self.tr_window.len() == self.period { + self.tr_sum -= self.tr_window.pop_front().expect("non-empty"); + self.highs.pop_front(); + self.lows.pop_front(); + } + self.tr_window.push_back(tr); + self.tr_sum += tr; + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + + if self.tr_window.len() < self.period { + return None; + } + let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min); + let span = highest - lowest; + if span == 0.0 { + // A perfectly flat window: maximal choppiness by convention. + return Some(100.0); + } + Some(100.0 * (self.tr_sum / span).log10() / self.log_n) + } + + fn reset(&mut self) { + self.prev_close = None; + self.tr_window.clear(); + self.tr_sum = 0.0; + self.highs.clear(); + self.lows.clear(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.tr_window.len() == self.period + } + + fn name(&self) -> &'static str { + "ChoppinessIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn reference_value_equal_range_bars() { + // Two H=11 L=9 C=10 bars: TR = 2 each, ΣTR = 4; span = 11 - 9 = 2. + // CI = 100 · log10(4 / 2) / log10(2) = 100. + let mut ci = ChoppinessIndex::new(2).unwrap(); + let out = ci.batch(&[c(11.0, 9.0, 10.0, 0), c(11.0, 9.0, 10.0, 1)]); + assert!(out[0].is_none()); + assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9); + } + + #[test] + fn flat_window_yields_hundred() { + let candles: Vec = (0..20).map(|i| c(10.0, 10.0, 10.0, i)).collect(); + let mut ci = ChoppinessIndex::new(14).unwrap(); + for v in ci.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 100.0, epsilon = 1e-9); + } + } + + #[test] + fn steady_trend_reads_low() { + // A clean one-directional march travels close to its span -> low CI. + let candles: Vec = (0..60) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut ci = ChoppinessIndex::new(14).unwrap(); + for v in ci.batch(&candles).into_iter().flatten() { + assert!(v < 50.0, "a steady trend should read below 50, got {v}"); + assert!(v >= 0.0, "CI must be non-negative, got {v}"); + } + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ci = ChoppinessIndex::new(8).unwrap(); + let out = ci.batch(&candles); + assert_eq!(ci.warmup_period(), 8); + for (i, v) in out.iter().enumerate().take(7) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[7].is_some(), "first value lands at warmup_period - 1"); + } + + #[test] + fn rejects_period_below_two() { + assert!(ChoppinessIndex::new(0).is_err()); + assert!(ChoppinessIndex::new(1).is_err()); + assert!(ChoppinessIndex::new(2).is_ok()); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect(); + let mut ci = ChoppinessIndex::new(14).unwrap(); + ci.batch(&candles); + assert!(ci.is_ready()); + ci.reset(); + assert!(!ci.is_ready()); + assert_eq!(ci.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(mid + 1.5, mid - 1.5, mid + 0.5, i) + }) + .collect(); + let mut a = ChoppinessIndex::new(14).unwrap(); + let mut b = ChoppinessIndex::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 48d7a177..30bb6b2c 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -4,6 +4,7 @@ //! volume) but every public name is also re-exported flat from this module and //! from the crate root for convenience. +mod accelerator_oscillator; mod adl; mod adx; mod aroon; @@ -11,12 +12,14 @@ mod aroon_oscillator; mod atr; mod atr_trailing_stop; mod awesome_oscillator; +mod balance_of_power; mod bollinger; mod bollinger_bandwidth; mod cci; mod chaikin_oscillator; mod chande_kroll_stop; mod chandelier_exit; +mod choppiness_index; mod cmf; mod cmo; mod coppock; @@ -59,6 +62,7 @@ mod tsi; mod typical_price; mod ulcer_index; mod ultimate_oscillator; +mod vertical_horizontal_filter; mod vortex; mod vpt; mod vwap; @@ -68,6 +72,7 @@ mod williams_r; mod wma; mod zlema; +pub use accelerator_oscillator::AcceleratorOscillator; pub use adl::Adl; pub use adx::{Adx, AdxOutput}; pub use aroon::{Aroon, AroonOutput}; @@ -75,12 +80,14 @@ pub use aroon_oscillator::AroonOscillator; pub use atr::Atr; pub use atr_trailing_stop::AtrTrailingStop; pub use awesome_oscillator::AwesomeOscillator; +pub use balance_of_power::BalanceOfPower; pub use bollinger::{BollingerBands, BollingerOutput}; pub use bollinger_bandwidth::BollingerBandwidth; pub use cci::Cci; pub use chaikin_oscillator::ChaikinOscillator; pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput}; pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput}; +pub use choppiness_index::ChoppinessIndex; pub use cmf::ChaikinMoneyFlow; pub use cmo::Cmo; pub use coppock::Coppock; @@ -123,6 +130,7 @@ pub use tsi::Tsi; pub use typical_price::TypicalPrice; pub use ulcer_index::UlcerIndex; pub use ultimate_oscillator::UltimateOscillator; +pub use vertical_horizontal_filter::VerticalHorizontalFilter; pub use vortex::{Vortex, VortexOutput}; pub use vpt::VolumePriceTrend; pub use vwap::{RollingVwap, Vwap}; diff --git a/crates/wickra-core/src/indicators/vertical_horizontal_filter.rs b/crates/wickra-core/src/indicators/vertical_horizontal_filter.rs new file mode 100644 index 00000000..b1c72a53 --- /dev/null +++ b/crates/wickra-core/src/indicators/vertical_horizontal_filter.rs @@ -0,0 +1,202 @@ +//! Vertical Horizontal Filter. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Vertical Horizontal Filter — Adam White's trend-versus-range gauge. +/// +/// ```text +/// VHF = (highest_close(n) − lowest_close(n)) / Σ|close − close_prev|(n) +/// ``` +/// +/// The numerator is the *net* distance price covered over the window; the +/// denominator is the *total* distance it walked. Their ratio lives in +/// `[0, 1]`: a clean trend walks almost only in its net direction, so `VHF` +/// approaches `1`; a choppy market doubles back constantly, inflating the +/// denominator and pushing `VHF` toward `0`. It answers the same question as +/// the [`ChoppinessIndex`](crate::ChoppinessIndex) on an inverted scale. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, VerticalHorizontalFilter}; +/// +/// let mut indicator = VerticalHorizontalFilter::new(28).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VerticalHorizontalFilter { + period: usize, + closes: VecDeque, + prev_close: Option, + diffs: VecDeque, + diff_sum: f64, +} + +impl VerticalHorizontalFilter { + /// Construct a new Vertical Horizontal Filter over `period` closes. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + closes: VecDeque::with_capacity(period), + prev_close: None, + diffs: VecDeque::with_capacity(period), + diff_sum: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for VerticalHorizontalFilter { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.closes.len() == self.period { + self.closes.pop_front(); + } + self.closes.push_back(value); + + if let Some(prev) = self.prev_close { + let diff = (value - prev).abs(); + if self.diffs.len() == self.period { + self.diff_sum -= self.diffs.pop_front().expect("non-empty"); + } + self.diffs.push_back(diff); + self.diff_sum += diff; + } + self.prev_close = Some(value); + + if self.closes.len() < self.period || self.diffs.len() < self.period { + return None; + } + let highest = self + .closes + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let lowest = self.closes.iter().copied().fold(f64::INFINITY, f64::min); + if self.diff_sum == 0.0 { + // A flat window walked nowhere — no trend to filter. + return Some(0.0); + } + Some((highest - lowest) / self.diff_sum) + } + + fn reset(&mut self) { + self.closes.clear(); + self.prev_close = None; + self.diffs.clear(); + self.diff_sum = 0.0; + } + + fn warmup_period(&self) -> usize { + // `period` closes fill the high/low window; the `period`-th diff needs + // one extra input because the first input has nothing to diff against. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.diffs.len() == self.period + } + + fn name(&self) -> &'static str { + "VerticalHorizontalFilter" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn reference_values_pure_uptrend() { + // Closes 1,2,…: every diff is 1 (Σ = period), the n-close span is + // period − 1, so VHF = (period − 1) / period. For period 5: 4/5 = 0.8. + let mut vhf = VerticalHorizontalFilter::new(5).unwrap(); + let out = vhf.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + for (i, v) in out.iter().enumerate().take(5) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert_relative_eq!(out[5].unwrap(), 0.8, epsilon = 1e-12); + assert_eq!(vhf.warmup_period(), 6); + } + + #[test] + fn choppy_series_reads_low() { + // A market that oscillates between two prices covers a tiny net span + // while walking a long way -> VHF near zero. + let prices: Vec = (0..40) + .map(|i| if i % 2 == 0 { 10.0 } else { 11.0 }) + .collect(); + let mut vhf = VerticalHorizontalFilter::new(10).unwrap(); + for v in vhf.batch(&prices).into_iter().flatten() { + assert!(v < 0.2, "a choppy series should read low, got {v}"); + } + } + + #[test] + fn flat_series_yields_zero() { + let mut vhf = VerticalHorizontalFilter::new(8).unwrap(); + for v in vhf.batch(&[50.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn stays_within_unit_range() { + let prices: Vec = (0..120) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut vhf = VerticalHorizontalFilter::new(28).unwrap(); + for v in vhf.batch(&prices).into_iter().flatten() { + assert!((0.0..=1.0).contains(&v), "VHF {v} outside [0, 1]"); + } + } + + #[test] + fn rejects_zero_period() { + assert!(VerticalHorizontalFilter::new(0).is_err()); + } + + #[test] + fn reset_clears_state() { + let mut vhf = VerticalHorizontalFilter::new(8).unwrap(); + vhf.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]); + assert!(vhf.is_ready()); + vhf.reset(); + assert!(!vhf.is_ready()); + assert_eq!(vhf.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let mut a = VerticalHorizontalFilter::new(28).unwrap(); + let mut b = VerticalHorizontalFilter::new(28).unwrap(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index ed913e93..d6ec41d6 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -44,16 +44,16 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ - Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AtrTrailingStop, - AwesomeOscillator, BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, - ChaikinOscillator, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, - ChandelierExitOutput, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, - ForceIndex, HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, LinRegSlope, - LinearRegression, MacdIndicator, MacdOutput, MassIndex, MedianPrice, Mfi, Mom, Natr, Obv, - PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, Stochastic, - StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima, Trix, Tsi, TypicalPrice, - UlcerIndex, UltimateOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, - WeightedClose, WilliamsR, Wma, Zlema, T3, + AcceleratorOscillator, Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, + AtrTrailingStop, AwesomeOscillator, BalanceOfPower, BollingerBands, BollingerBandwidth, + BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChandeKrollStop, + ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, Cmo, Coppock, + Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, ForceIndex, HistoricalVolatility, + Hma, Kama, Keltner, KeltnerOutput, LinRegSlope, LinearRegression, MacdIndicator, MacdOutput, + MassIndex, MedianPrice, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, + Sma, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, Tema, + Trima, Trix, Tsi, TypicalPrice, UlcerIndex, UltimateOscillator, VerticalHorizontalFilter, + VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, WeightedClose, WilliamsR, Wma, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/docs/wiki/indicators/momentum/Indicator-AcceleratorOscillator.md b/docs/wiki/indicators/momentum/Indicator-AcceleratorOscillator.md new file mode 100644 index 00000000..65d0c8ba --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-AcceleratorOscillator.md @@ -0,0 +1,144 @@ +# AcceleratorOscillator + +> Accelerator Oscillator (AC) — Bill Williams' measure of how fast +> momentum itself is changing. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Price Oscillators | +| Input type | `Candle` (uses `high`, `low`) | +| Output type | `f64` | +| Output range | unbounded around zero | +| Default parameters | `ao_fast = 5`, `ao_slow = 34`, `signal_period = 5` (Python) | +| Warmup period | `ao_slow + signal_period − 1` | +| Interpretation | Acceleration of momentum; zero-line crossings lead the Awesome Oscillator. | + +## Formula + +``` +AO = SMA(median, ao_fast) − SMA(median, ao_slow) (the Awesome Oscillator) +AC = AO − SMA(AO, signal_period) +``` + +Where the [`AwesomeOscillator`](Indicator-AwesomeOscillator.md) measures +momentum, the Accelerator measures the *change* in momentum — it is the AO +minus a short moving average of itself. Because acceleration leads speed, the +`AC` tends to turn before the `AO` does. Bill Williams' classic configuration +is the `(5, 34)` AO with a `5`-period signal average. + +## Parameters + +- `ao_fast`, `ao_slow` — the underlying Awesome Oscillator periods (`5`, `34`). +- `signal_period` — the moving average of the AO subtracted from it (`5`). + +`AcceleratorOscillator::classic()` returns the `(5, 34, 5)` configuration. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/accelerator_oscillator.rs`: + +```rust +impl Indicator for AcceleratorOscillator { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +It is a **candle-input** indicator — the inner Awesome Oscillator reads the +median price `(high + low) / 2`. Python's streaming `update` accepts a 6-tuple +or a dict; the batch helper takes `high`, `low` numpy arrays. Node and WASM +expose `update(high, low)` and the matching `batch`. + +## Warmup + +`AcceleratorOscillator::classic().warmup_period() == 38`. The AO first emits at +candle `ao_slow`; the signal average then needs `signal_period` AO values. + +## Edge cases + +- **Flat market.** A flat series gives `AO = 0`, so `AC = 0` throughout. +- **`ao_fast >= ao_slow`.** Rejected at construction. +- **Reset.** `ac.reset()` clears the AO and the signal average. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, AcceleratorOscillator}; + +fn main() -> Result<(), Box> { + let mut ac = AcceleratorOscillator::classic(); + let candles: Vec = (0..60) + .map(|i| Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, i).unwrap()) + .collect(); + println!("{:?}", ac.batch(&candles).last().unwrap()); + Ok(()) +} +``` + +Output: + +``` +Some(0.0) +``` + +A flat market produces a flat AO and therefore a zero Accelerator. + +### Python + +```python +import numpy as np +import wickra as ta + +ac = ta.AcceleratorOscillator(5, 34, 5) +n = 60 +print(ac.batch(np.full(n, 11.0), np.full(n, 9.0))[-1]) +``` + +Output: + +``` +0.0 +``` + +### Node + +```javascript +const ta = require('wickra'); +const ac = new ta.AcceleratorOscillator(5, 34, 5); +const out = ac.batch(Array(60).fill(11), Array(60).fill(9)); +console.log(out[out.length - 1]); +``` + +Output: + +``` +0 +``` + +## Interpretation + +Trade the Accelerator like a momentum-acceleration gauge: bars rising above +the zero line mean momentum is building, bars falling below mean it is fading. +Because it leads the Awesome Oscillator, a colour change in the AC is an early +warning that the AO — and price momentum — is about to turn. + +## Common pitfalls + +- **Reading the level.** Only the sign and the slope matter; the magnitude + scales with the instrument. +- **Feeding it scalar prices.** It needs the `high`/`low` bar. + +## References + +Bill Williams' Accelerator Oscillator, from *Trading Chaos*. + +## See also + +- [Indicator-AwesomeOscillator.md](Indicator-AwesomeOscillator.md) — the + momentum oscillator the Accelerator is built on. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/momentum/Indicator-BalanceOfPower.md b/docs/wiki/indicators/momentum/Indicator-BalanceOfPower.md new file mode 100644 index 00000000..f679abd4 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-BalanceOfPower.md @@ -0,0 +1,138 @@ +# BalanceOfPower + +> Balance of Power (BOP) — where the bar closed within its range relative +> to where it opened. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Price Oscillators | +| Input type | `Candle` (uses `open`, `high`, `low`, `close`) | +| Output type | `f64` | +| Output range | `[−1, +1]` | +| Default parameters | none (no parameters) | +| Warmup period | `1` | +| Interpretation | Intrabar buyer/seller control; `+1` buyers, `−1` sellers. | + +## Formula + +``` +BOP = (close − open) / (high − low) +``` + +Balance of Power asks a single question per bar: did buyers or sellers win it? +A bar that opened on its low and closed on its high scores `+1` (buyers in +total control); the mirror image scores `−1`. It is a stateless per-bar +reading. A zero-range bar carries no information and yields `0`. + +## Parameters + +`BalanceOfPower` takes **no parameters** — `BalanceOfPower::new()` in Rust, +`wickra.BalanceOfPower()` in Python, `new ta.BalanceOfPower()` in Node. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/balance_of_power.rs`: + +```rust +impl Indicator for BalanceOfPower { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`BalanceOfPower` is a **candle-input** indicator that reads all four of +`open`, `high`, `low`, `close`. Python's streaming `update` accepts a 6-tuple +or a dict; the batch helper takes `open`, `high`, `low`, `close` numpy arrays. +Node and WASM expose `update(open, high, low, close)` and the matching +`batch`. + +## Warmup + +`BalanceOfPower::new().warmup_period() == 1`. It is a stateless per-bar +transform — it emits a value from the very first candle. + +## Edge cases + +- **Zero-range bar.** `high == low` yields `0` instead of dividing by zero. +- **Close on high, open on low.** Scores exactly `+1`. +- **Reset.** `bop.reset()` only clears the `is_ready` flag. + +## Examples + +### Rust + +```rust +use wickra::{Candle, Indicator, BalanceOfPower}; + +fn main() -> Result<(), Box> { + let mut bop = BalanceOfPower::new(); + // open 10, high 14, low 10, close 12 -> (12 - 10) / (14 - 10) = 0.5. + let v = bop.update(Candle::new(10.0, 14.0, 10.0, 12.0, 1.0, 0)?); + println!("{:?}", v); + Ok(()) +} +``` + +Output: + +``` +Some(0.5) +``` + +### Python + +```python +import numpy as np +import wickra as ta + +bop = ta.BalanceOfPower() +print(bop.batch( + np.array([10.0]), np.array([14.0]), np.array([10.0]), np.array([12.0]) +)) +``` + +Output: + +``` +[0.5] +``` + +### Node + +```javascript +const ta = require('wickra'); +const bop = new ta.BalanceOfPower(); +console.log(bop.batch([10], [14], [10], [12])); +``` + +Output: + +``` +[ 0.5 ] +``` + +## Interpretation + +A BOP holding above zero says buyers are consistently winning the bars — a +healthy uptrend; below zero is the seller's mirror. Because the raw per-bar +value is noisy, it is commonly smoothed with a short moving average before +trading the zero-line crossings, or read for divergence against price. + +## Common pitfalls + +- **Using the raw value as a trend signal.** Per-bar BOP whipsaws; smooth it. +- **Feeding it scalar prices.** It needs the full OHLC bar — including `open`. + +## References + +Balance of Power, popularised by Igor Livshin; the `(close − open) / +(high − low)` definition is the standard one. + +## See also + +- [Indicator-AwesomeOscillator.md](Indicator-AwesomeOscillator.md) — another + Bill Williams-era price oscillator. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/momentum/Indicator-ChoppinessIndex.md b/docs/wiki/indicators/momentum/Indicator-ChoppinessIndex.md new file mode 100644 index 00000000..ec671b31 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-ChoppinessIndex.md @@ -0,0 +1,146 @@ +# ChoppinessIndex + +> Choppiness Index — is the market trending or just chopping sideways? + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend & Directional | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `f64` | +| Output range | `[0, 100]` (typical) | +| Default parameters | `period = 14` (Python) | +| Warmup period | `period` | +| Interpretation | High = choppy/ranging, low = trending; `61.8` / `38.2` thresholds. | + +## Formula + +``` +CI = 100 · log10( Σ(TR, n) / (highest_high(n) − lowest_low(n)) ) / log10(n) +``` + +The ratio compares the distance price *actually travelled* (the summed true +range) with the *net ground it covered* (the high-low span of the window). A +clean trend travels almost exactly its span, so the ratio is near `1` and `CI` +near `0`; a choppy market criss-crosses far more than its span, so the ratio +is large and `CI` climbs toward `100`. The conventional reading is `CI > 61.8` +ranging, `CI < 38.2` trending. + +## Parameters + +`period` — the lookback window. Must be at least `2` (the `log10(period)` +denominator is zero for `period == 1`). The Python binding defaults it to `14`. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/choppiness_index.rs`: + +```rust +impl Indicator for ChoppinessIndex { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`ChoppinessIndex` is a **candle-input** indicator that reads `high`, `low` and +`close` (the close drives the true range across bar gaps). Python's streaming +`update` accepts a 6-tuple or a dict; the batch helper takes `high`, `low`, +`close` numpy arrays. Node and WASM expose `update(high, low, close)` and the +matching `batch`. + +## Warmup + +`ChoppinessIndex::new(14).warmup_period() == 14`. The first value lands once +the window holds a full `period` bars. + +## Edge cases + +- **Flat window.** A window with `high == low` everywhere has a zero span; + `CI` is defined as `100` (maximal choppiness). +- **Steady trend.** A one-directional march reads well below `50`. +- **`period < 2`.** Rejected at construction. +- **Reset.** `ci.reset()` clears the true-range and high/low windows. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, ChoppinessIndex}; + +fn main() -> Result<(), Box> { + let mut ci = ChoppinessIndex::new(2)?; + // Two H=11 L=9 C=10 bars: ΣTR = 4, span = 2 -> CI = 100·log10(2)/log10(2). + let out = ci.batch(&[ + Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0)?, + Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 1)?, + ]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[None, Some(100.0)] +``` + +### Python + +```python +import numpy as np +import wickra as ta + +ci = ta.ChoppinessIndex(2) +high = np.array([11.0, 11.0]) +low = np.array([9.0, 9.0]) +close = np.array([10.0, 10.0]) +print(ci.batch(high, low, close)) +``` + +Output: + +``` +[ nan 100.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const ci = new ta.ChoppinessIndex(2); +console.log(ci.batch([11, 11], [9, 9], [10, 10])); +``` + +Output: + +``` +[ NaN, 100 ] +``` + +## Interpretation + +The Choppiness Index is not directional — it does not say *which way* price is +going, only *whether* it is going anywhere. Use it as a regime filter: above +`61.8` favour mean-reversion / range tactics; below `38.2` favour +trend-following. It pairs naturally with a directional indicator that picks +the side once a trend is confirmed. + +## Common pitfalls + +- **Expecting a direction.** It has none — combine it with a trend indicator. +- **Tiny periods.** `period = 2` is allowed but noisy; `14` is conventional. + +## References + +E. W. Dreiss' Choppiness Index; the summed-true-range formulation here is the +standard one. + +## See also + +- [Indicator-VerticalHorizontalFilter.md](Indicator-VerticalHorizontalFilter.md) + — the same trending-vs-ranging question on an inverted scale. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/momentum/Indicator-VerticalHorizontalFilter.md b/docs/wiki/indicators/momentum/Indicator-VerticalHorizontalFilter.md new file mode 100644 index 00000000..402a2f0d --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-VerticalHorizontalFilter.md @@ -0,0 +1,140 @@ +# VerticalHorizontalFilter + +> Vertical Horizontal Filter (VHF) — net distance covered divided by total +> distance walked; a trend-versus-range gauge. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Trend & Directional | +| Input type | `f64` (close price) | +| Output type | `f64` | +| Output range | `[0, 1]` | +| Default parameters | `period = 28` (Python) | +| Warmup period | `period + 1` | +| Interpretation | Near `1` = trending, near `0` = choppy. | + +## Formula + +``` +VHF = (highest_close(n) − lowest_close(n)) / Σ|close − close_prev|(n) +``` + +The numerator is the *net* distance price covered over the window; the +denominator is the *total* distance it walked. Their ratio lives in `[0, 1]`: +a clean trend walks almost only in its net direction, so `VHF` approaches `1`; +a choppy market doubles back constantly, inflating the denominator and pushing +`VHF` toward `0`. It answers the same question as the +[`ChoppinessIndex`](Indicator-ChoppinessIndex.md) on an inverted scale. + +## Parameters + +`period` — the lookback window. The Python binding defaults it to `28`; the +Rust and Node constructors require it explicitly. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/vertical_horizontal_filter.rs`: + +```rust +impl Indicator for VerticalHorizontalFilter { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +`VerticalHorizontalFilter` is a **scalar** indicator: it consumes one `f64` +close per step. Because `Input = f64` it can sit inside a +[`Chain`](../../Indicator-Chaining.md). + +## Warmup + +`VerticalHorizontalFilter::new(28).warmup_period() == 29`. The high/low window +fills at `period` closes, but the `period`-th difference needs one extra input +because the first close has nothing to diff against. + +## Edge cases + +- **Flat series.** A window that walked nowhere has a zero denominator; `VHF` + is defined as `0`. +- **Pure trend.** A series rising by a fixed step reads `(period − 1) / period`. +- **Choppy series.** An oscillating series reads near `0`. +- **Reset.** `vhf.reset()` clears the close and difference windows. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, VerticalHorizontalFilter}; + +fn main() -> Result<(), Box> { + let mut vhf = VerticalHorizontalFilter::new(5)?; + // Closes 1..6: each diff is 1 (Σ = 5), the 5-close span is 4 -> 4/5. + let out = vhf.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[None, None, None, None, None, Some(0.8)] +``` + +### Python + +```python +import numpy as np +import wickra as ta + +vhf = ta.VerticalHorizontalFilter(5) +print(vhf.batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]))) +``` + +Output: + +``` +[ nan nan nan nan nan 0.8] +``` + +### Node + +```javascript +const ta = require('wickra'); +const vhf = new ta.VerticalHorizontalFilter(5); +console.log(vhf.batch([1, 2, 3, 4, 5, 6])); +``` + +Output: + +``` +[ NaN, NaN, NaN, NaN, NaN, 0.8 ] +``` + +## Interpretation + +Use the VHF as a regime filter: a high, rising VHF says a trend is in force — +favour trend-following entries; a low VHF says price is ranging — favour +mean-reversion. A VHF turning down from a high level is an early hint the +trend is losing its grip. + +## Common pitfalls + +- **Expecting a direction.** Like the Choppiness Index it is non-directional — + pair it with a trend indicator. +- **Reading a single bar.** It is a regime gauge; read its level and slope. + +## References + +Adam White's Vertical Horizontal Filter; the net-over-total formulation here +is the standard one. + +## See also + +- [Indicator-ChoppinessIndex.md](Indicator-ChoppinessIndex.md) — the same + trending-vs-ranging question on an inverted `[0, 100]` scale. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.