diff --git a/bindings/node/index.js b/bindings/node/index.js index 64702bbb..a8048aed 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, 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 +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, 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, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -331,6 +331,7 @@ module.exports.DPO = DPO module.exports.StdDev = StdDev module.exports.UlcerIndex = UlcerIndex module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter +module.exports.ZScore = ZScore module.exports.MACD = MACD module.exports.BollingerBands = BollingerBands module.exports.ATR = ATR @@ -368,6 +369,9 @@ module.exports.LinRegSlope = LinRegSlope module.exports.AcceleratorOscillator = AcceleratorOscillator module.exports.BalanceOfPower = BalanceOfPower module.exports.ChoppinessIndex = ChoppinessIndex +module.exports.TrueRange = TrueRange +module.exports.ChaikinVolatility = ChaikinVolatility +module.exports.LinRegAngle = LinRegAngle 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 a905c674..c4987474 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -115,6 +115,7 @@ node_scalar_indicator!( "VerticalHorizontalFilter", wc::VerticalHorizontalFilter ); +node_scalar_indicator!(ZScoreNode, "ZScore", wc::ZScore); // ============================== MACD ============================== @@ -2216,6 +2217,155 @@ impl ChoppinessIndexNode { } } +// ============================== True Range ============================== + +#[napi(js_name = "TrueRange")] +pub struct TrueRangeNode { + inner: wc::TrueRange, +} + +impl Default for TrueRangeNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl TrueRangeNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::TrueRange::new(), + } + } + #[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 + } +} + +// ============================== Chaikin Volatility ============================== + +#[napi(js_name = "ChaikinVolatility")] +pub struct ChaikinVolatilityNode { + inner: wc::ChaikinVolatility, +} + +#[napi] +impl ChaikinVolatilityNode { + #[napi(constructor)] + pub fn new(ema_period: u32, roc_period: u32) -> napi::Result { + Ok(Self { + inner: wc::ChaikinVolatility::new(ema_period as usize, roc_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 + } +} + +// ============================== Linear Regression Angle ============================== + +#[napi(js_name = "LinRegAngle")] +pub struct LinRegAngleNode { + inner: wc::LinRegAngle, +} + +#[napi] +impl LinRegAngleNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::LinRegAngle::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + // ============================== Bollinger Bandwidth ============================== #[napi(js_name = "BollingerBandwidth")] diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 0b08f7e7..7407f6d3 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -82,6 +82,8 @@ from ._wickra import ( ChandelierExit, ChandeKrollStop, AtrTrailingStop, + TrueRange, + ChaikinVolatility, # Volume OBV, VWAP, @@ -97,6 +99,8 @@ from ._wickra import ( WeightedClose, LinearRegression, LinRegSlope, + ZScore, + LinRegAngle, ) __all__ = [ @@ -158,6 +162,8 @@ __all__ = [ "ChandelierExit", "ChandeKrollStop", "AtrTrailingStop", + "TrueRange", + "ChaikinVolatility", # Volume "OBV", "VWAP", @@ -173,4 +179,6 @@ __all__ = [ "WeightedClose", "LinearRegression", "LinRegSlope", + "ZScore", + "LinRegAngle", ] diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index 9ecc689b..e260c4fe 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -350,6 +350,53 @@ class VerticalHorizontalFilter: @property def period(self) -> int: ... +class TrueRange: + def __init__(self) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + +class ChaikinVolatility: + def __init__(self, ema_period: int = 10, roc_period: int = 10) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def periods(self) -> Tuple[int, int]: ... + +class ZScore: + def __init__(self, period: int = 20) -> 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 LinRegAngle: + def __init__(self, period: int = 14) -> 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 bea5a364..eb1cc35e 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -4103,6 +4103,228 @@ impl PyVerticalHorizontalFilter { } } +// ============================== True Range ============================== + +#[pyclass(name = "TrueRange", module = "wickra._wickra")] +#[derive(Clone)] +struct PyTrueRange { + inner: wc::TrueRange, +} + +#[pymethods] +impl PyTrueRange { + #[new] + fn new() -> Self { + Self { + inner: wc::TrueRange::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns high, low, close (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)) + } + 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 { + "TrueRange()".to_string() + } +} + +// ============================== Chaikin Volatility ============================== + +#[pyclass(name = "ChaikinVolatility", module = "wickra._wickra")] +#[derive(Clone)] +struct PyChaikinVolatility { + inner: wc::ChaikinVolatility, +} + +#[pymethods] +impl PyChaikinVolatility { + #[new] + #[pyo3(signature = (ema_period=10, roc_period=10))] + fn new(ema_period: usize, roc_period: usize) -> PyResult { + Ok(Self { + inner: wc::ChaikinVolatility::new(ema_period, roc_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 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 (ema, roc) = self.inner.periods(); + format!("ChaikinVolatility(ema_period={ema}, roc_period={roc})") + } +} + +// ============================== Z-Score ============================== + +#[pyclass(name = "ZScore", module = "wickra._wickra")] +#[derive(Clone)] +struct PyZScore { + inner: wc::ZScore, +} + +#[pymethods] +impl PyZScore { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::ZScore::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!("ZScore(period={})", self.inner.period()) + } +} + +// ============================== Linear Regression Angle ============================== + +#[pyclass(name = "LinRegAngle", module = "wickra._wickra")] +#[derive(Clone)] +struct PyLinRegAngle { + inner: wc::LinRegAngle, +} + +#[pymethods] +impl PyLinRegAngle { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::LinRegAngle::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!("LinRegAngle(period={})", self.inner.period()) + } +} + // ============================== Module ============================== #[pymodule] @@ -4175,5 +4397,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 179e7dd9..71dcfae9 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -95,6 +95,8 @@ wasm_scalar_indicator!(WasmPercentB, "PercentB", wc::PercentB, period: usize, mu 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); +wasm_scalar_indicator!(WasmZScore, "ZScore", wc::ZScore, period: usize); +wasm_scalar_indicator!(WasmLinRegAngle, "LinRegAngle", wc::LinRegAngle, period: usize); // ---------- KAMA (three params) ---------- @@ -1100,6 +1102,84 @@ impl WasmChoppinessIndex { } } +#[wasm_bindgen(js_name = TrueRange)] +pub struct WasmTrueRange { + inner: wc::TrueRange, +} + +impl Default for WasmTrueRange { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = TrueRange)] +impl WasmTrueRange { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmTrueRange { + Self { + inner: wc::TrueRange::new(), + } + } + 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 = ChaikinVolatility)] +pub struct WasmChaikinVolatility { + inner: wc::ChaikinVolatility, +} + +#[wasm_bindgen(js_class = ChaikinVolatility)] +impl WasmChaikinVolatility { + #[wasm_bindgen(constructor)] + pub fn new(ema_period: usize, roc_period: usize) -> Result { + Ok(Self { + inner: wc::ChaikinVolatility::new(ema_period, roc_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 = NATR)] pub struct WasmNatr { inner: wc::Natr, diff --git a/crates/wickra-core/src/indicators/chaikin_volatility.rs b/crates/wickra-core/src/indicators/chaikin_volatility.rs new file mode 100644 index 00000000..419852cc --- /dev/null +++ b/crates/wickra-core/src/indicators/chaikin_volatility.rs @@ -0,0 +1,223 @@ +//! Chaikin Volatility. + +use crate::error::Result; +use crate::indicators::ema::Ema; +use crate::indicators::roc::Roc; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Chaikin Volatility — the rate of change of a smoothed high-low spread. +/// +/// ```text +/// spread_t = high_t − low_t +/// smoothed_t = EMA(spread, ema_period)_t +/// ChaikinVol = 100 · (smoothed_t − smoothed_{t−roc_period}) / smoothed_{t−roc_period} +/// ``` +/// +/// Marc Chaikin's volatility measure tracks not the *level* of the trading +/// range but how fast it is *widening or narrowing*. A rising value means +/// ranges are expanding (often near a top, as fear spikes); a falling value +/// means they are contracting (often a quiet, complacent market). The classic +/// configuration smooths the spread with a `10`-period EMA and takes its +/// `10`-period rate of change. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ChaikinVolatility}; +/// +/// let mut indicator = ChaikinVolatility::new(10, 10).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 ChaikinVolatility { + ema: Ema, + roc: Roc, + ema_period: usize, + roc_period: usize, +} + +impl ChaikinVolatility { + /// Construct a Chaikin Volatility with explicit EMA and rate-of-change + /// periods. + /// + /// # Errors + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if either period + /// is zero. + pub fn new(ema_period: usize, roc_period: usize) -> Result { + Ok(Self { + ema: Ema::new(ema_period)?, + roc: Roc::new(roc_period)?, + ema_period, + roc_period, + }) + } + + /// Marc Chaikin's classic configuration: `EMA(10)` of the spread, `ROC(10)`. + pub fn classic() -> Self { + Self::new(10, 10).expect("classic Chaikin Volatility params are valid") + } + + /// Configured `(ema_period, roc_period)`. + pub const fn periods(&self) -> (usize, usize) { + (self.ema_period, self.roc_period) + } +} + +impl Indicator for ChaikinVolatility { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let spread = candle.high - candle.low; + let smoothed = self.ema.update(spread)?; + self.roc.update(smoothed) + } + + fn reset(&mut self) { + self.ema.reset(); + self.roc.reset(); + } + + fn warmup_period(&self) -> usize { + // The EMA emits at candle `ema_period`; the ROC then needs + // `roc_period` more smoothed values to span its lookback. + self.ema_period + self.roc_period + } + + fn is_ready(&self) -> bool { + self.roc.is_ready() + } + + fn name(&self) -> &'static str { + "ChaikinVolatility" + } +} + +#[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_range_yields_zero() { + // A constant high-low spread smooths to a constant EMA, whose rate of + // change is zero. + 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 cv = ChaikinVolatility::new(10, 10).unwrap(); + for v in cv.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn widening_range_reads_positive() { + // Each bar's range is strictly wider than the last -> expanding + // volatility -> positive Chaikin Volatility. + let candles: Vec = (0..60) + .map(|i| { + let half = 1.0 + i as f64 * 0.1; + c(100.0 + half, 100.0 - half, 100.0, i) + }) + .collect(); + let mut cv = ChaikinVolatility::new(10, 10).unwrap(); + for v in cv.batch(&candles).into_iter().flatten() { + assert!(v > 0.0, "an expanding range should read positive, got {v}"); + } + } + + #[test] + fn matches_independent_ema_and_roc() { + let candles: Vec = (0..80) + .map(|i| { + let half = 1.0 + (i as f64 * 0.2).sin().abs() * 2.0; + c(100.0 + half, 100.0 - half, 100.0, i) + }) + .collect(); + let mut cv = ChaikinVolatility::new(10, 10).unwrap(); + let mut ema = Ema::new(10).unwrap(); + let mut roc = Roc::new(10).unwrap(); + for (i, candle) in candles.iter().enumerate() { + let got = cv.update(*candle); + match ema.update(candle.high - candle.low) { + Some(e) => { + let want = roc.update(e); + assert_eq!(got, want, "i={i}"); + } + None => assert!(got.is_none(), "i={i}"), + } + } + } + + #[test] + fn first_emission_matches_warmup_period() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut cv = ChaikinVolatility::new(5, 5).unwrap(); + let out = cv.batch(&candles); + assert_eq!(cv.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_zero_period() { + assert!(ChaikinVolatility::new(0, 10).is_err()); + assert!(ChaikinVolatility::new(10, 0).is_err()); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + i as f64; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut cv = ChaikinVolatility::classic(); + cv.batch(&candles); + assert!(cv.is_ready()); + cv.reset(); + assert!(!cv.is_ready()); + assert_eq!(cv.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let half = 1.0 + (i as f64 * 0.25).sin().abs() * 3.0; + c(100.0 + half, 100.0 - half, 100.0, i) + }) + .collect(); + let mut a = ChaikinVolatility::classic(); + let mut b = ChaikinVolatility::classic(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/linreg_angle.rs b/crates/wickra-core/src/indicators/linreg_angle.rs new file mode 100644 index 00000000..6198c197 --- /dev/null +++ b/crates/wickra-core/src/indicators/linreg_angle.rs @@ -0,0 +1,163 @@ +//! Linear Regression Angle. + +use crate::error::Result; +use crate::indicators::linreg_slope::LinRegSlope; +use crate::traits::Indicator; + +/// Linear Regression Angle — the slope of the rolling least-squares fit, +/// expressed as an angle in degrees. +/// +/// ```text +/// LinRegAngle = atan(LinRegSlope) · 180 / π +/// ``` +/// +/// It carries exactly the same information as [`LinRegSlope`](crate::LinRegSlope) +/// — positive while price trends up, negative while it trends down — but maps +/// the unbounded slope through `atan` onto `(−90°, +90°)`. That bounded, +/// price-unit-free scale makes "how steep is the trend" comparable at a glance +/// and across instruments. This is TA-Lib's `LINEARREG_ANGLE`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, LinRegAngle}; +/// +/// let mut indicator = LinRegAngle::new(14).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 LinRegAngle { + slope: LinRegSlope, +} + +impl LinRegAngle { + /// Construct a new rolling linear-regression angle over `period` inputs. + /// + /// # Errors + /// Returns [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if + /// `period < 2` — a regression line is undefined for fewer than two points. + pub fn new(period: usize) -> Result { + Ok(Self { + slope: LinRegSlope::new(period)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.slope.period() + } +} + +impl Indicator for LinRegAngle { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + self.slope.update(value).map(|s| s.atan().to_degrees()) + } + + fn reset(&mut self) { + self.slope.reset(); + } + + fn warmup_period(&self) -> usize { + self.slope.warmup_period() + } + + fn is_ready(&self) -> bool { + self.slope.is_ready() + } + + fn name(&self) -> &'static str { + "LinRegAngle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn unit_slope_is_forty_five_degrees() { + // A series rising by exactly 1 per step has slope 1, and atan(1) = 45°. + let mut angle = LinRegAngle::new(5).unwrap(); + let out = angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + for (i, v) in out.iter().enumerate().take(4) { + assert!(v.is_none(), "index {i} must be None during warmup"); + } + assert_relative_eq!(out[4].unwrap(), 45.0, epsilon = 1e-9); + assert_relative_eq!(out[5].unwrap(), 45.0, epsilon = 1e-9); + } + + #[test] + fn reference_value_steep_slope() { + // period 3 over [1, 2, 9]: slope 4, angle = atan(4) in degrees. + let mut angle = LinRegAngle::new(3).unwrap(); + let out = angle.batch(&[1.0, 2.0, 9.0]); + assert_relative_eq!(out[2].unwrap(), 4.0_f64.atan().to_degrees(), epsilon = 1e-9); + } + + #[test] + fn constant_series_has_zero_angle() { + let mut angle = LinRegAngle::new(8).unwrap(); + for v in angle.batch(&[42.0; 20]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn falling_series_has_negative_angle() { + let prices: Vec = (0..30).map(|i| 100.0 - f64::from(i)).collect(); + let mut angle = LinRegAngle::new(10).unwrap(); + for v in angle.batch(&prices).into_iter().flatten() { + assert!(v < 0.0, "a falling series must have a negative angle"); + } + } + + #[test] + fn stays_within_ninety_degrees() { + let prices: Vec = (0..60) + .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 1000.0) + .collect(); + let mut angle = LinRegAngle::new(14).unwrap(); + for v in angle.batch(&prices).into_iter().flatten() { + assert!(v > -90.0 && v < 90.0, "angle {v} outside (-90, 90)"); + } + } + + #[test] + fn rejects_period_below_two() { + assert!(LinRegAngle::new(0).is_err()); + assert!(LinRegAngle::new(1).is_err()); + assert!(LinRegAngle::new(2).is_ok()); + } + + #[test] + fn reset_clears_state() { + let mut angle = LinRegAngle::new(5).unwrap(); + angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(angle.is_ready()); + angle.reset(); + assert!(!angle.is_ready()); + assert_eq!(angle.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 = LinRegAngle::new(14).unwrap(); + let mut b = LinRegAngle::new(14).unwrap(); + assert_eq!( + a.batch(&prices), + prices.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 30bb6b2c..c555bd04 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -17,6 +17,7 @@ mod bollinger; mod bollinger_bandwidth; mod cci; mod chaikin_oscillator; +mod chaikin_volatility; mod chande_kroll_stop; mod chandelier_exit; mod choppiness_index; @@ -34,6 +35,7 @@ mod hma; mod kama; mod keltner; mod linreg; +mod linreg_angle; mod linreg_slope; mod macd; mod mass_index; @@ -58,6 +60,7 @@ mod t3; mod tema; mod trima; mod trix; +mod true_range; mod tsi; mod typical_price; mod ulcer_index; @@ -70,6 +73,7 @@ mod vwma; mod weighted_close; mod williams_r; mod wma; +mod z_score; mod zlema; pub use accelerator_oscillator::AcceleratorOscillator; @@ -85,6 +89,7 @@ pub use bollinger::{BollingerBands, BollingerOutput}; pub use bollinger_bandwidth::BollingerBandwidth; pub use cci::Cci; pub use chaikin_oscillator::ChaikinOscillator; +pub use chaikin_volatility::ChaikinVolatility; pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput}; pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput}; pub use choppiness_index::ChoppinessIndex; @@ -102,6 +107,7 @@ pub use hma::Hma; pub use kama::Kama; pub use keltner::{Keltner, KeltnerOutput}; pub use linreg::LinearRegression; +pub use linreg_angle::LinRegAngle; pub use linreg_slope::LinRegSlope; pub use macd::{MacdIndicator, MacdOutput}; pub use mass_index::MassIndex; @@ -126,6 +132,7 @@ pub use t3::T3; pub use tema::Tema; pub use trima::Trima; pub use trix::Trix; +pub use true_range::TrueRange; pub use tsi::Tsi; pub use typical_price::TypicalPrice; pub use ulcer_index::UlcerIndex; @@ -138,4 +145,5 @@ pub use vwma::Vwma; pub use weighted_close::WeightedClose; pub use williams_r::WilliamsR; pub use wma::Wma; +pub use z_score::ZScore; pub use zlema::Zlema; diff --git a/crates/wickra-core/src/indicators/true_range.rs b/crates/wickra-core/src/indicators/true_range.rs new file mode 100644 index 00000000..632c4f5a --- /dev/null +++ b/crates/wickra-core/src/indicators/true_range.rs @@ -0,0 +1,151 @@ +//! True Range. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// True Range — the single-bar building block of every ATR-based indicator. +/// +/// ```text +/// TR = max( high − low, |high − close_prev|, |low − close_prev| ) +/// ``` +/// +/// True Range is the greatest of the bar's own range and the two gaps to the +/// previous close, so it captures volatility that opens *between* bars rather +/// than only within them. The first bar has no previous close and falls back +/// to `high − low`. Where [`Atr`](crate::Atr) smooths this series, `TrueRange` +/// exposes it raw, one value per bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TrueRange}; +/// +/// let mut indicator = TrueRange::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 TrueRange { + prev_close: Option, + has_emitted: bool, +} + +impl TrueRange { + /// Construct a new True Range indicator. + pub const fn new() -> Self { + Self { + prev_close: None, + has_emitted: false, + } + } +} + +impl Indicator for TrueRange { + 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); + self.has_emitted = true; + Some(tr) + } + + fn reset(&mut self) { + self.prev_close = None; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TrueRange" + } +} + +#[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_values() { + // Bar 1 has no previous close -> TR = high - low = 12 - 8 = 4. + // Bar 2: prev close 11, TR = max(10-9, |10-11|, |9-11|) = max(1, 1, 2) = 2. + let mut tr = TrueRange::new(); + let out = tr.batch(&[c(12.0, 8.0, 11.0, 0), c(10.0, 9.0, 9.5, 1)]); + assert_relative_eq!(out[0].unwrap(), 4.0, epsilon = 1e-12); + assert_relative_eq!(out[1].unwrap(), 2.0, epsilon = 1e-12); + } + + #[test] + fn emits_from_first_candle() { + let mut tr = TrueRange::new(); + assert_eq!(tr.warmup_period(), 1); + assert!(!tr.is_ready()); + assert!(tr.update(c(11.0, 9.0, 10.0, 0)).is_some()); + assert!(tr.is_ready()); + } + + #[test] + fn never_negative() { + let candles: Vec = (0..120) + .map(|i| { + let base = 100.0 + (i as f64 * 0.3).sin() * 5.0; + c(base + 1.0, base - 1.0, base, i) + }) + .collect(); + let mut tr = TrueRange::new(); + for v in tr.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "true range must be non-negative, got {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut tr = TrueRange::new(); + tr.batch(&[c(12.0, 8.0, 10.0, 0), c(13.0, 9.0, 11.0, 1)]); + assert!(tr.is_ready()); + tr.reset(); + assert!(!tr.is_ready()); + // After reset the next bar again has no previous close. + assert_relative_eq!( + tr.update(c(12.0, 8.0, 10.0, 0)).unwrap(), + 4.0, + epsilon = 1e-12 + ); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..60) + .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 = TrueRange::new(); + let mut b = TrueRange::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/z_score.rs b/crates/wickra-core/src/indicators/z_score.rs new file mode 100644 index 00000000..0603894f --- /dev/null +++ b/crates/wickra-core/src/indicators/z_score.rs @@ -0,0 +1,186 @@ +//! Z-Score. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Z-Score — how many standard deviations the latest price sits from its +/// rolling mean. +/// +/// ```text +/// ZScore = (price − SMA(price, n)) / population_stddev(price, n) +/// ``` +/// +/// A reading of `+2` means price is two standard deviations above its recent +/// average — statistically stretched to the upside; `−2` is the mirror. It is +/// the standard normalisation behind mean-reversion strategies: a large +/// magnitude flags an extension, a return toward `0` flags reversion. A window +/// with zero dispersion (a flat series) yields `0`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, ZScore}; +/// +/// let mut indicator = ZScore::new(20).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 ZScore { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, +} + +impl ZScore { + /// Construct a new Z-Score over a rolling window of `period` prices. + /// + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } +} + +impl Indicator for ZScore { + type Input = f64; + type Output = f64; + + fn update(&mut self, value: f64) -> Option { + if self.window.len() == self.period { + let old = self.window.pop_front().expect("non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.window.push_back(value); + self.sum += value; + self.sum_sq += value * value; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + // Population variance E[x²] − E[x]²; clamp away tiny negative drift. + let variance = (self.sum_sq / n - mean * mean).max(0.0); + let std = variance.sqrt(); + if std == 0.0 { + // A window with no dispersion: the price is exactly its own mean. + return Some(0.0); + } + Some((value - mean) / std) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.window.len() == self.period + } + + fn name(&self) -> &'static str { + "ZScore" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn reference_values() { + // Window [1, 3]: mean 2, population variance (1 + 9)/2 − 4 = 1, + // stddev 1; the latest price 3 is (3 − 2) / 1 = 1 stddev above. + let mut z = ZScore::new(2).unwrap(); + let out = z.batch(&[1.0, 3.0]); + assert!(out[0].is_none()); + assert_relative_eq!(out[1].unwrap(), 1.0, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut z = ZScore::new(10).unwrap(); + for v in z.batch(&[42.0; 30]).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn rising_price_is_above_its_mean() { + // A monotonically rising series always sits above its trailing mean. + let prices: Vec = (0..40).map(f64::from).collect(); + let mut z = ZScore::new(10).unwrap(); + for v in z.batch(&prices).into_iter().flatten() { + assert!( + v > 0.0, + "a rising price should score above its mean, got {v}" + ); + } + } + + #[test] + fn first_value_on_period_th_input() { + let mut z = ZScore::new(5).unwrap(); + let out = z.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + 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 value lands at index period - 1"); + assert_eq!(z.warmup_period(), 5); + } + + #[test] + fn rejects_zero_period() { + assert!(ZScore::new(0).is_err()); + } + + #[test] + fn reset_clears_state() { + let mut z = ZScore::new(5).unwrap(); + z.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert!(z.is_ready()); + z.reset(); + assert!(!z.is_ready()); + assert_eq!(z.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 = ZScore::new(20).unwrap(); + let mut b = ZScore::new(20).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 d6ec41d6..a089b120 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -46,14 +46,15 @@ pub use error::{Error, Result}; pub use indicators::{ AcceleratorOscillator, Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AtrTrailingStop, AwesomeOscillator, BalanceOfPower, BollingerBands, BollingerBandwidth, - BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChandeKrollStop, + BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, 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, + Hma, Kama, Keltner, KeltnerOutput, LinRegAngle, 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, TrueRange, Tsi, TypicalPrice, UlcerIndex, + UltimateOscillator, VerticalHorizontalFilter, VolumePriceTrend, Vortex, VortexOutput, Vwap, + Vwma, WeightedClose, WilliamsR, Wma, ZScore, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/docs/wiki/indicators/statistics/Indicator-LinRegAngle.md b/docs/wiki/indicators/statistics/Indicator-LinRegAngle.md new file mode 100644 index 00000000..99093519 --- /dev/null +++ b/docs/wiki/indicators/statistics/Indicator-LinRegAngle.md @@ -0,0 +1,143 @@ +# LinRegAngle + +> Linear Regression Angle — the slope of the rolling least-squares fit, +> expressed as an angle in degrees. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Price Statistics | +| Input type | `f64` (price) | +| Output type | `f64` | +| Output range | `(−90°, +90°)` | +| Default parameters | `period = 14` (Python) | +| Warmup period | `period` | +| Interpretation | Steepness of the trend; sign is direction, magnitude is pitch. | + +## Formula + +``` +LinRegAngle = atan(LinRegSlope) · 180 / π +``` + +The angle carries exactly the same information as +[`LinRegSlope`](Indicator-LinRegSlope.md) — positive while price trends up, +negative while it trends down — but maps the unbounded slope through `atan` +onto `(−90°, +90°)`. That bounded, price-unit-free scale makes "how steep is +the trend" comparable at a glance and across instruments. This is TA-Lib's +`LINEARREG_ANGLE`. + +## Parameters + +`period` — the regression window. Must be at least `2` (a line needs two +points). The Python binding defaults it to `14`; the Rust and Node +constructors require it explicitly. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/linreg_angle.rs`: + +```rust +impl Indicator for LinRegAngle { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +`LinRegAngle` is a **scalar** indicator: it consumes one `f64` price per step. +Because `Input = f64` it can sit inside a [`Chain`](../../Indicator-Chaining.md). + +## Warmup + +`LinRegAngle::new(14).warmup_period() == 14`. The first value lands once the +window holds a full `period` prices. + +## Edge cases + +- **`period < 2`.** Rejected at construction — a regression line is undefined + for fewer than two points. +- **Unit slope.** A series rising by exactly `1` per step has slope `1`, and + `atan(1) = 45°`. +- **Flat series.** A constant input has slope `0` and therefore angle `0`. +- **Reset.** `angle.reset()` clears the rolling regression window. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, LinRegAngle}; + +fn main() -> Result<(), Box> { + let mut angle = LinRegAngle::new(5)?; + // Closes rising by 1 per step -> slope 1 -> atan(1) = 45 degrees. + let out = angle.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[None, None, None, None, Some(45.0), Some(45.0)] +``` + +### Python + +```python +import numpy as np +import wickra as ta + +angle = ta.LinRegAngle(5) +print(angle.batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]))) +``` + +Output: + +``` +[ nan nan nan nan 45. 45.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const angle = new ta.LinRegAngle(5); +console.log(angle.batch([1, 2, 3, 4, 5, 6])); +``` + +Output: + +``` +[ NaN, NaN, NaN, NaN, 45, 45 ] +``` + +## Interpretation + +The angle is read like a slope: sign gives trend direction, magnitude gives +how steeply price is pitched. Because it is bounded to `±90°` it is convenient +for thresholds — e.g. "only trade with the trend while the angle exceeds +`30°`" — and for comparing trend pitch across instruments with different price +scales, which the raw [`LinRegSlope`](Indicator-LinRegSlope.md) cannot do. + +## Common pitfalls + +- **Reading degrees as a price quantity.** The angle depends on the chart's + implicit scaling; treat it as a relative steepness gauge, not an absolute. +- **Tiny periods.** `period = 2` reduces the fit to the last difference. + +## References + +The angle of an ordinary least-squares fit to a rolling price window; matches +TA-Lib's `LINEARREG_ANGLE`. + +## See also + +- [Indicator-LinRegSlope.md](Indicator-LinRegSlope.md) — the same fit's slope, + in raw price-per-bar units. +- [Indicator-LinearRegression.md](Indicator-LinearRegression.md) — the + endpoint of the same rolling fit. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/statistics/Indicator-ZScore.md b/docs/wiki/indicators/statistics/Indicator-ZScore.md new file mode 100644 index 00000000..142cc75f --- /dev/null +++ b/docs/wiki/indicators/statistics/Indicator-ZScore.md @@ -0,0 +1,139 @@ +# ZScore + +> Z-Score — how many standard deviations the latest price sits from its +> rolling mean. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Price Statistics | +| Input type | `f64` (price) | +| Output type | `f64` | +| Output range | unbounded around zero (standard deviations) | +| Default parameters | `period = 20` (Python) | +| Warmup period | `period` | +| Interpretation | Large magnitude = stretched; a return toward `0` = reversion. | + +## Formula + +``` +ZScore = (price − SMA(price, n)) / population_stddev(price, n) +``` + +The Z-Score normalises price against its own recent behaviour: it subtracts +the rolling mean and divides by the rolling population standard deviation. A +reading of `+2` means price is two standard deviations above its `n`-bar +average — statistically stretched to the upside; `−2` is the mirror. It is the +standard input to mean-reversion strategies. + +## Parameters + +`period` — the rolling window for the mean and standard deviation. The Python +binding defaults it to `20`; the Rust and Node constructors require it. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/z_score.rs`: + +```rust +impl Indicator for ZScore { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +`ZScore` is a **scalar** indicator: it consumes one `f64` price per step. +Because `Input = f64` it can sit inside a [`Chain`](../../Indicator-Chaining.md). + +## Warmup + +`ZScore::new(20).warmup_period() == 20`. The first value lands once the window +holds a full `period` prices. + +## Edge cases + +- **Zero dispersion.** A flat window has a zero standard deviation; `ZScore` + is defined as `0` rather than dividing by zero. +- **Rising series.** A monotonically rising price always scores above its + trailing mean (positive). +- **Reset.** `z.reset()` clears the rolling window. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, ZScore}; + +fn main() -> Result<(), Box> { + let mut z = ZScore::new(2)?; + // Window [1, 3]: mean 2, population stddev 1; latest 3 -> (3 - 2) / 1. + println!("{:?}", z.batch(&[1.0, 3.0])); + Ok(()) +} +``` + +Output: + +``` +[None, Some(1.0)] +``` + +### Python + +```python +import numpy as np +import wickra as ta + +z = ta.ZScore(2) +print(z.batch(np.array([1.0, 3.0]))) +``` + +Output: + +``` +[nan 1.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const z = new ta.ZScore(2); +console.log(z.batch([1, 3])); +``` + +Output: + +``` +[ NaN, 1 ] +``` + +## Interpretation + +Z-Score is the workhorse of mean-reversion: a common rule enters against the +move when `|ZScore| > 2` and exits as it crosses back through `0`. Read +together with a trend filter — a high Z-Score in a strong trend is often +continuation, not exhaustion, so the reversion edge is best in ranging +regimes. + +## Common pitfalls + +- **Trading extremes blindly.** A trending market can hold a high Z-Score for + a long time; pair it with a regime filter. +- **Tiny periods.** A short window makes the mean and stddev jumpy. + +## References + +The standard statistical Z-Score (standard score) applied to a rolling price +window. + +## See also + +- [Indicator-StdDev.md](../volatility/Indicator-StdDev.md) — the rolling + standard deviation in the denominator. +- [Indicator-LinearRegression.md](Indicator-LinearRegression.md) — another + rolling statistical fit. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volatility/Indicator-ChaikinVolatility.md b/docs/wiki/indicators/volatility/Indicator-ChaikinVolatility.md new file mode 100644 index 00000000..2be47e0a --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-ChaikinVolatility.md @@ -0,0 +1,154 @@ +# ChaikinVolatility + +> Chaikin Volatility — the rate of change of a smoothed high-low spread; +> is the trading range widening or narrowing? + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volatility & Bands | +| Input type | `Candle` (uses `high`, `low`) | +| Output type | `f64` | +| Output range | unbounded around zero (percent) | +| Default parameters | `ema_period = 10`, `roc_period = 10` (Python) | +| Warmup period | `ema_period + roc_period` | +| Interpretation | Positive = ranges expanding, negative = ranges contracting. | + +## Formula + +``` +spread_t = high_t − low_t +smoothed_t = EMA(spread, ema_period)_t +ChaikinVol = 100 · (smoothed_t − smoothed_{t−roc_period}) / smoothed_{t−roc_period} +``` + +Marc Chaikin's volatility measure tracks not the *level* of the trading range +but how fast it is *widening or narrowing*. The bar's high-low spread is +EMA-smoothed, then run through a rate-of-change: a rising value means ranges +are expanding (often near a market top, as fear spikes), a falling value means +they are contracting (a quiet, complacent market). The classic configuration +smooths the spread with a `10`-period EMA and takes its `10`-period rate of +change. + +## Parameters + +- `ema_period` — the EMA that smooths the high-low spread (`10`). +- `roc_period` — the rate-of-change lookback over the smoothed spread (`10`). + +`ChaikinVolatility::classic()` returns the `(10, 10)` configuration. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/chaikin_volatility.rs`: + +```rust +impl Indicator for ChaikinVolatility { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`ChaikinVolatility` is a **candle-input** indicator that reads `high` and +`low`. 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 + +`ChaikinVolatility::classic().warmup_period() == 20`. The EMA emits at candle +`ema_period`; the rate-of-change then needs `roc_period` more smoothed values. + +## Edge cases + +- **Constant range.** A constant high-low spread smooths to a constant EMA, + whose rate of change is `0`. +- **Expanding range.** A monotonically widening range reads positive. +- **Reset.** `cv.reset()` clears the inner EMA and ROC. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, ChaikinVolatility}; + +fn main() -> Result<(), Box> { + let mut cv = ChaikinVolatility::new(10, 10)?; + // A constant 2-wide range -> constant EMA -> zero rate of change. + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + f64::from(i); + Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i).unwrap() + }) + .collect(); + println!("{:?}", cv.batch(&candles).last().unwrap()); + Ok(()) +} +``` + +Output: + +``` +Some(0.0) +``` + +### Python + +```python +import numpy as np +import wickra as ta + +cv = ta.ChaikinVolatility(10, 10) +n = 40 +base = np.arange(n, dtype=float) + 100.0 +print(cv.batch(base + 1.0, base - 1.0)[-1]) +``` + +Output: + +``` +0.0 +``` + +### Node + +```javascript +const ta = require('wickra'); +const cv = new ta.ChaikinVolatility(10, 10); +const base = Array.from({ length: 40 }, (_, i) => 100 + i); +const out = cv.batch(base.map((b) => b + 1), base.map((b) => b - 1)); +console.log(out[out.length - 1]); +``` + +Output: + +``` +0 +``` + +## Interpretation + +A rising Chaikin Volatility warns that ranges are expanding fast — Chaikin +associated sharp rises with market tops, where panic widens bars. A low or +falling reading is the calm, range-contracting market that often precedes a +move. It complements [`Atr`](Indicator-Atr.md): ATR gives the level of +volatility, Chaikin Volatility gives its momentum. + +## Common pitfalls + +- **Reading it as a volatility level.** It is a *rate of change* — zero means + steady ranges, not zero volatility. +- **Feeding it scalar prices.** It needs the `high`/`low` bar. + +## References + +Marc Chaikin's Chaikin Volatility; the EMA-of-spread rate-of-change definition +here is the standard one. + +## See also + +- [Indicator-Atr.md](Indicator-Atr.md) — the level of per-bar volatility. +- [Indicator-TrueRange.md](Indicator-TrueRange.md) — raw single-bar range. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volatility/Indicator-TrueRange.md b/docs/wiki/indicators/volatility/Indicator-TrueRange.md new file mode 100644 index 00000000..189ea33f --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-TrueRange.md @@ -0,0 +1,147 @@ +# TrueRange + +> True Range — the single-bar volatility measure that ATR is the average +> of, exposed raw. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volatility & Bands | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `f64` | +| Output range | `[0, ∞)` (price scale) | +| Default parameters | none (no parameters) | +| Warmup period | `1` | +| Interpretation | Per-bar volatility including overnight gaps. | + +## Formula + +``` +TR = max( high − low, |high − close_prev|, |low − close_prev| ) +``` + +True Range is the greatest of the bar's own range and the two gaps to the +previous close, so it captures volatility that opens *between* bars — an +overnight gap — not only the range printed within a bar. The first bar has no +previous close and falls back to `high − low`. Where [`Atr`](Indicator-Atr.md) +is the Wilder-smoothed average of this series, `TrueRange` exposes it raw, one +value per bar. + +## Parameters + +`TrueRange` takes **no parameters** — `TrueRange::new()` in Rust, +`wickra.TrueRange()` in Python, `new ta.TrueRange()` in Node. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/true_range.rs`: + +```rust +impl Indicator for TrueRange { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`TrueRange` is a **candle-input** indicator that reads `high`, `low` and +`close` (the close drives the gap terms). 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 + +`TrueRange::new().warmup_period() == 1`. It emits a value from the very first +candle — that bar simply has no previous close and uses `high − low`. + +## Edge cases + +- **First bar.** No previous close: `TR = high − low`. +- **Gap.** A bar that opens far from the prior close has a `TR` larger than + its own `high − low`. +- **Non-negative.** `TR` is always `>= 0`. +- **Reset.** `tr.reset()` drops the previous close; the next bar restarts. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, TrueRange}; + +fn main() -> Result<(), Box> { + let mut tr = TrueRange::new(); + let out = tr.batch(&[ + Candle::new(11.0, 12.0, 8.0, 11.0, 1.0, 0)?, // no prev close -> 12 - 8 + Candle::new(9.5, 10.0, 9.0, 9.5, 1.0, 1)?, // prev close 11 -> max(1, 1, 2) + ]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[Some(4.0), Some(2.0)] +``` + +### Python + +```python +import numpy as np +import wickra as ta + +tr = ta.TrueRange() +print(tr.batch( + np.array([12.0, 10.0]), np.array([8.0, 9.0]), np.array([11.0, 9.5]) +)) +``` + +Output: + +``` +[4. 2.] +``` + +### Node + +```javascript +const ta = require('wickra'); +const tr = new ta.TrueRange(); +console.log(tr.batch([12, 10], [8, 9], [11, 9.5])); +``` + +Output: + +``` +[ 4, 2 ] +``` + +## Interpretation + +Read `TrueRange` as raw per-bar volatility. It spikes on wide-range or gapping +bars and shrinks in quiet stretches. Smoothing it with a moving average gives +[`Atr`](Indicator-Atr.md); using it directly is useful for volatility-scaled +position sizing or for spotting single outlier bars an average would hide. + +## Common pitfalls + +- **Confusing it with `high − low`.** On a gap bar the True Range is larger — + that is the whole point. +- **Feeding it scalar prices.** It needs the full `high`/`low`/`close` bar. + +## References + +J. Welles Wilder Jr.'s True Range, from *New Concepts in Technical Trading +Systems* (1978). + +## See also + +- [Indicator-Atr.md](Indicator-Atr.md) — the Wilder-smoothed average of the + True Range. +- [Indicator-ChaikinVolatility.md](Indicator-ChaikinVolatility.md) — a + rate-of-change volatility measure. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.