diff --git a/bindings/node/index.js b/bindings/node/index.js index 26f1ceae..aa8561ca 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, 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, 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 @@ -339,6 +339,10 @@ module.exports.Coppock = Coppock module.exports.AroonOscillator = AroonOscillator module.exports.Vortex = Vortex module.exports.MassIndex = MassIndex +module.exports.NATR = NATR +module.exports.StdDev = StdDev +module.exports.UlcerIndex = UlcerIndex +module.exports.HistoricalVolatility = HistoricalVolatility 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 6984d04d..13ab683f 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -108,6 +108,8 @@ node_scalar_indicator!(ZlemaNode, "ZLEMA", wc::Zlema); node_scalar_indicator!(MomNode, "MOM", wc::Mom); node_scalar_indicator!(CmoNode, "CMO", wc::Cmo); node_scalar_indicator!(DpoNode, "DPO", wc::Dpo); +node_scalar_indicator!(StdDevNode, "StdDev", wc::StdDev); +node_scalar_indicator!(UlcerIndexNode, "UlcerIndex", wc::UlcerIndex); // ============================== MACD ============================== @@ -1145,6 +1147,99 @@ impl PmoNode { // ============================== VWMA ============================== +// ============================== NATR ============================== + +#[napi(js_name = "NATR")] +pub struct NatrNode { + inner: wc::Natr, +} + +#[napi] +impl NatrNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::Natr::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(high.len()); + for i in 0..high.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Historical Volatility ============================== + +#[napi(js_name = "HistoricalVolatility")] +pub struct HistoricalVolatilityNode { + inner: wc::HistoricalVolatility, +} + +#[napi] +impl HistoricalVolatilityNode { + #[napi(constructor)] + pub fn new(period: u32, trading_periods: u32) -> napi::Result { + Ok(Self { + inner: wc::HistoricalVolatility::new(period as usize, trading_periods 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 + } +} + // ============================== Aroon Oscillator ============================== #[napi(js_name = "AroonOscillator")] diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index 970a3d03..1a4901a5 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -76,6 +76,59 @@ class TRIMA: @property def value(self) -> Optional[float]: ... +class NATR: + def __init__(self, period: int = 14) -> None: ... + def update(self, candle: CandleLike) -> Optional[float]: ... + def batch( + self, + high: NDArray[np.float64], + low: NDArray[np.float64], + close: NDArray[np.float64], + ) -> NDArray[np.float64]: ... + def reset(self) -> None: ... + def is_ready(self) -> bool: ... + def warmup_period(self) -> int: ... + @property + def period(self) -> int: ... + @property + def value(self) -> Optional[float]: ... + +class StdDev: + 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: ... + @property + def value(self) -> Optional[float]: ... + +class UlcerIndex: + 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: ... + @property + def value(self) -> Optional[float]: ... + +class HistoricalVolatility: + def __init__(self, period: int = 20, trading_periods: int = 252) -> 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 periods(self) -> Tuple[int, int]: ... + @property + def value(self) -> Optional[float]: ... + class AroonOscillator: def __init__(self, period: int = 14) -> None: ... def update(self, candle: CandleLike) -> Optional[float]: ... diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index a1f3f76c..1a69c877 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1519,6 +1519,235 @@ impl PyAroon { } } +// ============================== NATR ============================== + +#[pyclass(name = "NATR", module = "wickra._wickra")] +#[derive(Clone)] +struct PyNatr { + inner: wc::Natr, +} + +#[pymethods] +impl PyNatr { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Natr::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("NATR(period={})", self.inner.period()) + } +} + +// ============================== StdDev ============================== + +#[pyclass(name = "StdDev", module = "wickra._wickra")] +#[derive(Clone)] +struct PyStdDev { + inner: wc::StdDev, +} + +#[pymethods] +impl PyStdDev { + #[new] + #[pyo3(signature = (period=20))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::StdDev::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("StdDev(period={})", self.inner.period()) + } +} + +// ============================== Ulcer Index ============================== + +#[pyclass(name = "UlcerIndex", module = "wickra._wickra")] +#[derive(Clone)] +struct PyUlcerIndex { + inner: wc::UlcerIndex, +} + +#[pymethods] +impl PyUlcerIndex { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::UlcerIndex::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("UlcerIndex(period={})", self.inner.period()) + } +} + +// ============================== Historical Volatility ============================== + +#[pyclass(name = "HistoricalVolatility", module = "wickra._wickra")] +#[derive(Clone)] +struct PyHistoricalVolatility { + inner: wc::HistoricalVolatility, +} + +#[pymethods] +impl PyHistoricalVolatility { + #[new] + #[pyo3(signature = (period=20, trading_periods=252))] + fn new(period: usize, trading_periods: usize) -> PyResult { + Ok(Self { + inner: wc::HistoricalVolatility::new(period, trading_periods).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 periods(&self) -> (usize, usize) { + self.inner.periods() + } + #[getter] + fn value(&self) -> Option { + self.inner.value() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (p, t) = self.inner.periods(); + format!("HistoricalVolatility(period={p}, trading_periods={t})") + } +} + // ============================== Aroon Oscillator ============================== #[pyclass(name = "AroonOscillator", module = "wickra._wickra")] @@ -2556,5 +2785,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 95c51c20..d565bb31 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -87,6 +87,9 @@ wasm_scalar_indicator!(WasmStochRsi, "StochRSI", wc::StochRsi, rsi_period: usize wasm_scalar_indicator!(WasmDpo, "DPO", wc::Dpo, period: usize); wasm_scalar_indicator!(WasmPpo, "PPO", wc::Ppo, fast: usize, slow: usize); wasm_scalar_indicator!(WasmCoppock, "Coppock", wc::Coppock, roc_long: usize, roc_short: usize, wma_period: usize); +wasm_scalar_indicator!(WasmStdDev, "StdDev", wc::StdDev, period: usize); +wasm_scalar_indicator!(WasmUlcerIndex, "UlcerIndex", wc::UlcerIndex, period: usize); +wasm_scalar_indicator!(WasmHistoricalVolatility, "HistoricalVolatility", wc::HistoricalVolatility, period: usize, trading_periods: usize); // ---------- KAMA (three params) ---------- @@ -372,6 +375,44 @@ impl WasmUltimateOscillator { } } +#[wasm_bindgen(js_name = NATR)] +pub struct WasmNatr { + inner: wc::Natr, +} + +#[wasm_bindgen(js_class = NATR)] +impl WasmNatr { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::Natr::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, JsError> { + let c = make_candle(high, low, close, 0.0)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close 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], 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 = AroonOscillator)] pub struct WasmAroonOscillator { inner: wc::AroonOscillator, diff --git a/crates/wickra-core/src/indicators/historical_volatility.rs b/crates/wickra-core/src/indicators/historical_volatility.rs new file mode 100644 index 00000000..eafdb6f9 --- /dev/null +++ b/crates/wickra-core/src/indicators/historical_volatility.rs @@ -0,0 +1,253 @@ +//! Historical Volatility. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Historical Volatility — the annualised standard deviation of log returns. +/// +/// This is the realised (backward-looking) volatility used to price options +/// and size risk: +/// +/// ```text +/// r_t = ln(price_t / price_{t−1}) +/// HV = stddev_sample(r over period) · √trading_periods · 100 +/// ``` +/// +/// The log returns over the window are measured with the **sample** standard +/// deviation (divisor `n − 1`, the unbiased estimator), then scaled to an +/// annual figure by `√trading_periods` — `252` for daily bars, `52` for +/// weekly, `12` for monthly — and expressed as a percentage. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, HistoricalVolatility}; +/// +/// // 20-bar window, 252 trading days per year. +/// let mut indicator = HistoricalVolatility::new(20, 252).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct HistoricalVolatility { + period: usize, + trading_periods: usize, + prev_price: Option, + /// Rolling window of the last `period` log returns. + window: VecDeque, + sum: f64, + sum_sq: f64, + last: Option, +} + +impl HistoricalVolatility { + /// Construct a new Historical Volatility indicator. + /// + /// `period` is the number of log returns in the rolling window; + /// `trading_periods` is the annualisation factor (`252` daily, `52` + /// weekly, `12` monthly). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `trading_periods` is `0`, + /// or [`Error::InvalidPeriod`] if `period == 1` (the sample standard + /// deviation needs at least two returns). + pub fn new(period: usize, trading_periods: usize) -> Result { + if period == 0 || trading_periods == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "historical volatility period must be >= 2", + }); + } + Ok(Self { + period, + trading_periods, + prev_price: None, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + last: None, + }) + } + + /// Configured `(period, trading_periods)`. + pub const fn periods(&self) -> (usize, usize) { + (self.period, self.trading_periods) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for HistoricalVolatility { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; state is left untouched. + return self.last; + } + let Some(prev) = self.prev_price else { + self.prev_price = Some(input); + return None; + }; + self.prev_price = Some(input); + + let log_return = if prev <= 0.0 || input <= 0.0 { + // Log return is undefined for non-positive prices. + 0.0 + } else { + (input / prev).ln() + }; + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.window.push_back(log_return); + self.sum += log_return; + self.sum_sq += log_return * log_return; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + // Sample variance (Bessel's correction): Σ(x−mean)² / (n−1). + let variance = ((self.sum_sq - n * mean * mean) / (n - 1.0)).max(0.0); + let hv = variance.sqrt() * (self.trading_periods as f64).sqrt() * 100.0; + self.last = Some(hv); + Some(hv) + } + + fn reset(&mut self) { + self.prev_price = None; + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // The first log return needs a previous price, then the window fills. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "HistoricalVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!( + HistoricalVolatility::new(0, 252), + Err(Error::PeriodZero) + )); + assert!(matches!( + HistoricalVolatility::new(20, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn new_rejects_period_one() { + assert!(matches!( + HistoricalVolatility::new(1, 252), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut hv = HistoricalVolatility::new(5, 252).unwrap(); + assert_eq!(hv.warmup_period(), 6); + let out = hv.batch(&(1..=20).map(f64::from).collect::>()); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn constant_series_yields_zero() { + // Flat prices -> all log returns are 0 -> zero volatility. + let mut hv = HistoricalVolatility::new(10, 252).unwrap(); + let out = hv.batch(&[100.0; 40]); + for v in out.iter().skip(10).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn geometric_series_yields_zero() { + // A constant growth factor gives a constant log return -> zero stddev. + let mut hv = HistoricalVolatility::new(10, 252).unwrap(); + let prices: Vec = (0..40).map(|i| 100.0 * 1.01_f64.powi(i)).collect(); + let out = hv.batch(&prices); + for v in out.iter().skip(10).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn output_is_non_negative() { + let mut hv = HistoricalVolatility::new(20, 252).unwrap(); + let prices: Vec = (1..=200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0) + .collect(); + for v in hv.batch(&prices).into_iter().flatten() { + assert!(v >= 0.0, "volatility must be non-negative, got {v}"); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut hv = HistoricalVolatility::new(5, 252).unwrap(); + let out = hv.batch(&(1..=20).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(hv.update(f64::NAN), last); + assert_eq!(hv.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut hv = HistoricalVolatility::new(5, 252).unwrap(); + hv.batch(&(1..=20).map(f64::from).collect::>()); + assert!(hv.is_ready()); + hv.reset(); + assert!(!hv.is_ready()); + assert_eq!(hv.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = HistoricalVolatility::new(20, 252).unwrap().batch(&prices); + let mut b = HistoricalVolatility::new(20, 252).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 9e2248a6..0c1ec701 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -17,6 +17,7 @@ mod dema; mod donchian; mod dpo; mod ema; +mod historical_volatility; mod hma; mod kama; mod keltner; @@ -24,6 +25,7 @@ mod macd; mod mass_index; mod mfi; mod mom; +mod natr; mod obv; mod pmo; mod ppo; @@ -32,6 +34,7 @@ mod roc; mod rsi; mod sma; mod smma; +mod std_dev; mod stoch_rsi; mod stochastic; mod t3; @@ -39,6 +42,7 @@ mod tema; mod trima; mod trix; mod tsi; +mod ulcer_index; mod ultimate_oscillator; mod vortex; mod vwap; @@ -60,6 +64,7 @@ pub use dema::Dema; pub use donchian::{Donchian, DonchianOutput}; pub use dpo::Dpo; pub use ema::Ema; +pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; pub use kama::Kama; pub use keltner::{Keltner, KeltnerOutput}; @@ -67,6 +72,7 @@ pub use macd::{MacdIndicator, MacdOutput}; pub use mass_index::MassIndex; pub use mfi::Mfi; pub use mom::Mom; +pub use natr::Natr; pub use obv::Obv; pub use pmo::Pmo; pub use ppo::Ppo; @@ -75,6 +81,7 @@ pub use roc::Roc; pub use rsi::Rsi; pub use sma::Sma; pub use smma::Smma; +pub use std_dev::StdDev; pub use stoch_rsi::StochRsi; pub use stochastic::{Stochastic, StochasticOutput}; pub use t3::T3; @@ -82,6 +89,7 @@ pub use tema::Tema; pub use trima::Trima; pub use trix::Trix; pub use tsi::Tsi; +pub use ulcer_index::UlcerIndex; pub use ultimate_oscillator::UltimateOscillator; pub use vortex::{Vortex, VortexOutput}; pub use vwap::{RollingVwap, Vwap}; diff --git a/crates/wickra-core/src/indicators/natr.rs b/crates/wickra-core/src/indicators/natr.rs new file mode 100644 index 00000000..8a80cca0 --- /dev/null +++ b/crates/wickra-core/src/indicators/natr.rs @@ -0,0 +1,185 @@ +//! Normalized Average True Range. + +use crate::error::Result; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +use super::Atr; + +/// Normalized Average True Range — [`Atr`] expressed as a percentage of price. +/// +/// `Atr` reports volatility in raw price units, which makes its readings +/// impossible to compare across instruments at different price levels. NATR +/// fixes that by dividing by the current close: +/// +/// ```text +/// NATR = 100 · ATR / close +/// ``` +/// +/// A NATR of `2.0` always means "the average true range is 2 % of price", +/// whether the instrument trades at $10 or $10 000 — so NATR values are +/// directly comparable, and stop distances or position sizes expressed as a +/// NATR multiple behave consistently across a portfolio. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Natr}; +/// +/// let mut indicator = Natr::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, 10.0, i64::from(i)).unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Natr { + atr: Atr, + last: Option, +} + +impl Natr { + /// Construct a new NATR with the given ATR period. + /// + /// # Errors + /// + /// Returns [`crate::Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + atr: Atr::new(period)?, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.atr.period() + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Natr { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let atr = self.atr.update(candle)?; + let natr = if candle.close == 0.0 { + // NATR is undefined against a zero close. + 0.0 + } else { + 100.0 * atr / candle.close + }; + self.last = Some(natr); + Some(natr) + } + + fn reset(&mut self) { + self.atr.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.atr.warmup_period() + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "NATR" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, 1.0, ts).unwrap() + } + + #[test] + fn new_rejects_zero_period() { + assert!(Natr::new(0).is_err()); + } + + #[test] + fn warmup_period_matches_atr() { + let natr = Natr::new(14).unwrap(); + assert_eq!(natr.warmup_period(), 14); + } + + #[test] + fn natr_is_atr_over_close_as_percent() { + // NATR must equal 100 * ATR / close, bar for bar. + let candles: Vec = (0..60) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0; + candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i) + }) + .collect(); + let natr_out = Natr::new(14).unwrap().batch(&candles); + let atr_out = Atr::new(14).unwrap().batch(&candles); + for (i, (n, a)) in natr_out.iter().zip(atr_out.iter()).enumerate() { + match (n, a) { + (Some(nv), Some(av)) => { + let want = 100.0 * av / candles[i].close; + assert_relative_eq!(*nv, want, epsilon = 1e-9); + } + (None, None) => {} + _ => panic!("warmup mismatch at {i}"), + } + } + } + + #[test] + fn flat_market_yields_zero() { + // No range -> ATR is 0 -> NATR is 0. + let mut natr = Natr::new(5).unwrap(); + let candles: Vec = (0..30) + .map(|i| candle(100.0, 100.0, 100.0, 100.0, i)) + .collect(); + for v in natr.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut natr = Natr::new(5).unwrap(); + let candles: Vec = (0..20) + .map(|i| candle(100.0, 102.0, 98.0, 101.0, i)) + .collect(); + natr.batch(&candles); + assert!(natr.is_ready()); + natr.reset(); + assert!(!natr.is_ready()); + assert_eq!(natr.update(candles[0]), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let mid = 100.0 + (i as f64 * 0.35).sin() * 9.0; + candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i) + }) + .collect(); + let batch = Natr::new(14).unwrap().batch(&candles); + let mut b = Natr::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/std_dev.rs b/crates/wickra-core/src/indicators/std_dev.rs new file mode 100644 index 00000000..bec83f1b --- /dev/null +++ b/crates/wickra-core/src/indicators/std_dev.rs @@ -0,0 +1,200 @@ +//! Rolling population standard deviation. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Rolling population standard deviation over the last `period` values. +/// +/// ```text +/// mean = (1/n) · Σ price +/// variance = (1/n) · Σ price² − mean² +/// StdDev = √variance +/// ``` +/// +/// This is the **population** standard deviation (divisor `n`, not `n − 1`) — +/// the same dispersion measure that drives [`BollingerBands`](crate::BollingerBands). +/// It is maintained as an O(1) rolling state machine: a running sum and a +/// running sum-of-squares, updated by one add and one subtract per bar. Tiny +/// negative variances from floating-point cancellation are clamped to zero +/// before the square root. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, StdDev}; +/// +/// let mut indicator = StdDev::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct StdDev { + period: usize, + window: VecDeque, + sum: f64, + sum_sq: f64, + last: Option, +} + +impl StdDev { + /// Construct a new rolling standard deviation with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for StdDev { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; the window is left untouched. + return self.last; + } + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + self.sum_sq -= old * old; + } + self.window.push_back(input); + self.sum += input; + self.sum_sq += input * input; + if self.window.len() < self.period { + return None; + } + let n = self.period as f64; + let mean = self.sum / n; + // Clamp floating-point cancellation noise: variance is never negative. + let variance = (self.sum_sq / n - mean * mean).max(0.0); + let sd = variance.sqrt(); + self.last = Some(sd); + Some(sd) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "StdDev" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(StdDev::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn reference_value() { + // StdDev(3) of [2, 4, 6]: mean = 4, variance = (4+0+4)/3 = 8/3. + let mut sd = StdDev::new(3).unwrap(); + let out = sd.batch(&[2.0, 4.0, 6.0]); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_relative_eq!(out[2].unwrap(), (8.0_f64 / 3.0).sqrt(), epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + let mut sd = StdDev::new(5).unwrap(); + let out = sd.batch(&[42.0; 20]); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn matches_naive_definition() { + let prices: Vec = (1..=60) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0) + .collect(); + let period = 10; + let got = StdDev::new(period).unwrap().batch(&prices); + for (i, g) in got.iter().enumerate() { + if let Some(value) = g { + let window = &prices[i + 1 - period..=i]; + let mean = window.iter().sum::() / period as f64; + let var = window.iter().map(|x| (x - mean).powi(2)).sum::() / period as f64; + assert_relative_eq!(*value, var.sqrt(), epsilon = 1e-9); + } + } + } + + #[test] + fn ignores_non_finite_input() { + let mut sd = StdDev::new(3).unwrap(); + let out = sd.batch(&[2.0, 4.0, 6.0]); + let last = out[2]; + assert!(last.is_some()); + assert_eq!(sd.update(f64::NAN), last); + assert_eq!(sd.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut sd = StdDev::new(3).unwrap(); + sd.batch(&[1.0, 2.0, 3.0, 4.0]); + assert!(sd.is_ready()); + sd.reset(); + assert!(!sd.is_ready()); + assert_eq!(sd.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=60) + .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0) + .collect(); + let batch = StdDev::new(14).unwrap().batch(&prices); + let mut b = StdDev::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/ulcer_index.rs b/crates/wickra-core/src/indicators/ulcer_index.rs new file mode 100644 index 00000000..0a5337eb --- /dev/null +++ b/crates/wickra-core/src/indicators/ulcer_index.rs @@ -0,0 +1,229 @@ +//! Ulcer Index. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Ulcer Index — Peter Martin's downside-only volatility / risk measure. +/// +/// Standard deviation punishes upside and downside moves equally; the Ulcer +/// Index measures only the **pain of drawdowns**. For each bar it computes the +/// percentage drop from the highest price of the trailing window, squares it, +/// and reports the root-mean-square over the window: +/// +/// ```text +/// drawdown_t = 100 · (price_t − max(price, period)_t) / max(price, period)_t +/// UlcerIndex = √( mean( drawdown² over period ) ) +/// ``` +/// +/// A pure up-trend never trades below its own running high, so its Ulcer Index +/// is `0`; the deeper and longer the drawdowns, the higher the reading. It is +/// the volatility measure of choice for risk-adjusted return ratios (the +/// "Martin ratio" / UPI). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, UlcerIndex}; +/// +/// let mut indicator = UlcerIndex::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 8.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct UlcerIndex { + period: usize, + /// Rolling window of the last `period` prices (for the trailing maximum). + prices: VecDeque, + /// Rolling window of the last `period` squared percentage drawdowns. + drawdowns_sq: VecDeque, + sum_sq: f64, + last: Option, +} + +impl UlcerIndex { + /// Construct a new Ulcer Index with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prices: VecDeque::with_capacity(period), + drawdowns_sq: VecDeque::with_capacity(period), + sum_sq: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for UlcerIndex { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; state is left untouched. + return self.last; + } + if self.prices.len() == self.period { + self.prices.pop_front(); + } + self.prices.push_back(input); + if self.prices.len() < self.period { + return None; + } + let max_price = self + .prices + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let drawdown = if max_price == 0.0 { + 0.0 + } else { + 100.0 * (input - max_price) / max_price + }; + let sq = drawdown * drawdown; + + if self.drawdowns_sq.len() == self.period { + self.sum_sq -= self.drawdowns_sq.pop_front().expect("window is non-empty"); + } + self.drawdowns_sq.push_back(sq); + self.sum_sq += sq; + if self.drawdowns_sq.len() < self.period { + return None; + } + let ui = (self.sum_sq / self.period as f64).sqrt(); + self.last = Some(ui); + Some(ui) + } + + fn reset(&mut self) { + self.prices.clear(); + self.drawdowns_sq.clear(); + self.sum_sq = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // `period` prices fill the trailing-max window, then `period` squared + // drawdowns fill the RMS window. + 2 * self.period - 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "UlcerIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(UlcerIndex::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn reference_values() { + // UlcerIndex(2): warmup = 3. + // [10, 8, 12, 9]: + // bar 3: window [8,12], max 12, drawdown 0; sq window [400, 0] + // -> UI = sqrt(200). + // bar 4: window [12,9], max 12, drawdown -25, sq 625; sq window [0, 625] + // -> UI = sqrt(312.5). + let mut ui = UlcerIndex::new(2).unwrap(); + let out = ui.batch(&[10.0, 8.0, 12.0, 9.0]); + assert_eq!(ui.warmup_period(), 3); + assert_eq!(out[0], None); + assert_eq!(out[1], None); + assert_relative_eq!(out[2].unwrap(), 200.0_f64.sqrt(), epsilon = 1e-12); + assert_relative_eq!(out[3].unwrap(), 312.5_f64.sqrt(), epsilon = 1e-12); + } + + #[test] + fn pure_uptrend_yields_zero() { + // Price never trades below its own running high: no drawdown at all. + let mut ui = UlcerIndex::new(5).unwrap(); + let out = ui.batch(&(1..=40).map(f64::from).collect::>()); + for v in out.iter().skip(ui.warmup_period() - 1).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn constant_series_yields_zero() { + let mut ui = UlcerIndex::new(5).unwrap(); + let out = ui.batch(&[50.0; 30]); + for v in out.iter().skip(ui.warmup_period() - 1).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_non_negative() { + let mut ui = UlcerIndex::new(14).unwrap(); + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 15.0) + .collect(); + for v in ui.batch(&prices).into_iter().flatten() { + assert!(v >= 0.0, "Ulcer Index must be non-negative, got {v}"); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut ui = UlcerIndex::new(2).unwrap(); + let out = ui.batch(&[10.0, 8.0, 12.0, 9.0]); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(ui.update(f64::NAN), last); + assert_eq!(ui.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut ui = UlcerIndex::new(3).unwrap(); + ui.batch(&[10.0, 8.0, 12.0, 9.0, 11.0, 7.0]); + assert!(ui.is_ready()); + ui.reset(); + assert!(!ui.is_ready()); + assert_eq!(ui.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0) + .collect(); + let batch = UlcerIndex::new(14).unwrap().batch(&prices); + let mut b = UlcerIndex::new(14).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 6d3dd005..4d2358b9 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -45,10 +45,11 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AwesomeOscillator, BollingerBands, - BollingerOutput, Cci, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, Ema, Hma, Kama, - Keltner, KeltnerOutput, MacdIndicator, MacdOutput, MassIndex, Mfi, Mom, Obv, Pmo, Ppo, Psar, - Roc, RollingVwap, Rsi, Sma, Smma, StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix, - Tsi, UltimateOscillator, Vortex, VortexOutput, Vwap, Vwma, WilliamsR, Wma, Zlema, T3, + BollingerOutput, Cci, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, Ema, + HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, MacdIndicator, MacdOutput, MassIndex, + Mfi, Mom, Natr, Obv, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, + Stochastic, StochasticOutput, Tema, Trima, Trix, Tsi, UlcerIndex, UltimateOscillator, Vortex, + VortexOutput, Vwap, Vwma, WilliamsR, Wma, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 1951d1b7..90e9ed1e 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -118,6 +118,10 @@ Rust / Python / Node examples. They are grouped by family, mirroring the - [Indicator-Keltner.md](indicators/volatility/Indicator-Keltner.md) - [Indicator-Donchian.md](indicators/volatility/Indicator-Donchian.md) - [Indicator-Psar.md](indicators/volatility/Indicator-Psar.md) +- [Indicator-Natr.md](indicators/volatility/Indicator-Natr.md) +- [Indicator-StdDev.md](indicators/volatility/Indicator-StdDev.md) +- [Indicator-UlcerIndex.md](indicators/volatility/Indicator-UlcerIndex.md) +- [Indicator-HistoricalVolatility.md](indicators/volatility/Indicator-HistoricalVolatility.md) **Volume** — price moves weighted or confirmed by traded volume. diff --git a/docs/wiki/Indicators-Overview.md b/docs/wiki/Indicators-Overview.md index d3dc1d7f..e52c3523 100644 --- a/docs/wiki/Indicators-Overview.md +++ b/docs/wiki/Indicators-Overview.md @@ -1,6 +1,6 @@ # Indicators Overview -Wickra ships 42 indicators, organised in source under the four classical +Wickra ships 46 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 @@ -136,6 +136,10 @@ measure — that lives in the volatility module by source convention. | Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive | |-----------|-----------|-------|--------|-------|----------|--------|-----------| | `Atr` | Wilder-smoothed True Range; per-bar absolute volatility. | `Candle` | `f64` | `[0, ∞)` (price scale) | `period = 14` (Python) | `period` | [Indicator-Atr.md](indicators/volatility/Indicator-Atr.md) | +| `Natr` | `100·ATR/close`; ATR as a percentage, comparable across instruments. | `Candle` | `f64` | `[0, ∞)` (percent) | `period = 14` (Python) | `period` | [Indicator-Natr.md](indicators/volatility/Indicator-Natr.md) | +| `StdDev` | Rolling population standard deviation of price. | `f64` | `f64` | `[0, ∞)` (price scale) | `period = 20` (Python) | `period` | [Indicator-StdDev.md](indicators/volatility/Indicator-StdDev.md) | +| `UlcerIndex` | RMS of trailing-high drawdowns; downside-only risk. | `f64` | `f64` | `[0, ∞)` (percent) | `period = 14` (Python) | `2·period − 1` | [Indicator-UlcerIndex.md](indicators/volatility/Indicator-UlcerIndex.md) | +| `HistoricalVolatility` | Annualised sample stddev of log returns. | `f64` | `f64` | `[0, ∞)` (annualised percent) | `(period=20, trading_periods=252)` (Python) | `period + 1` | [Indicator-HistoricalVolatility.md](indicators/volatility/Indicator-HistoricalVolatility.md) | ### Trailing stop diff --git a/docs/wiki/indicators/volatility/Indicator-HistoricalVolatility.md b/docs/wiki/indicators/volatility/Indicator-HistoricalVolatility.md new file mode 100644 index 00000000..31d794b6 --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-HistoricalVolatility.md @@ -0,0 +1,166 @@ +# HistoricalVolatility + +> Historical Volatility — the annualised standard deviation of log returns, +> the realised volatility used to price options and size risk. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volatility | +| Sub-category | Return-based | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | `[0, ∞)` (annualised percent) | +| Default parameters | `(period = 20, trading_periods = 252)` (Python) | +| Warmup period | `period + 1` | +| Interpretation | Annualised volatility of returns, in percent. | + +## Formula + +``` +r_t = ln(price_t / price_{t−1}) +HV = stddev_sample(r over period) · √trading_periods · 100 +``` + +The log returns over the window are measured with the **sample** standard +deviation (divisor `n − 1`, Bessel's correction — the unbiased volatility +estimator), then annualised by `√trading_periods` and expressed as a +percentage. `trading_periods` is the number of bars in a year for the +data's frequency: `252` for daily bars, `52` for weekly, `12` for +monthly. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|-------------------|---------|----------------|-------------|-------------| +| `period` | `usize` | `20` (Python) | `>= 2` | Number of log returns in the window. `0` errors with `Error::PeriodZero`; `1` with `Error::InvalidPeriod` (the sample stddev needs two returns). | +| `trading_periods` | `usize` | `252` (Python) | `>= 1` | Annualisation factor. `0` errors with `Error::PeriodZero`. | + +The Python binding defaults the pair to `(20, 252)`. The `periods` +property returns `(period, trading_periods)`. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/historical_volatility.rs`: + +```rust +impl Indicator for HistoricalVolatility { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +A single `f64` close in, an `Option` out. Python maps this to +`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` / +`Array` (NaN warmup). + +## Warmup + +`warmup_period() == period + 1`. The first log return needs a previous +price, and the window must then hold `period` returns — so the first +non-`None` output lands on input `period + 1`. + +## Edge cases + +- **Constant series.** A flat price series has all log returns equal to + `0`, so volatility is `0.0` (`constant_series_yields_zero` pins this). +- **Geometric series.** A constant growth factor produces a *constant* + log return; its standard deviation — and so HV — is `0` + (`geometric_series_yields_zero` pins this). +- **Non-positive prices.** A log return is undefined when either price is + `<= 0`; that return is treated as `0`. +- **Non-negative.** Volatility is a standard deviation and is never + negative (`output_is_non_negative` pins this). +- **NaN / infinity inputs.** Non-finite inputs are silently dropped. +- **Reset.** `hv.reset()` clears the previous price, the window and the + running sums. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, HistoricalVolatility}; + +fn main() -> Result<(), Box> { + // 20-bar window, 252 trading days per year. + let mut hv = HistoricalVolatility::new(20, 252)?; + let prices: Vec = (0..40).map(|i| 100.0 * 1.01_f64.powi(i)).collect(); + let out = hv.batch(&prices); + println!("warmup_period = {}", hv.warmup_period()); + // A perfectly geometric series has constant returns -> zero volatility. + println!("last = {:?}", out.last().unwrap()); + Ok(()) +} +``` + +Output: + +``` +warmup_period = 21 +last = Some(0.0) +``` + +### Python + +```python +import numpy as np +import wickra as ta + +hv = ta.HistoricalVolatility() # (period=20, trading_periods=252) +prices = np.full(40, 100.0) # flat series +print(hv.batch(prices)[-1]) # no return variation -> 0 +``` + +Output: + +``` +0.0 +``` + +### Node + +```javascript +const ta = require('wickra'); +// 52 trading periods per year for weekly bars. +const hv = new ta.HistoricalVolatility(20, 52); +const prices = Array.from({ length: 60 }, (_, i) => 100 + Math.sin(i * 0.3) * 5); +console.log('warmupPeriod:', hv.warmupPeriod()); +``` + +## Interpretation + +`HistoricalVolatility` is the realised-volatility number quoted in +options and risk work — "this stock has been running at 30 % annualised +vol". Compare it against an option's *implied* volatility to judge whether +options are cheap or rich, feed it into position-sizing (smaller size as +HV rises), or track its own trend: volatility clusters, so a rising HV +tends to keep rising. + +Always match `trading_periods` to your bar frequency — annualising daily +bars with `252`, weekly with `52`, monthly with `12`. Using the wrong +factor rescales every reading. + +## Common pitfalls + +- **Mismatched `trading_periods`.** Annualising weekly data with `252` + inflates HV by `√(252/52) ≈ 2.2×`. +- **Confusing it with `StdDev`.** `StdDev` is the population dispersion of + *prices*; `HistoricalVolatility` is the sample (`n − 1`) dispersion of + *log returns*, annualised. + +## References + +Historical (realised) volatility is the standard `√252`-annualised +standard deviation of log returns; the unbiased `n − 1` estimator is the +conventional choice for volatility estimation. + +## See also + +- [Indicator-StdDev.md](Indicator-StdDev.md) — population dispersion of + raw prices. +- [Indicator-Natr.md](Indicator-Natr.md) — range-based volatility as a + percentage. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volatility/Indicator-Natr.md b/docs/wiki/indicators/volatility/Indicator-Natr.md new file mode 100644 index 00000000..99bb298c --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-Natr.md @@ -0,0 +1,144 @@ +# NATR + +> Normalized Average True Range — ATR expressed as a percentage of price, so +> volatility is comparable across instruments. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volatility | +| Sub-category | Range-average | +| Input type | `Candle` (uses `high`, `low`, `close`) | +| Output type | `f64` | +| Output range | `[0, ∞)` (percent) | +| Default parameters | `period = 14` (Python) | +| Warmup period | `period` | +| Interpretation | Average true range as a percent of the close. | + +## Formula + +``` +NATR = 100 · ATR(period) / close +``` + +[`Atr`](Indicator-Atr.md) measures volatility in raw price units — a `2.0` +ATR is large on a $10 stock and tiny on a $5000 index. Dividing by the +current close converts it to a percentage, so a NATR of `2.0` always +means "the average true range is 2 % of price". That makes NATR readings +comparable across a portfolio, and stop or position-size rules expressed +as a NATR multiple behave consistently regardless of price level. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------------|-------------|-------------| +| `period` | `usize` | `14` (Python) | `>= 1` | Wilder smoothing period of the underlying ATR. `0` errors with `Error::PeriodZero`. | + +The Python binding defaults `period` to `14`. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/natr.rs`: + +```rust +impl Indicator for Natr { + type Input = Candle; + type Output = f64; + // update(&mut self, input: Candle) -> Option +} +``` + +`NATR` is a **candle-input** indicator: it reads `high`, `low` and +`close`. In Python the 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 `batch(high, low, close)`. + +## Warmup + +`Natr::new(period).warmup_period() == period` — identical to the +underlying `Atr`, which is Wilder-seeded over `period` true ranges. + +## Edge cases + +- **Flat market.** A market with no range has `ATR = 0`, so `NATR = 0` + (`flat_market_yields_zero` pins this). +- **Zero close.** NATR is undefined against a `0.0` close; the indicator + reports `0.0` for that bar. +- **Identity.** NATR equals `100 · ATR / close` bar for bar + (`natr_is_atr_over_close_as_percent` pins this). +- **Reset.** `natr.reset()` clears the underlying ATR. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Candle, Indicator, Natr}; + +fn main() -> Result<(), Box> { + let mut natr = Natr::new(14)?; + let candles: Vec = (0..40) + .map(|i| { + let p = 100.0 + f64::from(i); + Candle::new(p, p + 2.0, p - 2.0, p, 10.0, i64::from(i)).unwrap() + }) + .collect(); + let out = natr.batch(&candles); + println!("warmup_period = {}", natr.warmup_period()); + println!("last = {:?}", out.last().unwrap()); + Ok(()) +} +``` + +### Python + +```python +import numpy as np +import wickra as ta + +natr = ta.NATR(14) +high = np.arange(102.0, 142.0) +low = high - 4.0 +close = high - 2.0 +print(natr.batch(high, low, close)[-1]) +``` + +### Node + +```javascript +const ta = require('wickra'); +const natr = new ta.NATR(14); +const high = Array.from({ length: 40 }, (_, i) => 102 + i); +const low = high.map((h) => h - 4); +const close = high.map((h) => h - 2); +console.log(natr.batch(high, low, close).at(-1)); +``` + +## Interpretation + +`Natr` is the tool of choice whenever an ATR-based rule must work across +instruments or across long stretches of time where the price level +drifts. A volatility filter like "skip entries when NATR > 5" or a stop +at "entry − 3 × NATR %" stays meaningful on any symbol. Use raw +[`Atr`](Indicator-Atr.md) only when you specifically want the answer in +price units (e.g. to place a stop a fixed number of points away). + +## Common pitfalls + +- **Feeding it scalar prices.** It needs `high`/`low`/`close`. +- **Confusing it with ATR.** NATR is a percentage; an ATR-multiple stop + and a NATR-multiple stop are different distances. + +## References + +NATR is the percentage-normalised ATR as implemented by TA-Lib (`NATR`); +the underlying ATR is Wilder's from *New Concepts in Technical Trading +Systems* (1978). + +## See also + +- [Indicator-Atr.md](Indicator-Atr.md) — the price-unit original. +- [Indicator-HistoricalVolatility.md](Indicator-HistoricalVolatility.md) — + return-based annualised volatility. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volatility/Indicator-StdDev.md b/docs/wiki/indicators/volatility/Indicator-StdDev.md new file mode 100644 index 00000000..e2f44e3d --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-StdDev.md @@ -0,0 +1,156 @@ +# StdDev + +> Rolling population standard deviation — the dispersion of the last +> `period` prices around their mean. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volatility | +| Sub-category | Dispersion | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | `[0, ∞)` (price-difference scale) | +| Default parameters | `period = 20` (Python) | +| Warmup period | `period` | +| Interpretation | Spread of recent prices; the raw volatility behind Bollinger Bands. | + +## Formula + +``` +mean = (1/n) · Σ price +variance = (1/n) · Σ price² − mean² +StdDev = √variance +``` + +This is the **population** standard deviation (divisor `n`, not `n − 1`) +— the exact dispersion measure that drives the band width of +[`BollingerBands`](Indicator-BollingerBands.md). It is maintained as an +O(1) state machine: a running sum and a running sum-of-squares, each +updated by one add and one subtract per bar. Floating-point cancellation +can leave the computed variance very slightly negative; it is clamped to +zero before the square root. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------------|-------------|-------------| +| `period` | `usize` | `20` (Python) | `>= 1` | Rolling window length. `0` errors with `Error::PeriodZero`. `period = 1` always yields `0`. | + +The Python binding defaults `period` to `20`. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/std_dev.rs`: + +```rust +impl Indicator for StdDev { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +A single `f64` close in, an `Option` out. Python maps this to +`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` / +`Array` (NaN warmup). + +## Warmup + +`StdDev::new(period).warmup_period() == period`. The first non-`None` +value is emitted once the window holds `period` prices. + +## Edge cases + +- **Constant series.** A flat series has zero dispersion, so the output + is `0.0` (`constant_series_yields_zero` pins this). +- **NaN / infinity inputs.** Non-finite inputs are silently dropped; the + window and the running sums are left untouched. +- **Reset.** `sd.reset()` clears the window and both running sums. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, StdDev}; + +fn main() -> Result<(), Box> { + let mut sd = StdDev::new(3)?; + let out: Vec> = sd.batch(&[2.0, 4.0, 6.0]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[None, None, Some(1.6329931618554525)] +``` + +The window `[2, 4, 6]` has mean `4` and variance `(4 + 0 + 4) / 3 = 8/3`, +so the standard deviation is `√(8/3) ≈ 1.633`. This matches the +`reference_value` test in `crates/wickra-core/src/indicators/std_dev.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +sd = ta.StdDev(3) +print(sd.batch(np.array([2.0, 4.0, 6.0]))) +``` + +Output: + +``` +[ nan nan 1.6329932] +``` + +### Node + +```javascript +const ta = require('wickra'); +const sd = new ta.StdDev(3); +console.log(sd.batch([2, 4, 6])); +``` + +Output: + +``` +[ NaN, NaN, 1.6329931618554525 ] +``` + +## Interpretation + +`StdDev` is the most direct volatility measure in the library: large +values mean prices are scattered widely around their mean, small values +mean a tight, quiet market. Use it on its own as a volatility filter, or +recognise it as the engine inside `BollingerBands` — multiplying `StdDev` +by the band multiplier and adding it to an `Sma` reproduces the bands +exactly. + +## Common pitfalls + +- **Expecting the sample standard deviation.** `StdDev` divides by `n`, + not `n − 1`. For the unbiased return-based estimator use + [`HistoricalVolatility`](Indicator-HistoricalVolatility.md). +- **Comparing across instruments.** The output is in price units; a + `StdDev` of `5` is not comparable between a $10 and a $1000 asset. + +## References + +The population standard deviation is standard statistics; this +implementation matches the dispersion term of John Bollinger's Bollinger +Bands and pandas' `rolling(period).std(ddof=0)`. + +## See also + +- [Indicator-BollingerBands.md](Indicator-BollingerBands.md) — bands built + from this dispersion measure. +- [Indicator-HistoricalVolatility.md](Indicator-HistoricalVolatility.md) — + annualised volatility of log returns. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/volatility/Indicator-UlcerIndex.md b/docs/wiki/indicators/volatility/Indicator-UlcerIndex.md new file mode 100644 index 00000000..6d04cf67 --- /dev/null +++ b/docs/wiki/indicators/volatility/Indicator-UlcerIndex.md @@ -0,0 +1,161 @@ +# UlcerIndex + +> Ulcer Index — Peter Martin's downside-only risk measure: the +> root-mean-square of recent drawdowns. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Volatility | +| Sub-category | Downside risk | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | `[0, ∞)` (percent) | +| Default parameters | `period = 14` (Python) | +| Warmup period | `2·period − 1` | +| Interpretation | Depth and duration of drawdowns; `0` means no drawdown at all. | + +## Formula + +``` +max_t = highest price over the trailing `period` bars +drawdown_t = 100 · (price_t − max_t) / max_t +UlcerIndex = √( mean( drawdown² over period ) ) +``` + +Standard deviation treats an up-move and a down-move as equally +"volatile". The Ulcer Index measures only the **pain of being underwater**: +for each bar it takes the percentage drop from the trailing high, squares +it, and reports the root-mean-square. A market that only rises has no +drawdown and an Ulcer Index of `0`; the deeper and longer the drawdowns, +the higher the reading. It is the volatility term in the Martin ratio +(Ulcer Performance Index). + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------------|-------------|-------------| +| `period` | `usize` | `14` (Python) | `>= 1` | Look-back for both the trailing high and the RMS window. `0` errors with `Error::PeriodZero`. | + +The Python binding defaults `period` to `14`. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/ulcer_index.rs`: + +```rust +impl Indicator for UlcerIndex { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +A single `f64` close in, an `Option` out. Python maps this to +`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` / +`Array` (NaN warmup). + +## Warmup + +`UlcerIndex::new(period).warmup_period() == 2·period − 1`. The first +`period` prices fill the trailing-maximum window; the per-bar squared +drawdown then needs another `period − 1` bars to fill the RMS window. + +## Edge cases + +- **Pure up-trend.** Price never trades below its own running high, so + every drawdown — and the Ulcer Index — is `0` + (`pure_uptrend_yields_zero` pins this). +- **Constant series.** A flat series has no drawdown; the output is `0.0` + (`constant_series_yields_zero` pins this). +- **Non-negative.** The Ulcer Index is an RMS of real numbers and is + never negative (`output_is_non_negative` pins this). +- **NaN / infinity inputs.** Non-finite inputs are silently dropped. +- **Reset.** `ui.reset()` clears both rolling windows and the sum. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, UlcerIndex}; + +fn main() -> Result<(), Box> { + let mut ui = UlcerIndex::new(2)?; + let out: Vec> = ui.batch(&[10.0, 8.0, 12.0, 9.0]); + println!("{:?}", out); + Ok(()) +} +``` + +Output: + +``` +[None, None, Some(14.142135623730951), Some(17.67766952966369)] +``` + +`UlcerIndex(2)` warms up after `3` bars. At bar 3 the squared drawdowns in +the window are `[400, 0]`, so the index is `√(400/2) = √200`. At bar 4 +they are `[0, 625]`, giving `√(625/2) = √312.5`. This matches the +`reference_values` test in +`crates/wickra-core/src/indicators/ulcer_index.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +ui = ta.UlcerIndex(2) +print(ui.batch(np.array([10.0, 8.0, 12.0, 9.0]))) +``` + +Output: + +``` +[ nan nan 14.1421356 17.6776695] +``` + +### Node + +```javascript +const ta = require('wickra'); +const ui = new ta.UlcerIndex(2); +console.log(ui.batch([10, 8, 12, 9])); +``` + +Output: + +``` +[ NaN, NaN, 14.142135623730951, 17.67766952966369 ] +``` + +## Interpretation + +`UlcerIndex` answers "how uncomfortable has holding this been?" — a high +reading means deep or prolonged drawdowns, a low reading means a smooth +ride up. It is most useful for *comparing* instruments or strategies on a +downside-risk basis, and as the denominator of the Ulcer Performance +Index (`(return − risk-free) / UlcerIndex`), a Sharpe-ratio analogue that +penalises only downside volatility. + +## Common pitfalls + +- **Reading it as two-sided volatility.** The Ulcer Index ignores upside + entirely — a wildly choppy *up*-trend can still score near `0`. Use + [`StdDev`](Indicator-StdDev.md) for two-sided dispersion. +- **Forgetting the doubled warmup.** Warmup is `2·period − 1`, not + `period`. + +## References + +Peter Martin and Byron McCann, *The Investor's Guide to Fidelity Funds* +(1989); the index is also documented at StockCharts. The trailing-high +drawdown RMS here follows that definition. + +## See also + +- [Indicator-StdDev.md](Indicator-StdDev.md) — two-sided dispersion. +- [Indicator-Atr.md](Indicator-Atr.md) — per-bar range volatility. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.