diff --git a/bindings/node/index.js b/bindings/node/index.js index 5807bba2..4bc9c9e8 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, ADL, VolumePriceTrend, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, 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 @@ -347,6 +347,10 @@ module.exports.BollingerBandwidth = BollingerBandwidth module.exports.PercentB = PercentB module.exports.ADL = ADL module.exports.VolumePriceTrend = VolumePriceTrend +module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow +module.exports.ChaikinOscillator = ChaikinOscillator +module.exports.ForceIndex = ForceIndex +module.exports.EaseOfMovement = EaseOfMovement 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 3ac2dc89..2e039167 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -1271,6 +1271,235 @@ impl VolumePriceTrendNode { } } +// ============================== Chaikin Money Flow ============================== + +#[napi(js_name = "ChaikinMoneyFlow")] +pub struct ChaikinMoneyFlowNode { + inner: wc::ChaikinMoneyFlow, +} + +#[napi] +impl ChaikinMoneyFlowNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::ChaikinMoneyFlow::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, volume)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(NapiError::from_reason( + "high, low, close, volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], volume[i])?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Chaikin Oscillator ============================== + +#[napi(js_name = "ChaikinOscillator")] +pub struct ChaikinOscillatorNode { + inner: wc::ChaikinOscillator, +} + +#[napi] +impl ChaikinOscillatorNode { + #[napi(constructor)] + pub fn new(fast: u32, slow: u32) -> napi::Result { + Ok(Self { + inner: wc::ChaikinOscillator::new(fast as usize, slow as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, volume)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(NapiError::from_reason( + "high, low, close, volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], volume[i])?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Force Index ============================== + +#[napi(js_name = "ForceIndex")] +pub struct ForceIndexNode { + inner: wc::ForceIndex, +} + +#[napi] +impl ForceIndexNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::ForceIndex::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, close: f64, volume: f64) -> napi::Result> { + Ok(self.inner.update(cnd(close, close, close, volume)?)) + } + #[napi] + pub fn batch(&mut self, close: Vec, volume: Vec) -> napi::Result> { + if close.len() != volume.len() { + return Err(NapiError::from_reason( + "close and volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd(close[i], close[i], close[i], volume[i])?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Ease of Movement ============================== + +#[napi(js_name = "EaseOfMovement")] +pub struct EaseOfMovementNode { + inner: wc::EaseOfMovement, +} + +#[napi] +impl EaseOfMovementNode { + #[napi(constructor)] + pub fn new(period: u32, divisor: f64) -> napi::Result { + Ok(Self { + inner: wc::EaseOfMovement::with_divisor(period as usize, divisor).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, volume: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, low, volume)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + volume: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != volume.len() { + return Err(NapiError::from_reason( + "high, low, volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], low[i], volume[i])?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + // ============================== Bollinger Bandwidth ============================== #[napi(js_name = "BollingerBandwidth")] diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index 8d0ed8bb..3bfd09e1 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -106,6 +106,69 @@ class VolumePriceTrend: @property def value(self) -> Optional[float]: ... +class ChaikinMoneyFlow: + def __init__(self, period: int = 20) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + volume: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def period(self) -> int: ... + +class ChaikinOscillator: + def __init__(self, fast: int = 3, slow: int = 10) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + volume: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def periods(self) -> Tuple[int, int]: ... + +class ForceIndex: + def __init__(self, period: int = 13) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + close: NDArray[np.float64], + volume: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def period(self) -> int: ... + +class EaseOfMovement: + def __init__(self, period: int = 14, divisor: float = 100000000.0) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + volume: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def period(self) -> int: ... + @property + def divisor(self) -> float: ... + class BollingerBandwidth: def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ... def update(self, value: float) -> Optional[float]: ... diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 816f2664..26079e90 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -2992,6 +2992,291 @@ impl PyTrima { } } +// ============================== Chaikin Money Flow ============================== + +#[pyclass(name = "ChaikinMoneyFlow", module = "wickra._wickra")] +#[derive(Clone)] +struct PyChaikinMoneyFlow { + inner: wc::ChaikinMoneyFlow, +} + +#[pymethods] +impl PyChaikinMoneyFlow { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::ChaikinMoneyFlow::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, volume (all equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() { + return Err(PyValueError::new_err( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + #[getter] + fn 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!("ChaikinMoneyFlow(period={})", self.inner.period()) + } +} + +// ============================== Chaikin Oscillator ============================== + +#[pyclass(name = "ChaikinOscillator", module = "wickra._wickra")] +#[derive(Clone)] +struct PyChaikinOscillator { + inner: wc::ChaikinOscillator, +} + +#[pymethods] +impl PyChaikinOscillator { + #[new] + #[pyo3(signature = (fast=3, slow=10))] + fn new(fast: usize, slow: usize) -> PyResult { + Ok(Self { + inner: wc::ChaikinOscillator::new(fast, slow).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, volume (all equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() { + return Err(PyValueError::new_err( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + #[getter] + fn periods(&self) -> (usize, usize) { + self.inner.periods() + } + 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 (fast, slow) = self.inner.periods(); + format!("ChaikinOscillator(fast={fast}, slow={slow})") + } +} + +// ============================== Force Index ============================== + +#[pyclass(name = "ForceIndex", module = "wickra._wickra")] +#[derive(Clone)] +struct PyForceIndex { + inner: wc::ForceIndex, +} + +#[pymethods] +impl PyForceIndex { + #[new] + #[pyo3(signature = (period=13))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::ForceIndex::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy close + volume arrays (both 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if c.len() != v.len() { + return Err(PyValueError::new_err( + "close and volume must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + 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!("ForceIndex(period={})", self.inner.period()) + } +} + +// ============================== Ease of Movement ============================== + +#[pyclass(name = "EaseOfMovement", module = "wickra._wickra")] +#[derive(Clone)] +struct PyEaseOfMovement { + inner: wc::EaseOfMovement, +} + +#[pymethods] +impl PyEaseOfMovement { + #[new] + #[pyo3(signature = (period=14, divisor=100_000_000.0))] + fn new(period: usize, divisor: f64) -> PyResult { + Ok(Self { + inner: wc::EaseOfMovement::with_divisor(period, divisor).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, volume (all equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != v.len() { + return Err(PyValueError::new_err( + "high, low, volume 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], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn divisor(&self) -> f64 { + self.inner.divisor() + } + 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!( + "EaseOfMovement(period={}, divisor={})", + self.inner.period(), + self.inner.divisor() + ) + } +} + // ============================== Module ============================== #[pymodule] @@ -3047,5 +3332,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 054d6f07..7e0356af 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -470,6 +470,174 @@ impl WasmVolumePriceTrend { } } +#[wasm_bindgen(js_name = ChaikinMoneyFlow)] +pub struct WasmChaikinMoneyFlow { + inner: wc::ChaikinMoneyFlow, +} + +#[wasm_bindgen(js_class = ChaikinMoneyFlow)] +impl WasmChaikinMoneyFlow { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::ChaikinMoneyFlow::new(period).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> Result, JsError> { + let c = make_candle(high, low, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n || volume.len() != n { + return Err(JsError::new( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], volume[i])?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = ChaikinOscillator)] +pub struct WasmChaikinOscillator { + inner: wc::ChaikinOscillator, +} + +#[wasm_bindgen(js_class = ChaikinOscillator)] +impl WasmChaikinOscillator { + #[wasm_bindgen(constructor)] + pub fn new(fast: usize, slow: usize) -> Result { + Ok(Self { + inner: wc::ChaikinOscillator::new(fast, slow).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> Result, JsError> { + let c = make_candle(high, low, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || close.len() != n || volume.len() != n { + return Err(JsError::new( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], volume[i])?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = ForceIndex)] +pub struct WasmForceIndex { + inner: wc::ForceIndex, +} + +#[wasm_bindgen(js_class = ForceIndex)] +impl WasmForceIndex { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::ForceIndex::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, close: f64, volume: f64) -> Result, JsError> { + let c = make_candle(close, close, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result { + if close.len() != volume.len() { + return Err(JsError::new("close and volume must be equal length")); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle(close[i], close[i], close[i], volume[i])?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +#[wasm_bindgen(js_name = EaseOfMovement)] +pub struct WasmEaseOfMovement { + inner: wc::EaseOfMovement, +} + +#[wasm_bindgen(js_class = EaseOfMovement)] +impl WasmEaseOfMovement { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, divisor: f64) -> Result { + Ok(Self { + inner: wc::EaseOfMovement::with_divisor(period, divisor).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result, JsError> { + let c = make_candle(high, low, low, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + volume: &[f64], + ) -> Result { + let n = high.len(); + if low.len() != n || volume.len() != n { + return Err(JsError::new("high, low, volume must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = make_candle(high[i], low[i], low[i], volume[i])?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } +} + #[wasm_bindgen(js_name = NATR)] pub struct WasmNatr { inner: wc::Natr, diff --git a/crates/wickra-core/src/indicators/chaikin_oscillator.rs b/crates/wickra-core/src/indicators/chaikin_oscillator.rs new file mode 100644 index 00000000..c41ce479 --- /dev/null +++ b/crates/wickra-core/src/indicators/chaikin_oscillator.rs @@ -0,0 +1,233 @@ +//! Chaikin Oscillator. + +use crate::error::{Error, Result}; +use crate::indicators::adl::Adl; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Chaikin Oscillator — the MACD of the Accumulation/Distribution Line. +/// +/// ```text +/// ChaikinOsc_t = EMA(ADL, fast)_t − EMA(ADL, slow)_t +/// ``` +/// +/// It turns the unbounded, ever-drifting [`Adl`](crate::Adl) into a +/// zero-centred momentum oscillator: positive when short-term accumulation +/// outpaces the longer trend, negative when distribution leads. Because the +/// ADL emits from the very first candle, the slow EMA gates the first output — +/// the warmup period is exactly `slow`. Chaikin's classic configuration is +/// `fast = 3`, `slow = 10`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ChaikinOscillator}; +/// +/// let mut indicator = ChaikinOscillator::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 ChaikinOscillator { + adl: Adl, + fast: Ema, + slow: Ema, + fast_period: usize, + slow_period: usize, +} + +impl ChaikinOscillator { + /// Construct a Chaikin Oscillator with explicit fast / slow EMA periods. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if either period is zero, or + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "Chaikin Oscillator needs fast < slow", + }); + } + Ok(Self { + adl: Adl::new(), + fast: Ema::new(fast)?, + slow: Ema::new(slow)?, + fast_period: fast, + slow_period: slow, + }) + } + + /// Chaikin's classic configuration: `EMA(ADL, 3) − EMA(ADL, 10)`. + pub fn classic() -> Self { + Self::new(3, 10).expect("classic Chaikin Oscillator params are valid") + } + + /// Configured `(fast, slow)` periods. + pub const fn periods(&self) -> (usize, usize) { + (self.fast_period, self.slow_period) + } +} + +impl Indicator for ChaikinOscillator { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // The ADL emits a value from the very first candle, so both EMAs are + // fed on every bar and warm up in parallel. + let adl = self.adl.update(candle)?; + let fast = self.fast.update(adl); + let slow = self.slow.update(adl); + Some(fast? - slow?) + } + + fn reset(&mut self) { + self.adl.reset(); + self.fast.reset(); + self.slow.reset(); + } + + fn warmup_period(&self) -> usize { + // ADL is ready at candle 1; the slow EMA gates the first emission. + self.slow_period + } + + fn is_ready(&self) -> bool { + self.fast.is_ready() && self.slow.is_ready() + } + + fn name(&self) -> &'static str { + "ChaikinOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn cdl(base: f64, volume: f64, ts: i64) -> Candle { + Candle::new(base, base + 1.0, base - 1.0, base, volume, ts).unwrap() + } + + fn flat(price: f64, ts: i64) -> Candle { + Candle::new(price, price, price, price, 100.0, ts).unwrap() + } + + #[test] + fn matches_independent_adl_and_emas() { + // The oscillator must equal feeding a standalone ADL into two + // standalone EMAs and differencing them once both are ready. + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.2).sin() * 6.0; + Candle::new( + mid, + mid + 1.5, + mid - 1.5, + mid + 0.3, + 10.0 + (i % 6) as f64, + i, + ) + .unwrap() + }) + .collect(); + let mut osc = ChaikinOscillator::classic(); + let mut adl = Adl::new(); + let mut fast = Ema::new(3).unwrap(); + let mut slow = Ema::new(10).unwrap(); + for (i, candle) in candles.iter().enumerate() { + let got = osc.update(*candle); + let a = adl.update(*candle).expect("ADL emits from candle 1"); + let f = fast.update(a); + let s = slow.update(a); + match (f, s) { + (Some(fv), Some(sv)) => { + assert_relative_eq!( + got.expect("oscillator ready once slow EMA is"), + fv - sv, + epsilon = 1e-9 + ); + } + _ => assert!(got.is_none(), "must be None until slow EMA ready (i={i})"), + } + } + } + + #[test] + fn flat_market_yields_zero() { + // A flat candle has zero money-flow volume, so the ADL never moves and + // both EMAs of a constant-zero series stay at zero. + let candles: Vec = (0..60).map(|i| flat(10.0, i)).collect(); + let mut osc = ChaikinOscillator::classic(); + for v in osc.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect(); + let mut osc = ChaikinOscillator::classic(); + let out = osc.batch(&candles); + assert_eq!(osc.warmup_period(), 10); + for (i, v) in out.iter().enumerate().take(9) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[9].is_some(), "first value lands at warmup_period - 1"); + } + + #[test] + fn rejects_invalid_params() { + assert!(ChaikinOscillator::new(0, 10).is_err()); + assert!(ChaikinOscillator::new(3, 0).is_err()); + assert!(ChaikinOscillator::new(10, 3).is_err()); + assert!(ChaikinOscillator::new(5, 5).is_err()); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect(); + let mut osc = ChaikinOscillator::classic(); + 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..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0; + Candle::new( + mid, + mid + 2.0, + mid - 2.0, + mid + 0.5, + 10.0 + (i % 5) as f64, + i, + ) + .unwrap() + }) + .collect(); + let mut a = ChaikinOscillator::classic(); + let mut b = ChaikinOscillator::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/cmf.rs b/crates/wickra-core/src/indicators/cmf.rs new file mode 100644 index 00000000..788b052f --- /dev/null +++ b/crates/wickra-core/src/indicators/cmf.rs @@ -0,0 +1,252 @@ +//! Chaikin Money Flow (CMF). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Chaikin Money Flow — Marc Chaikin's `period`-window money-flow oscillator. +/// +/// Each bar produces a *money-flow volume*: the bar's volume weighted by where +/// the close fell within its range (the same money-flow multiplier the +/// [`Adl`](crate::Adl) uses). CMF is the ratio of summed money-flow volume to +/// summed volume over the lookback window: +/// +/// ```text +/// MFM_t = ((close − low) − (high − close)) / (high − low) (−1..+1) +/// MFV_t = MFM_t · volume_t +/// CMF_t = Σ(MFV, period) / Σ(volume, period) +/// ``` +/// +/// The result lives in `[−1, +1]`: sustained closes near the high push CMF +/// toward `+1` (accumulation), near the low toward `−1` (distribution). A bar +/// with `high == low` carries no positional information and contributes a +/// money-flow volume of `0`; a window whose total volume is zero yields `0.0` +/// by convention. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ChaikinMoneyFlow}; +/// +/// let mut indicator = ChaikinMoneyFlow::new(20).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 ChaikinMoneyFlow { + period: usize, + mfv_window: VecDeque, + vol_window: VecDeque, + mfv_sum: f64, + vol_sum: f64, +} + +impl ChaikinMoneyFlow { + /// Construct a new Chaikin Money Flow over `period` bars. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + mfv_window: VecDeque::with_capacity(period), + vol_window: VecDeque::with_capacity(period), + mfv_sum: 0.0, + vol_sum: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for ChaikinMoneyFlow { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let mfv = if range == 0.0 { + // A zero-range bar carries no positional information. + 0.0 + } else { + let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range; + mfm * candle.volume + }; + + if self.mfv_window.len() == self.period { + self.mfv_sum -= self.mfv_window.pop_front().expect("non-empty"); + self.vol_sum -= self.vol_window.pop_front().expect("non-empty"); + } + self.mfv_window.push_back(mfv); + self.vol_window.push_back(candle.volume); + self.mfv_sum += mfv; + self.vol_sum += candle.volume; + + if self.mfv_window.len() < self.period { + return None; + } + if self.vol_sum == 0.0 { + // No volume traded across the whole window — no flow to report. + return Some(0.0); + } + Some(self.mfv_sum / self.vol_sum) + } + + fn reset(&mut self) { + self.mfv_window.clear(); + self.vol_window.clear(); + self.mfv_sum = 0.0; + self.vol_sum = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.mfv_window.len() == self.period + } + + fn name(&self) -> &'static str { + "CMF" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn reference_values() { + // CMF(2): bar 1 closes at the high -> MFM = +1, MFV = +100. + // bar 2 closes mid-range -> MFM = 0, MFV = 0. + // CMF = (100 + 0) / (100 + 100) = 0.5. + let mut cmf = ChaikinMoneyFlow::new(2).unwrap(); + let out = cmf.batch(&[ + candle(8.0, 10.0, 8.0, 10.0, 100.0, 0), + candle(10.0, 12.0, 8.0, 10.0, 100.0, 1), + ]); + assert!(out[0].is_none()); + assert_relative_eq!(out[1].unwrap(), 0.5, epsilon = 1e-12); + } + + #[test] + fn stays_within_unit_range() { + let candles: Vec = (0..120) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.25).sin() * 10.0; + candle( + mid, + mid + 3.0, + mid - 3.0, + mid + (i as f64 * 0.5).cos() * 2.0, + 10.0 + (i % 7) as f64, + i, + ) + }) + .collect(); + let mut cmf = ChaikinMoneyFlow::new(20).unwrap(); + for v in cmf.batch(&candles).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "CMF {v} outside [-1, 1]"); + } + } + + #[test] + fn closes_at_high_yield_cmf_one() { + // Every bar closes on its high -> MFM = +1 -> CMF saturates at +1. + let candles: Vec = (0..30) + .map(|i| candle(9.0, 10.0, 8.0, 10.0, 50.0, i)) + .collect(); + let mut cmf = ChaikinMoneyFlow::new(14).unwrap(); + for v in cmf.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 1.0, epsilon = 1e-12); + } + } + + #[test] + fn zero_volume_window_yields_zero() { + // A window with no traded volume divides 0/0 — defined as 0.0. + let candles: Vec = (0..20) + .map(|i| candle(9.0, 10.0, 8.0, 10.0, 0.0, i)) + .collect(); + let mut cmf = ChaikinMoneyFlow::new(10).unwrap(); + for v in cmf.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn first_value_on_period_th_candle() { + let candles: Vec = (0..10) + .map(|i| candle(9.0, 10.0, 8.0, 9.5, 50.0, i)) + .collect(); + let mut cmf = ChaikinMoneyFlow::new(5).unwrap(); + let out = cmf.batch(&candles); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[4].is_some(), "first CMF lands at index period - 1"); + assert_eq!(cmf.warmup_period(), 5); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(ChaikinMoneyFlow::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..20) + .map(|i| candle(9.0, 11.0, 8.0, 10.0, 50.0, i)) + .collect(); + let mut cmf = ChaikinMoneyFlow::new(10).unwrap(); + cmf.batch(&candles); + assert!(cmf.is_ready()); + cmf.reset(); + assert!(!cmf.is_ready()); + assert_eq!(cmf.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; + candle( + mid, + mid + 2.0, + mid - 2.0, + mid + 0.5, + 10.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let mut a = ChaikinMoneyFlow::new(20).unwrap(); + let mut b = ChaikinMoneyFlow::new(20).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/ease_of_movement.rs b/crates/wickra-core/src/indicators/ease_of_movement.rs new file mode 100644 index 00000000..5f90aba3 --- /dev/null +++ b/crates/wickra-core/src/indicators/ease_of_movement.rs @@ -0,0 +1,277 @@ +//! Ease of Movement (Arms). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Richard Arms' Ease of Movement — how far price travels per unit of volume. +/// +/// ```text +/// distance_t = (high_t + low_t)/2 − (high_{t−1} + low_{t−1})/2 +/// EMV_t = distance_t · (high_t − low_t) · divisor / volume_t +/// EOM_t = SMA(EMV, period)_t +/// ``` +/// +/// A large positive EMV means price climbed a long way on light volume — it +/// moved "easily"; a value near zero means heavy volume was needed to shift +/// price at all. The `divisor` only rescales the output: the conventional +/// `1e8` keeps `EMV` in a readable range for typical share volumes. A bar with +/// zero volume contributes `EMV = 0` (no trading carries no signal), as does a +/// zero-range bar. The first candle only seeds the previous midpoint, so the +/// first value appears on candle `period + 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, EaseOfMovement}; +/// +/// let mut indicator = EaseOfMovement::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 EaseOfMovement { + period: usize, + divisor: f64, + prev_mid: Option, + window: VecDeque, + sum: f64, +} + +impl EaseOfMovement { + /// Construct an Ease of Movement with the conventional `1e8` volume divisor. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Self::with_divisor(period, 100_000_000.0) + } + + /// Construct an Ease of Movement with an explicit volume divisor. The + /// divisor is a pure output-scaling constant; pick whatever keeps `EMV` + /// readable for your instrument's volume magnitude. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0` and + /// [`Error::NonPositiveMultiplier`] if `divisor` is not strictly positive + /// and finite. + pub fn with_divisor(period: usize, divisor: f64) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if !divisor.is_finite() || divisor <= 0.0 { + return Err(Error::NonPositiveMultiplier); + } + Ok(Self { + period, + divisor, + prev_mid: None, + window: VecDeque::with_capacity(period), + sum: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Configured volume divisor. + pub const fn divisor(&self) -> f64 { + self.divisor + } +} + +impl Indicator for EaseOfMovement { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let mid = (candle.high + candle.low) / 2.0; + let Some(prev_mid) = self.prev_mid else { + // The first candle only establishes the previous midpoint. + self.prev_mid = Some(mid); + return None; + }; + let distance = mid - prev_mid; + let range = candle.high - candle.low; + let emv = if candle.volume == 0.0 { + // No volume traded — the move carries no ease-of-movement signal. + 0.0 + } else { + distance * range * self.divisor / candle.volume + }; + self.prev_mid = Some(mid); + + if self.window.len() == self.period { + self.sum -= self.window.pop_front().expect("non-empty"); + } + self.window.push_back(emv); + self.sum += emv; + if self.window.len() < self.period { + return None; + } + Some(self.sum / self.period as f64) + } + + fn reset(&mut self) { + self.prev_mid = None; + self.window.clear(); + self.sum = 0.0; + } + + fn warmup_period(&self) -> usize { + // One seed candle establishes the first previous midpoint, then + // `period` EMV values fill the averaging window. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "EaseOfMovement" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn reference_values() { + // EOM(period = 1, divisor = 1): one EMV value is its own average. + // candle 1: midpoint (10 + 8)/2 = 9 only seeds the previous mid. + // candle 2: mid = (14 + 10)/2 = 12, distance = 3, range = 4, + // EMV = 3 * 4 * 1 / 100 = 0.12. + let mut eom = EaseOfMovement::with_divisor(1, 1.0).unwrap(); + let out = eom.batch(&[ + candle(9.0, 10.0, 8.0, 9.0, 50.0, 0), + candle(12.0, 14.0, 10.0, 12.0, 100.0, 1), + ]); + assert!(out[0].is_none()); + assert_relative_eq!(out[1].unwrap(), 0.12, epsilon = 1e-12); + } + + #[test] + fn rising_midpoints_yield_positive_eom() { + // Strictly rising midpoints on constant volume -> every EMV is + // positive, so the averaged EOM is positive. + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 1.0, base - 1.0, base, 100.0, i) + }) + .collect(); + let mut eom = EaseOfMovement::new(14).unwrap(); + for v in eom.batch(&candles).into_iter().flatten() { + assert!(v > 0.0, "EOM {v} should be positive on a rising series"); + } + } + + #[test] + fn constant_series_yields_zero() { + // Unchanging candles -> zero distance -> EMV is zero throughout. + let candles: Vec = (0..30) + .map(|i| candle(10.0, 11.0, 9.0, 10.0, 50.0, i)) + .collect(); + let mut eom = EaseOfMovement::new(10).unwrap(); + for v in eom.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn zero_volume_contributes_zero() { + // A zero-volume bar yields EMV = 0 instead of dividing by zero. + let candles: Vec = (0..20) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 1.0, base - 1.0, base, 0.0, i) + }) + .collect(); + let mut eom = EaseOfMovement::new(10).unwrap(); + for v in eom.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn first_value_on_period_plus_one_candle() { + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 1.0, base - 1.0, base, 50.0, i) + }) + .collect(); + let mut eom = EaseOfMovement::new(5).unwrap(); + let out = eom.batch(&candles); + for (i, v) in out.iter().enumerate().take(5) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[5].is_some(), "first EOM lands at index period"); + assert_eq!(eom.warmup_period(), 6); + } + + #[test] + fn rejects_invalid_input() { + assert!(EaseOfMovement::new(0).is_err()); + assert!(EaseOfMovement::with_divisor(14, 0.0).is_err()); + assert!(EaseOfMovement::with_divisor(14, -1.0).is_err()); + assert!(EaseOfMovement::with_divisor(14, f64::NAN).is_err()); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + i as f64; + candle(base, base + 1.0, base - 1.0, base, 50.0, i) + }) + .collect(); + let mut eom = EaseOfMovement::new(10).unwrap(); + eom.batch(&candles); + assert!(eom.is_ready()); + eom.reset(); + assert!(!eom.is_ready()); + assert_eq!(eom.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; + candle( + mid, + mid + 2.0, + mid - 2.0, + mid + 0.5, + 10.0 + (i % 5) as f64, + i, + ) + }) + .collect(); + let mut a = EaseOfMovement::new(14).unwrap(); + let mut b = EaseOfMovement::new(14).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/force_index.rs b/crates/wickra-core/src/indicators/force_index.rs new file mode 100644 index 00000000..55b5b5ec --- /dev/null +++ b/crates/wickra-core/src/indicators/force_index.rs @@ -0,0 +1,189 @@ +//! Force Index (Elder). + +use crate::error::Result; +use crate::indicators::ema::Ema; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Alexander Elder's Force Index — price change scaled by volume, EMA-smoothed. +/// +/// ```text +/// raw_t = (close_t − close_{t−1}) · volume_t +/// Force_t = EMA(raw, period)_t +/// ``` +/// +/// The raw force is positive on an up-close and negative on a down-close, and +/// its magnitude grows with the volume that backed the move — a big move on +/// heavy volume registers a large force. Smoothing the raw series with an EMA +/// gives a tradeable line; Elder's classic period is `13`. The first candle +/// only establishes the previous close, so the first raw value appears on +/// candle 2 and the first smoothed value on candle `period + 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ForceIndex}; +/// +/// let mut indicator = ForceIndex::new(13).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 ForceIndex { + period: usize, + prev_close: Option, + ema: Ema, +} + +impl ForceIndex { + /// Construct a new Force Index with the given EMA smoothing period. + /// + /// # Errors + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + period, + prev_close: None, + ema: Ema::new(period)?, + }) + } + + /// Configured smoothing period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for ForceIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev) = self.prev_close else { + // The first candle only establishes the previous close. + self.prev_close = Some(candle.close); + return None; + }; + let raw = (candle.close - prev) * candle.volume; + self.prev_close = Some(candle.close); + self.ema.update(raw) + } + + fn reset(&mut self) { + self.prev_close = None; + self.ema.reset(); + } + + fn warmup_period(&self) -> usize { + // One seed candle establishes the first previous close, then the EMA + // needs `period` raw values. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.ema.is_ready() + } + + fn name(&self) -> &'static str { + "ForceIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, volume, ts).unwrap() + } + + #[test] + fn reference_values() { + // ForceIndex(1): EMA(1) has alpha = 1, so it passes raw force through. + // candle 1 (close 10) only seeds the previous close -> None. + // candle 2: raw = (12 - 10) * 100 = +200. + // candle 3: raw = (11 - 12) * 200 = -200. + let mut fi = ForceIndex::new(1).unwrap(); + let out = fi.batch(&[c(10.0, 100.0, 0), c(12.0, 100.0, 1), c(11.0, 200.0, 2)]); + assert!(out[0].is_none()); + assert_relative_eq!(out[1].unwrap(), 200.0, epsilon = 1e-9); + assert_relative_eq!(out[2].unwrap(), -200.0, epsilon = 1e-9); + } + + #[test] + fn pure_uptrend_is_positive() { + // Strictly rising closes on constant volume -> every raw force is + // positive, so the smoothed force is positive too. + let candles: Vec = (1..40) + .map(|i| c(f64::from(i), 100.0, i64::from(i))) + .collect(); + let mut fi = ForceIndex::new(13).unwrap(); + for v in fi.batch(&candles).into_iter().flatten() { + assert!(v > 0.0, "force {v} should be positive in an uptrend"); + } + } + + #[test] + fn pure_downtrend_is_negative() { + let candles: Vec = (1..40) + .rev() + .map(|i| c(f64::from(i), 100.0, i64::from(i))) + .collect(); + let mut fi = ForceIndex::new(13).unwrap(); + for v in fi.batch(&candles).into_iter().flatten() { + assert!(v < 0.0, "force {v} should be negative in a downtrend"); + } + } + + #[test] + fn first_value_on_period_plus_one_candle() { + let candles: Vec = (0..12).map(|i| c(10.0 + i as f64, 50.0, i)).collect(); + let mut fi = ForceIndex::new(5).unwrap(); + let out = fi.batch(&candles); + for (i, v) in out.iter().enumerate().take(5) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert!(out[5].is_some(), "first force lands at index period"); + assert_eq!(fi.warmup_period(), 6); + } + + #[test] + fn rejects_zero_period() { + assert!(ForceIndex::new(0).is_err()); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30).map(|i| c(10.0 + i as f64, 50.0, i)).collect(); + let mut fi = ForceIndex::new(13).unwrap(); + fi.batch(&candles); + assert!(fi.is_ready()); + fi.reset(); + assert!(!fi.is_ready()); + assert_eq!(fi.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let close = 100.0 + (i as f64 * 0.3).sin() * 8.0; + c(close, 10.0 + (i % 5) as f64, i) + }) + .collect(); + let mut a = ForceIndex::new(13).unwrap(); + let mut b = ForceIndex::new(13).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 654f73d5..f8ae5677 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -13,12 +13,16 @@ mod awesome_oscillator; mod bollinger; mod bollinger_bandwidth; mod cci; +mod chaikin_oscillator; +mod cmf; mod cmo; mod coppock; mod dema; mod donchian; mod dpo; +mod ease_of_movement; mod ema; +mod force_index; mod historical_volatility; mod hma; mod kama; @@ -64,12 +68,16 @@ pub use awesome_oscillator::AwesomeOscillator; pub use bollinger::{BollingerBands, BollingerOutput}; pub use bollinger_bandwidth::BollingerBandwidth; pub use cci::Cci; +pub use chaikin_oscillator::ChaikinOscillator; +pub use cmf::ChaikinMoneyFlow; pub use cmo::Cmo; pub use coppock::Coppock; pub use dema::Dema; pub use donchian::{Donchian, DonchianOutput}; pub use dpo::Dpo; +pub use ease_of_movement::EaseOfMovement; pub use ema::Ema; +pub use force_index::ForceIndex; pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; pub use kama::Kama; diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index fa50a017..5d854395 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -45,12 +45,12 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AwesomeOscillator, - BollingerBands, BollingerBandwidth, BollingerOutput, Cci, Cmo, Coppock, Dema, Donchian, - DonchianOutput, Dpo, Ema, HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, - MacdIndicator, MacdOutput, MassIndex, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, - RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix, - Tsi, UlcerIndex, UltimateOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, - WilliamsR, Wma, Zlema, T3, + BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, + Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, ForceIndex, + HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, MacdIndicator, MacdOutput, MassIndex, + Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, StdDev, + StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix, Tsi, UlcerIndex, UltimateOscillator, + VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, WilliamsR, Wma, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index f836588d..488a961a 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -131,6 +131,10 @@ Rust / Python / Node examples. They are grouped by family, mirroring the - [Indicator-Vwap.md](indicators/volume/Indicator-Vwap.md) - [Indicator-Adl.md](indicators/volume/Indicator-Adl.md) - [Indicator-VolumePriceTrend.md](indicators/volume/Indicator-VolumePriceTrend.md) +- [Indicator-ChaikinMoneyFlow.md](indicators/volume/Indicator-ChaikinMoneyFlow.md) +- [Indicator-ChaikinOscillator.md](indicators/volume/Indicator-ChaikinOscillator.md) +- [Indicator-ForceIndex.md](indicators/volume/Indicator-ForceIndex.md) +- [Indicator-EaseOfMovement.md](indicators/volume/Indicator-EaseOfMovement.md) ## See also diff --git a/docs/wiki/Indicators-Overview.md b/docs/wiki/Indicators-Overview.md index ae3f1193..e04c5ff0 100644 --- a/docs/wiki/Indicators-Overview.md +++ b/docs/wiki/Indicators-Overview.md @@ -1,6 +1,6 @@ # Indicators Overview -Wickra ships 50 indicators, organised in source under the four classical +Wickra ships 54 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 @@ -169,6 +169,18 @@ Volume indicators all take `Candle` input because they need `close` and |---------------|-----------|-------|--------|-------|----------|--------|-----------| | `RollingVwap` | VWAP over a sliding window instead of since-start; useful for session-independent VWAP. | `Candle` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Vwap.md → RollingVwap](indicators/volume/Indicator-Vwap.md#rollingvwap-finite-window) | +### Oscillators + +Volume-flow oscillators: bounded or zero-centred readings derived from where +price closes within each bar and how much volume backed the move. + +| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive | +|-----------|-----------|-------|--------|-------|----------|--------|-----------| +| `ChaikinMoneyFlow` | Summed money-flow volume divided by summed volume over `period` bars. | `Candle` | `f64` | `[−1, +1]` | `period = 20` (Python) | `period` | [Indicator-ChaikinMoneyFlow.md](indicators/volume/Indicator-ChaikinMoneyFlow.md) | +| `ChaikinOscillator` | `EMA(ADL, fast) − EMA(ADL, slow)`; the MACD of the ADL. | `Candle` | `f64` | unbounded around zero | `(fast=3, slow=10)` (Python) | `slow` | [Indicator-ChaikinOscillator.md](indicators/volume/Indicator-ChaikinOscillator.md) | +| `ForceIndex` | `EMA((close − prev_close) · volume, period)`; the conviction behind a move. | `Candle` | `f64` | unbounded around zero | `period = 13` (Python) | `period + 1` | [Indicator-ForceIndex.md](indicators/volume/Indicator-ForceIndex.md) | +| `EaseOfMovement` | `SMA` of distance travelled per unit of volume. | `Candle` | `f64` | unbounded around zero | `(period=14, divisor=1e8)` (Python) | `period + 1` | [Indicator-EaseOfMovement.md](indicators/volume/Indicator-EaseOfMovement.md) | + ## Pick the right indicator for… A short cheat-sheet of "I want X, which indicator?" answers, grounded in diff --git a/docs/wiki/indicators/volume/Indicator-ChaikinMoneyFlow.md b/docs/wiki/indicators/volume/Indicator-ChaikinMoneyFlow.md new file mode 100644 index 00000000..a3764f90 --- /dev/null +++ b/docs/wiki/indicators/volume/Indicator-ChaikinMoneyFlow.md @@ -0,0 +1,159 @@ +# ChaikinMoneyFlow + +> Chaikin Money Flow (CMF) — the ratio of money-flow volume to total +> volume over a rolling window, bounded to `[−1, +1]`. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volume | +| Sub-category | Oscillators | +| Input type | `Candle` (uses `high`, `low`, `close`, `volume`) | +| Output type | `f64` | +| Output range | `[−1, +1]` | +| Default parameters | `period = 20` (Python) | +| Warmup period | `period` | +| Interpretation | Window accumulation/distribution balance; sign and magnitude both matter. | + +## Formula + +``` +MFM_t = ((close − low) − (high − close)) / (high − low) (money-flow multiplier, −1..+1) +MFV_t = MFM_t · volume_t (money-flow volume) +CMF_t = Σ(MFV, period) / Σ(volume, period) +``` + +CMF is the [`Adl`](Indicator-Adl.md) increment averaged the way RSI averages +gains: rather than a running total, it divides the *summed* money-flow volume +of the last `period` bars by the *summed* volume of those bars. The result is +volume-normalised, so it lives in `[−1, +1]` regardless of how heavily the +instrument trades. A bar with `high == low` carries no positional information +and contributes a money-flow volume of `0`. + +## Parameters + +`period` — the lookback window. The Python binding defaults it to `20`; the +Rust and Node constructors require it explicitly. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/cmf.rs`: + +```rust +impl Indicator for ChaikinMoneyFlow { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`ChaikinMoneyFlow` is a **candle-input** indicator: it reads `high`, `low`, +`close` and `volume`. In Python the streaming `update` accepts a 6-tuple or a +dict; the batch helper takes `high`, `low`, `close`, `volume` numpy arrays. +Node and WASM expose `update(high, low, close, volume)` and the matching +`batch`. + +## Warmup + +`ChaikinMoneyFlow::new(20).warmup_period() == 20`. The first value lands once +the window holds a full `period` bars — on input index `period − 1`. + +## Edge cases + +- **Zero-range bar.** A bar with `high == low` contributes `MFV = 0`. +- **Empty-volume window.** If the whole window traded zero volume, the + `0/0` ratio is defined as `0.0` (`zero_volume_window_yields_zero` pins this). +- **Saturated flow.** Every bar closing on its high gives `MFM = +1`, so CMF + saturates at `+1` (`closes_at_high_yield_cmf_one` pins this). +- **Reset.** `cmf.reset()` clears the window and both running sums. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, ChaikinMoneyFlow}; + +fn main() -> Result<(), Box> { + let mut cmf = ChaikinMoneyFlow::new(2)?; + let out = cmf.batch(&[ + Candle::new(8.0, 10.0, 8.0, 10.0, 100.0, 0)?, // close at high -> MFV +100 + Candle::new(10.0, 12.0, 8.0, 10.0, 100.0, 1)?, // close mid-range -> MFV 0 + ]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[None, Some(0.5)] +``` + +Bar 1 closes at its high (`MFM = +1`, `MFV = +100`); bar 2 closes mid-range +(`MFM = 0`, `MFV = 0`). `CMF(2) = (100 + 0) / (100 + 100) = 0.5`. This matches +the `reference_values` test in `crates/wickra-core/src/indicators/cmf.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +cmf = ta.ChaikinMoneyFlow(2) +high = np.array([10.0, 12.0]) +low = np.array([8.0, 8.0]) +close = np.array([10.0, 10.0]) +volume = np.array([100.0, 100.0]) +print(cmf.batch(high, low, close, volume)) +``` + +Output: + +``` +[nan 0.5] +``` + +### Node + +```javascript +const ta = require('wickra'); +const cmf = new ta.ChaikinMoneyFlow(2); +console.log(cmf.batch([10, 12], [8, 8], [10, 10], [100, 100])); +``` + +Output: + +``` +[ NaN, 0.5 ] +``` + +## Interpretation + +CMF reads as a balance: sustained positive values mean closes are clustering +near bar highs on real volume (accumulation), sustained negative values mean +the opposite (distribution). Crosses of the zero line are the textbook signal; +the `±0.05` band is often treated as a neutral zone. Because CMF is +volume-normalised it is comparable across instruments — unlike the raw +[`Adl`](Indicator-Adl.md), whose level is arbitrary. + +## Common pitfalls + +- **Confusing it with the ADL.** CMF is a *bounded ratio*; the ADL is an + *unbounded running total*. They share the money-flow multiplier and nothing + else. +- **Feeding it scalar prices.** It needs the full OHLCV bar. + +## References + +Marc Chaikin's Chaikin Money Flow; the money-flow-multiplier formulation here +matches the standard definition (StockCharts). + +## See also + +- [Indicator-Adl.md](Indicator-Adl.md) — the cumulative line CMF is built on. +- [Indicator-ChaikinOscillator.md](Indicator-ChaikinOscillator.md) — the + EMA-difference oscillator on the ADL. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volume/Indicator-ChaikinOscillator.md b/docs/wiki/indicators/volume/Indicator-ChaikinOscillator.md new file mode 100644 index 00000000..c92fada0 --- /dev/null +++ b/docs/wiki/indicators/volume/Indicator-ChaikinOscillator.md @@ -0,0 +1,161 @@ +# ChaikinOscillator + +> Chaikin Oscillator — the MACD of the Accumulation/Distribution Line: +> a fast EMA of the ADL minus a slow EMA of the ADL. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volume | +| Sub-category | Oscillators | +| Input type | `Candle` (uses `high`, `low`, `close`, `volume`) | +| Output type | `f64` | +| Output range | unbounded around zero | +| Default parameters | `fast = 3`, `slow = 10` (Python) | +| Warmup period | `slow` | +| Interpretation | Momentum of accumulation/distribution; zero-line crossings are the signal. | + +## Formula + +``` +ChaikinOsc_t = EMA(ADL, fast)_t − EMA(ADL, slow)_t +``` + +The [`Adl`](Indicator-Adl.md) is an unbounded line that drifts with cumulative +volume — useful for its slope but awkward to trade directly. The Chaikin +Oscillator applies the MACD construction to it: difference a fast and a slow +EMA of the ADL to get a zero-centred momentum reading. Positive values mean +short-term accumulation is outrunning the longer trend; negative values mean +distribution leads. + +## Parameters + +- `fast` — period of the fast EMA on the ADL (classic `3`). +- `slow` — period of the slow EMA on the ADL (classic `10`). + +`fast` must be strictly less than `slow`. `ChaikinOscillator::classic()` +returns the `(3, 10)` configuration. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/chaikin_oscillator.rs`: + +```rust +impl Indicator for ChaikinOscillator { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +It is a **candle-input** indicator (the ADL inside it needs `high`, `low`, +`close`, `volume`). Python's streaming `update` accepts a 6-tuple or a dict; +the batch helper takes `high`, `low`, `close`, `volume` numpy arrays. Node and +WASM expose `update(high, low, close, volume)` and the matching `batch`. + +## Warmup + +`ChaikinOscillator::classic().warmup_period() == 10`. The ADL emits a value +from the very first candle, so both EMAs are fed every bar and the slow EMA +gates the first output — the warmup is exactly `slow`. + +## Edge cases + +- **Flat market.** A flat candle has zero money-flow volume, so the ADL never + moves and both EMAs of the constant-zero series stay at zero — the + oscillator sits at `0.0` (`flat_market_yields_zero` pins this). +- **`fast >= slow`.** Rejected at construction with an error. +- **Reset.** `osc.reset()` clears the ADL and both EMAs. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, ChaikinOscillator}; + +fn main() -> Result<(), Box> { + let mut osc = ChaikinOscillator::classic(); // EMA(ADL, 3) − EMA(ADL, 10) + // A flat market: the ADL never moves, so the oscillator sits at zero. + let candles: Vec = (0..20) + .map(|i| Candle::new(10.0, 10.0, 10.0, 10.0, 100.0, i).unwrap()) + .collect(); + let out = osc.batch(&candles); + println!("{:?}", out.last().unwrap()); + Ok(()) +} +``` + +Output: + +``` +Some(0.0) +``` + +A flat series produces a flat ADL and therefore a zero oscillator. This +matches the `flat_market_yields_zero` test in +`crates/wickra-core/src/indicators/chaikin_oscillator.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +osc = ta.ChaikinOscillator(3, 10) +n = 20 +flat = np.full(n, 10.0) +print(osc.batch(flat, flat, flat, np.full(n, 100.0))[-1]) +``` + +Output: + +``` +0.0 +``` + +### Node + +```javascript +const ta = require('wickra'); +const osc = new ta.ChaikinOscillator(3, 10); +const flat = Array(20).fill(10); +const vol = Array(20).fill(100); +const out = osc.batch(flat, flat, flat, vol); +console.log(out[out.length - 1]); +``` + +Output: + +``` +0 +``` + +## Interpretation + +Trade the Chaikin Oscillator like any MACD-style line: a cross above zero is a +bullish accumulation signal, a cross below is bearish. Divergence between the +oscillator and price is the higher-conviction setup — for example, price +making a new high while the oscillator does not is the same warning the raw +ADL gives, but packaged as a bounded, zero-centred series. + +## Common pitfalls + +- **Treating the level as meaningful.** Only the sign and the slope carry + information; the magnitude scales with the instrument's volume. +- **Feeding it scalar prices.** It needs the full OHLCV bar. + +## References + +Marc Chaikin's Chaikin Oscillator — the MACD construction applied to his +Accumulation/Distribution Line (StockCharts). + +## See also + +- [Indicator-Adl.md](Indicator-Adl.md) — the cumulative line this oscillates. +- [Indicator-ChaikinMoneyFlow.md](Indicator-ChaikinMoneyFlow.md) — a bounded + ratio built from the same money-flow volume. +- [Indicator-MacdIndicator.md](../momentum/Indicator-MacdIndicator.md) — the + same fast/slow EMA-difference construction on price. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volume/Indicator-EaseOfMovement.md b/docs/wiki/indicators/volume/Indicator-EaseOfMovement.md new file mode 100644 index 00000000..911f9bb8 --- /dev/null +++ b/docs/wiki/indicators/volume/Indicator-EaseOfMovement.md @@ -0,0 +1,161 @@ +# EaseOfMovement + +> Ease of Movement (EOM) — Richard Arms' measure of how far price travels +> per unit of volume, averaged over a window. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volume | +| Sub-category | Oscillators | +| Input type | `Candle` (uses `high`, `low`, `volume`) | +| Output type | `f64` | +| Output range | unbounded around zero (scaled by `divisor`) | +| Default parameters | `period = 14`, `divisor = 1e8` (Python) | +| Warmup period | `period + 1` | +| Interpretation | Light-volume moves push it away from zero; sign tracks direction. | + +## Formula + +``` +distance_t = (high_t + low_t)/2 − (high_{t−1} + low_{t−1})/2 +EMV_t = distance_t · (high_t − low_t) · divisor / volume_t +EOM_t = SMA(EMV, period)_t +``` + +Arms' question is *how easily did price move?* A bar whose midpoint jumped a +long way on a wide range but light volume gets a large `EMV`; a bar that +needed heavy volume to budge gets a small one. The `divisor` is a pure +output-scaling constant — the conventional `1e8` keeps `EMV` readable for +typical share volumes; smaller markets want a smaller divisor. The window SMA +smooths the noisy per-bar `EMV` into a tradeable line. + +## Parameters + +- `period` — the SMA averaging window (Python default `14`). +- `divisor` — the volume-scaling constant (Python default `1e8`). Rust exposes + `EaseOfMovement::new(period)` for the `1e8` default and + `EaseOfMovement::with_divisor(period, divisor)` for an explicit value. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/ease_of_movement.rs`: + +```rust +impl Indicator for EaseOfMovement { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`EaseOfMovement` is a **candle-input** indicator that reads `high`, `low` and +`volume`. In Python the streaming `update` accepts a 6-tuple or a dict; the +batch helper takes `high`, `low`, `volume` numpy arrays. Node and WASM expose +`update(high, low, volume)` and the matching `batch`. + +## Warmup + +`EaseOfMovement::new(14).warmup_period() == 15`. The first candle only seeds +the previous midpoint, so the first `EMV` appears on candle 2 and the first +averaged value on candle `period + 1`. + +## Edge cases + +- **Zero-volume bar.** Contributes `EMV = 0` instead of dividing by zero + (`zero_volume_contributes_zero` pins this). +- **Zero-range bar.** `high == low` makes the `(high − low)` factor zero, so + `EMV = 0`. +- **Constant series.** Unchanging midpoints give zero distance, so EOM stays + at `0.0` (`constant_series_yields_zero` pins this). +- **Reset.** `eom.reset()` clears the previous midpoint and the SMA window. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, EaseOfMovement}; + +fn main() -> Result<(), Box> { + // EOM(period = 1, divisor = 1): one EMV value is its own average. + let mut eom = EaseOfMovement::with_divisor(1, 1.0)?; + let out = eom.batch(&[ + Candle::new(9.0, 10.0, 8.0, 9.0, 50.0, 0)?, // seeds the previous midpoint (9) + Candle::new(12.0, 14.0, 10.0, 12.0, 100.0, 1)?, // mid 12, distance 3, range 4 + ]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[None, Some(0.12)] +``` + +Bar 2: `EMV = distance · range · divisor / volume = 3 · 4 · 1 / 100 = 0.12`. +This matches the `reference_values` test in +`crates/wickra-core/src/indicators/ease_of_movement.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +eom = ta.EaseOfMovement(1, 1.0) +high = np.array([10.0, 14.0]) +low = np.array([8.0, 10.0]) +volume = np.array([50.0, 100.0]) +print(eom.batch(high, low, volume)) +``` + +Output: + +``` +[ nan 0.12] +``` + +### Node + +```javascript +const ta = require('wickra'); +const eom = new ta.EaseOfMovement(1, 1.0); +console.log(eom.batch([10, 14], [8, 10], [50, 100])); +``` + +Output: + +``` +[ NaN, 0.12 ] +``` + +## Interpretation + +EOM crossing above zero says price is drifting up *without* needing much +volume — an easy, low-resistance advance; below zero is the same for a +decline. A reading hovering near zero means volume is heavy relative to the +distance covered, i.e. price is grinding. The sign tracks direction; the +distance from zero tracks how freely the move is happening. + +## Common pitfalls + +- **Reading the raw magnitude.** It depends entirely on the `divisor` you + chose — only the sign and relative size are portable. +- **Feeding it scalar prices.** It needs `high`, `low` *and* `volume`. + +## References + +Richard W. Arms Jr.'s Ease of Movement; the box-ratio formulation here matches +the standard definition. + +## See also + +- [Indicator-ForceIndex.md](Indicator-ForceIndex.md) — a different + price-change-vs-volume gauge. +- [Indicator-ChaikinMoneyFlow.md](Indicator-ChaikinMoneyFlow.md) — bounded + money-flow balance. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volume/Indicator-ForceIndex.md b/docs/wiki/indicators/volume/Indicator-ForceIndex.md new file mode 100644 index 00000000..7c42d806 --- /dev/null +++ b/docs/wiki/indicators/volume/Indicator-ForceIndex.md @@ -0,0 +1,155 @@ +# ForceIndex + +> Force Index — Alexander Elder's price change scaled by volume, then +> smoothed with an EMA. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volume | +| Sub-category | Oscillators | +| Input type | `Candle` (uses `close`, `volume`) | +| Output type | `f64` | +| Output range | unbounded around zero | +| Default parameters | `period = 13` (Python) | +| Warmup period | `period + 1` | +| Interpretation | Conviction behind a move; sign and zero-crossings are the signal. | + +## Formula + +``` +raw_t = (close_t − close_{t−1}) · volume_t +Force_t = EMA(raw, period)_t +``` + +The raw force is positive on an up-close and negative on a down-close, with a +magnitude that grows with the volume backing the move — a large move on heavy +volume registers a large force, a large move on thin volume does not. +Smoothing the raw series with an EMA turns the noisy per-bar reading into a +tradeable line; Elder's classic period is `13`. + +## Parameters + +`period` — the EMA smoothing period. The Python binding defaults it to `13`; +the Rust and Node constructors require it explicitly. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/force_index.rs`: + +```rust +impl Indicator for ForceIndex { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`ForceIndex` is a **candle-input** indicator that reads `close` and `volume`. +In Python the streaming `update` accepts a 6-tuple or a dict; the batch helper +takes `close`, `volume` numpy arrays. Node and WASM expose +`update(close, volume)` and the matching `batch`. + +## Warmup + +`ForceIndex::new(13).warmup_period() == 14`. The first candle only establishes +the previous close, so the first raw force appears on candle 2 and the first +smoothed value on candle `period + 1`. + +## Edge cases + +- **First candle.** Establishes the previous close only; emits `None`. +- **Up- vs down-trend.** A strictly rising series gives a positive force, a + strictly falling series a negative one (`pure_uptrend_is_positive` and + `pure_downtrend_is_negative` pin this). +- **`period = 1`.** `EMA(1)` has `alpha = 1`, so the Force Index passes the + raw force through unsmoothed. +- **Reset.** `fi.reset()` clears the previous close and the EMA. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, ForceIndex}; + +fn main() -> Result<(), Box> { + // ForceIndex(1): EMA(1) passes the raw force through. + let mut fi = ForceIndex::new(1)?; + let out = fi.batch(&[ + Candle::new(10.0, 10.0, 10.0, 10.0, 100.0, 0)?, // seeds the previous close + Candle::new(12.0, 12.0, 12.0, 12.0, 100.0, 1)?, // raw = (12-10)·100 + Candle::new(11.0, 11.0, 11.0, 11.0, 200.0, 2)?, // raw = (11-12)·200 + ]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[None, Some(200.0), Some(-200.0)] +``` + +This matches the `reference_values` test in +`crates/wickra-core/src/indicators/force_index.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +fi = ta.ForceIndex(1) +close = np.array([10.0, 12.0, 11.0]) +volume = np.array([100.0, 100.0, 200.0]) +print(fi.batch(close, volume)) +``` + +Output: + +``` +[ nan 200. -200.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const fi = new ta.ForceIndex(1); +console.log(fi.batch([10, 12, 11], [100, 100, 200])); +``` + +Output: + +``` +[ NaN, 200, -200 ] +``` + +## Interpretation + +Elder reads the Force Index on two horizons. A short period (the classic `2`) +is a sensitive entry timer — it crosses zero often. A longer period (`13`) +tracks the conviction behind the prevailing trend: it staying above zero +confirms buyers are in control. Divergence between a `13`-period Force Index +and price flags an exhausting move. + +## Common pitfalls + +- **Comparing levels across instruments.** The force scales with raw volume, + so a value of `200` means nothing without knowing the instrument. +- **Feeding it scalar prices.** It needs `close` *and* `volume`. + +## References + +Alexander Elder's Force Index, introduced in *Trading for a Living* (1993). + +## See also + +- [Indicator-Obv.md](Indicator-Obv.md) — cumulative signed volume, a coarser + volume-conviction gauge. +- [Indicator-VolumePriceTrend.md](Indicator-VolumePriceTrend.md) — cumulative + volume scaled by percentage move. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.