diff --git a/CHANGELOG.md b/CHANGELOG.md index 68973403..7d105e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Yang-Zhang Volatility.** Yang & Zhang (2000) gold-standard OHLC + estimator: a convex blend of overnight (close-to-open), open-to-close + and Rogers-Satchell variances. The blending factor + `k = 0.34 / (1.34 + (n+1)/(n-1))` is the one that minimises + estimator variance under driftless GBM with overnight gaps. The + overnight and open-to-close pieces use sample variance (Bessel's + correction, divisor `n−1`), so the indicator needs `period + 1` bars + to emit. Output annualised to a percent. Defaults: `period = 20`, + `trading_periods = 252`. The recommended OHLC estimator for equities, + futures, and any asset with material close-to-open gaps. +- **Rogers-Satchell Volatility.** Drift-free OHLC realised-volatility + estimator from Rogers, Satchell & Yoon (1994). Per-bar sample is + `ln(H/C)·ln(H/O) + ln(L/C)·ln(L/O)`; every term is non-negative by + construction (high >= open, close; low <= open, close), so the + rolling mean is exact, not biased, under arbitrary drift. The + algebraic drift-cancellation is what differentiates it from + Garman-Klass. Output annualised to a percent. Defaults: + `period = 20`, `trading_periods = 252`. +- **Garman-Klass Volatility.** Garman & Klass (1980) OHLC realised + volatility estimator: per-bar sample is + `0.5·(ln H/L)² − (2·ln2 − 1)·(ln C/O)²`, then take the annualised + square root of the rolling mean. Roughly 7.4× more statistically + efficient than close-to-close stddev under driftless GBM. Output + annualised to a percent. Defaults: `period = 20`, + `trading_periods = 252`. +- **Parkinson Volatility.** Michael Parkinson's (1980) high-low realised + volatility estimator: `sigma² = (1 / (4n·ln2)) · Σ (ln(H/L))²`. Output + annualised to a percent in the same style as `HistoricalVolatility` + (pass `trading_periods = 1` for the raw per-bar `sigma·100` figure). + Roughly 5× more statistically efficient than close-to-close stddev + under a driftless-GBM assumption. Defaults: `period = 20`, + `trading_periods = 252`. +- **RVIVolatility (Relative Volatility Index).** Donald Dorsey's + RSI-shaped volatility gauge: partition the rolling standard + deviation of close into "up" (close rose) and "down" (close fell) + samples, Wilder-smooth each side, and compute + `100 · AvgUp / (AvgUp + AvgDown)`. Bounded on `[0, 100]`; saturates + at `100` in pure uptrends, `0` in pure downtrends, and falls back to + `50` on a completely flat series (same undefined-RS convention as + `RSI`). Single `period` parameter (default `10`) drives both the + stddev window and the Wilder smoothing. Named `RVIVolatility` rather + than plain `RVI` to disambiguate from Relative Vigor Index, which + ships in Family 02 under the shorter `RVI` name. - **Family 03 — MACD & Price Oscillators.** `Stc` (Schaff Trend Cycle, Doug Schaff): doubly-`Stochastic`-smoothed MACD producing a bounded `[0, 100]` reading that reacts faster than `MACD` itself. Four diff --git a/README.md b/README.md index b0a38415..00406536 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -91 streaming-first indicators across eight families. Every one passes the +96 streaming-first indicators across eight families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -119,7 +119,7 @@ semantics tests. | Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia | | Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter | | Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC | -| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility | +| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility | | Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop | | Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement | | Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle | @@ -195,7 +195,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 85 indicators +│ ├── wickra-core/ core engine + all 96 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 24da5a22..e9d5917d 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -70,6 +70,7 @@ const scalarFactories = { LinRegAngle: () => new wickra.LinRegAngle(14), LaguerreRSI: () => new wickra.LaguerreRSI(0.5), ConnorsRSI: () => new wickra.ConnorsRSI(3, 2, 100), + RVIVolatility: () => new wickra.RVIVolatility(10), }; for (const [name, make] of Object.entries(scalarFactories)) { @@ -122,6 +123,10 @@ const candleScalar = { ChoppinessIndex: { make: () => new wickra.ChoppinessIndex(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, TrueRange: { make: () => new wickra.TrueRange(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, ChaikinVolatility: { make: () => new wickra.ChaikinVolatility(10, 10), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + ParkinsonVolatility: { make: () => new wickra.ParkinsonVolatility(20, 252), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) }, + GarmanKlassVolatility: { make: () => new wickra.GarmanKlassVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + RogersSatchellVolatility: { make: () => new wickra.RogersSatchellVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + YangZhangVolatility: { make: () => new wickra.YangZhangVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, }; for (const [name, d] of Object.entries(candleScalar)) { @@ -279,6 +284,51 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => { assert.ok(Math.abs(out[4] - 45) < 1e-9); }); +test('RVIVolatility pure uptrend saturates at 100', () => { + const prices = Array.from({ length: 40 }, (_, i) => i + 1); + const out = new wickra.RVIVolatility(5).batch(prices); + for (let i = 9; i < out.length; i++) { + assert.ok(Math.abs(out[i] - 100) < 1e-9, `RVIVolatility[${i}] = ${out[i]}`); + } +}); + +test('ParkinsonVolatility zero-range bars yield zero', () => { + const n = 30; + const h = Array(n).fill(10); + const l = Array(n).fill(10); + const out = new wickra.ParkinsonVolatility(14, 252).batch(h, l); + for (let i = 13; i < n; i++) { + assert.ok(Math.abs(out[i]) < 1e-12, `Parkinson[${i}] = ${out[i]}`); + } +}); + +test('GarmanKlassVolatility zero-movement bars yield zero', () => { + const n = 30; + const flat = Array(n).fill(10); + const out = new wickra.GarmanKlassVolatility(14, 252).batch(flat, flat, flat, flat); + for (let i = 13; i < n; i++) { + assert.ok(Math.abs(out[i]) < 1e-12, `GK[${i}] = ${out[i]}`); + } +}); + +test('RogersSatchellVolatility zero-movement bars yield zero', () => { + const n = 30; + const flat = Array(n).fill(10); + const out = new wickra.RogersSatchellVolatility(14, 252).batch(flat, flat, flat, flat); + for (let i = 13; i < n; i++) { + assert.ok(Math.abs(out[i]) < 1e-12, `RS[${i}] = ${out[i]}`); + } +}); + +test('YangZhangVolatility zero-movement bars yield zero', () => { + const n = 30; + const flat = Array(n).fill(10); + const out = new wickra.YangZhangVolatility(14, 252).batch(flat, flat, flat, flat); + for (let i = 14; i < n; i++) { + assert.ok(Math.abs(out[i]) < 1e-12, `YZ[${i}] = ${out[i]}`); + } +}); + test('ZeroLagMACD on a flat series converges to zero', () => { const out = new wickra.ZeroLagMACD(3, 5, 3).batch(Array(60).fill(42)); // Last interleaved row: macd, signal, histogram all 0. diff --git a/bindings/node/index.js b/bindings/node/index.js index 39986fca..d6eb4f39 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, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, 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 +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, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, 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, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -405,3 +405,8 @@ module.exports.UltimateOscillator = UltimateOscillator module.exports.PPO = PPO module.exports.Coppock = Coppock module.exports.VWMA = VWMA +module.exports.RVIVolatility = RVIVolatility +module.exports.ParkinsonVolatility = ParkinsonVolatility +module.exports.GarmanKlassVolatility = GarmanKlassVolatility +module.exports.RogersSatchellVolatility = RogersSatchellVolatility +module.exports.YangZhangVolatility = YangZhangVolatility diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index a6bc6fde..e4261318 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -119,6 +119,47 @@ node_scalar_indicator!(ZScoreNode, "ZScore", wc::ZScore); node_scalar_indicator!(McGinleyDynamicNode, "McGinleyDynamic", wc::McGinleyDynamic); node_scalar_indicator!(FramaNode, "FRAMA", wc::Frama); +// RviVolatility (Relative Volatility Index, Donald Dorsey). Disambiguated +// from `RVI` = Relative Vigor Index in Family 02. Takes a single `period` +// parameter and additionally rejects `period == 1` (a 1-bar standard +// deviation is always zero), so the `clamp_period`-to-1 strategy from +// `node_scalar_indicator!` would panic via `must`. Hand-rolled fallible +// constructor instead, throws a JS error on bad period. +#[napi(js_name = "RVIVolatility")] +pub struct RviVolatilityNode { + inner: wc::RviVolatility, +} + +#[napi] +impl RviVolatilityNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::RviVolatility::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 + } +} + // ============================== MACD ============================== /// MACD triple: macd line, signal line, histogram. @@ -3261,6 +3302,246 @@ impl ChaikinVolatilityNode { } } +// ============================== Yang-Zhang Volatility ============================== + +#[napi(js_name = "YangZhangVolatility")] +pub struct YangZhangVolatilityNode { + inner: wc::YangZhangVolatility, +} + +#[napi] +impl YangZhangVolatilityNode { + #[napi(constructor)] + pub fn new(period: u32, trading_periods: u32) -> napi::Result { + Ok(Self { + inner: wc::YangZhangVolatility::new(period as usize, trading_periods as usize) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(candle)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + let n = open.len(); + if high.len() != n || low.len() != n || close.len() != n { + return Err(NapiError::from_reason( + "open, high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let candle = + wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Rogers-Satchell Volatility ============================== + +#[napi(js_name = "RogersSatchellVolatility")] +pub struct RogersSatchellVolatilityNode { + inner: wc::RogersSatchellVolatility, +} + +#[napi] +impl RogersSatchellVolatilityNode { + #[napi(constructor)] + pub fn new(period: u32, trading_periods: u32) -> napi::Result { + Ok(Self { + inner: wc::RogersSatchellVolatility::new(period as usize, trading_periods as usize) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(candle)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + let n = open.len(); + if high.len() != n || low.len() != n || close.len() != n { + return Err(NapiError::from_reason( + "open, high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let candle = + wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Garman-Klass Volatility ============================== + +#[napi(js_name = "GarmanKlassVolatility")] +pub struct GarmanKlassVolatilityNode { + inner: wc::GarmanKlassVolatility, +} + +#[napi] +impl GarmanKlassVolatilityNode { + #[napi(constructor)] + pub fn new(period: u32, trading_periods: u32) -> napi::Result { + Ok(Self { + inner: wc::GarmanKlassVolatility::new(period as usize, trading_periods as usize) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(candle)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + let n = open.len(); + if high.len() != n || low.len() != n || close.len() != n { + return Err(NapiError::from_reason( + "open, high, low, close must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let candle = + wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Parkinson Volatility ============================== + +#[napi(js_name = "ParkinsonVolatility")] +pub struct ParkinsonVolatilityNode { + inner: wc::ParkinsonVolatility, +} + +#[napi] +impl ParkinsonVolatilityNode { + #[napi(constructor)] + pub fn new(period: u32, trading_periods: u32) -> napi::Result { + Ok(Self { + inner: wc::ParkinsonVolatility::new(period as usize, trading_periods 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")] diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index dc78bda6..a40a2cc8 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -104,6 +104,11 @@ from ._wickra import ( AtrTrailingStop, TrueRange, ChaikinVolatility, + RVIVolatility, + ParkinsonVolatility, + GarmanKlassVolatility, + RogersSatchellVolatility, + YangZhangVolatility, # Volume OBV, VWAP, @@ -205,6 +210,11 @@ __all__ = [ "AtrTrailingStop", "TrueRange", "ChaikinVolatility", + "RVIVolatility", + "ParkinsonVolatility", + "GarmanKlassVolatility", + "RogersSatchellVolatility", + "YangZhangVolatility", # Volume "OBV", "VWAP", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 30461aab..88ce9b60 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -5593,6 +5593,362 @@ impl PyLinRegAngle { } } +#[pyclass( + name = "YangZhangVolatility", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyYangZhangVolatility { + inner: wc::YangZhangVolatility, +} + +#[pymethods] +impl PyYangZhangVolatility { + #[new] + #[pyo3(signature = (period=20, trading_periods=252))] + fn new(period: usize, trading_periods: usize) -> PyResult { + Ok(Self { + inner: wc::YangZhangVolatility::new(period, trading_periods).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 open, high, low, close (all equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let cl = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if o.len() != h.len() || h.len() != l.len() || l.len() != cl.len() { + return Err(PyValueError::new_err( + "open, high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(o.len()); + for i in 0..o.len() { + let candle = wc::Candle::new(o[i], h[i], l[i], cl[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn periods(&self) -> (usize, usize) { + self.inner.periods() + } + #[getter] + fn k(&self) -> f64 { + self.inner.k() + } + 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!("YangZhangVolatility(period={p}, trading_periods={t})") + } +} + +// ============================== Rogers-Satchell Volatility ============================== + +#[pyclass( + name = "RogersSatchellVolatility", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyRogersSatchellVolatility { + inner: wc::RogersSatchellVolatility, +} + +#[pymethods] +impl PyRogersSatchellVolatility { + #[new] + #[pyo3(signature = (period=20, trading_periods=252))] + fn new(period: usize, trading_periods: usize) -> PyResult { + Ok(Self { + inner: wc::RogersSatchellVolatility::new(period, trading_periods).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 open, high, low, close (all equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let cl = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if o.len() != h.len() || h.len() != l.len() || l.len() != cl.len() { + return Err(PyValueError::new_err( + "open, high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(o.len()); + for i in 0..o.len() { + let candle = wc::Candle::new(o[i], h[i], l[i], cl[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(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 (p, t) = self.inner.periods(); + format!("RogersSatchellVolatility(period={p}, trading_periods={t})") + } +} + +// ============================== Garman-Klass Volatility ============================== + +#[pyclass( + name = "GarmanKlassVolatility", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyGarmanKlassVolatility { + inner: wc::GarmanKlassVolatility, +} + +#[pymethods] +impl PyGarmanKlassVolatility { + #[new] + #[pyo3(signature = (period=20, trading_periods=252))] + fn new(period: usize, trading_periods: usize) -> PyResult { + Ok(Self { + inner: wc::GarmanKlassVolatility::new(period, trading_periods).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 open, high, low, close (all equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let cl = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if o.len() != h.len() || h.len() != l.len() || l.len() != cl.len() { + return Err(PyValueError::new_err( + "open, high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(o.len()); + for i in 0..o.len() { + let candle = wc::Candle::new(o[i], h[i], l[i], cl[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(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 (p, t) = self.inner.periods(); + format!("GarmanKlassVolatility(period={p}, trading_periods={t})") + } +} + +// ============================== Parkinson Volatility ============================== + +#[pyclass( + name = "ParkinsonVolatility", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyParkinsonVolatility { + inner: wc::ParkinsonVolatility, +} + +#[pymethods] +impl PyParkinsonVolatility { + #[new] + #[pyo3(signature = (period=20, trading_periods=252))] + fn new(period: usize, trading_periods: usize) -> PyResult { + Ok(Self { + inner: wc::ParkinsonVolatility::new(period, trading_periods).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(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 (p, t) = self.inner.periods(); + format!("ParkinsonVolatility(period={p}, trading_periods={t})") + } +} + +// ============================== RVI (Volatility) ============================== +// +// Named `RVIVolatility` rather than plain `RVI` to disambiguate from +// Relative Vigor Index (a separate momentum indicator that lives in +// Family 02 with the shorter `RVI` name). + +#[pyclass(name = "RVIVolatility", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRviVolatility { + inner: wc::RviVolatility, +} + +#[pymethods] +impl PyRviVolatility { + #[new] + #[pyo3(signature = (period=10))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::RviVolatility::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 s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(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!("RVIVolatility(period={})", self.inner.period()) + } +} + // ============================== Module ============================== #[pymodule] @@ -5690,5 +6046,10 @@ 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::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index 8cb47c86..17aa4a86 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -330,3 +330,60 @@ def test_obv_cumulative_known_sequence(): volume = np.array([100.0, 20.0, 30.0, 40.0, 10.0]) out = ta.OBV().batch(close, volume) np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0]) + + +def test_rvi_volatility_pure_uptrend_saturates_at_one_hundred(): + # Strictly rising closes -> every stddev sample classified as "up" -> + # RVIVolatility saturates at 100. Renamed from the original ta.RVI in + # PR 42 to disambiguate from Family 02's Relative Vigor Index, which + # now owns the short ta.RVI name (candle input). + out = ta.RVIVolatility(5).batch(np.arange(1.0, 41.0, dtype=np.float64)) + ready = out[~np.isnan(out)] + assert ready.size > 0 + np.testing.assert_allclose(ready[-10:], 100.0, atol=1e-9) + + +def test_parkinson_volatility_zero_range_yields_zero(): + # H == L every bar -> ln(H/L) = 0 -> Parkinson sigma is zero. + h = np.full(30, 10.0) + l = np.full(30, 10.0) + out = ta.ParkinsonVolatility(14, 252).batch(h, l) + ready = out[~np.isnan(out)] + assert ready.size > 0 + np.testing.assert_allclose(ready, 0.0, atol=1e-12) + + +def test_garman_klass_zero_movement_yields_zero(): + # O == H == L == C every bar -> both log terms are zero -> sigma is zero. + o = np.full(30, 10.0) + h = np.full(30, 10.0) + l = np.full(30, 10.0) + c = np.full(30, 10.0) + out = ta.GarmanKlassVolatility(14, 252).batch(o, h, l, c) + ready = out[~np.isnan(out)] + assert ready.size > 0 + np.testing.assert_allclose(ready, 0.0, atol=1e-12) + + +def test_rogers_satchell_zero_movement_yields_zero(): + o = np.full(30, 10.0) + h = np.full(30, 10.0) + l = np.full(30, 10.0) + c = np.full(30, 10.0) + out = ta.RogersSatchellVolatility(14, 252).batch(o, h, l, c) + ready = out[~np.isnan(out)] + assert ready.size > 0 + np.testing.assert_allclose(ready, 0.0, atol=1e-12) + + +def test_yang_zhang_zero_movement_yields_zero(): + # O == H == L == C and constant across bars -> every sub-component is + # zero -> Yang-Zhang sigma is zero. + o = np.full(30, 10.0) + h = np.full(30, 10.0) + l = np.full(30, 10.0) + c = np.full(30, 10.0) + out = ta.YangZhangVolatility(14, 252).batch(o, h, l, c) + ready = out[~np.isnan(out)] + assert ready.size > 0 + np.testing.assert_allclose(ready, 0.0, atol=1e-12) diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index aedcbd14..360cd154 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -74,6 +74,7 @@ SCALAR = [ (ta.LinRegAngle, (14,)), (ta.LaguerreRSI, (0.5,)), (ta.ConnorsRSI, (3, 2, 100)), + (ta.RVIVolatility, (10,)), ] @@ -185,6 +186,24 @@ CANDLE_SCALAR = { lambda: ta.ChaikinVolatility(10, 10), lambda ind, h, l, c, v: ind.batch(h, l), ), + "ParkinsonVolatility": ( + lambda: ta.ParkinsonVolatility(20, 252), + lambda ind, h, l, c, v: ind.batch(h, l), + ), + "GarmanKlassVolatility": ( + # The streaming 6-tuple feeds open == close, so batch matches with + # the close column standing in for open. + lambda: ta.GarmanKlassVolatility(20, 252), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "RogersSatchellVolatility": ( + lambda: ta.RogersSatchellVolatility(20, 252), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "YangZhangVolatility": ( + lambda: ta.YangZhangVolatility(20, 252), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), } diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 3c158a01..5fb61746 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -158,9 +158,227 @@ wasm_scalar_indicator!(WasmLinRegSlope, "LinRegSlope", wc::LinRegSlope, period: 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); +wasm_scalar_indicator!(WasmRviVolatility, "RVIVolatility", wc::RviVolatility, period: usize); wasm_scalar_indicator!(WasmLaguerreRsi, "LaguerreRSI", wc::LaguerreRsi, gamma: f64); wasm_scalar_indicator!(WasmConnorsRsi, "ConnorsRSI", wc::ConnorsRsi, period_rsi: usize, period_streak: usize, period_rank: usize); +// ---------- Yang-Zhang Volatility (OHLC candle, 2 params) ---------- + +#[wasm_bindgen(js_name = YangZhangVolatility)] +pub struct WasmYangZhangVolatility { + inner: wc::YangZhangVolatility, +} + +#[wasm_bindgen(js_class = YangZhangVolatility)] +impl WasmYangZhangVolatility { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, trading_periods: usize) -> Result { + Ok(Self { + inner: wc::YangZhangVolatility::new(period, trading_periods).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> Result, JsError> { + let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = open.len(); + if high.len() != n || low.len() != n || close.len() != n { + return Err(JsError::new("open, high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ---------- Rogers-Satchell Volatility (OHLC candle, 2 params) ---------- + +#[wasm_bindgen(js_name = RogersSatchellVolatility)] +pub struct WasmRogersSatchellVolatility { + inner: wc::RogersSatchellVolatility, +} + +#[wasm_bindgen(js_class = RogersSatchellVolatility)] +impl WasmRogersSatchellVolatility { + #[wasm_bindgen(constructor)] + pub fn new( + period: usize, + trading_periods: usize, + ) -> Result { + Ok(Self { + inner: wc::RogersSatchellVolatility::new(period, trading_periods).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> Result, JsError> { + let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = open.len(); + if high.len() != n || low.len() != n || close.len() != n { + return Err(JsError::new("open, high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ---------- Garman-Klass Volatility (OHLC candle, 2 params) ---------- + +#[wasm_bindgen(js_name = GarmanKlassVolatility)] +pub struct WasmGarmanKlassVolatility { + inner: wc::GarmanKlassVolatility, +} + +#[wasm_bindgen(js_class = GarmanKlassVolatility)] +impl WasmGarmanKlassVolatility { + #[wasm_bindgen(constructor)] + pub fn new( + period: usize, + trading_periods: usize, + ) -> Result { + Ok(Self { + inner: wc::GarmanKlassVolatility::new(period, trading_periods).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> Result, JsError> { + let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + let n = open.len(); + if high.len() != n || low.len() != n || close.len() != n { + return Err(JsError::new("open, high, low, close must be equal length")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ---------- Parkinson Volatility (high-low candle, 2 params) ---------- + +#[wasm_bindgen(js_name = ParkinsonVolatility)] +pub struct WasmParkinsonVolatility { + inner: wc::ParkinsonVolatility, +} + +#[wasm_bindgen(js_class = ParkinsonVolatility)] +impl WasmParkinsonVolatility { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, trading_periods: usize) -> Result { + Ok(Self { + inner: wc::ParkinsonVolatility::new(period, trading_periods).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 = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + // ---------- KAMA (three params) ---------- #[wasm_bindgen(js_name = KAMA)] diff --git a/crates/wickra-core/src/indicators/garman_klass.rs b/crates/wickra-core/src/indicators/garman_klass.rs new file mode 100644 index 00000000..9f6d1a03 --- /dev/null +++ b/crates/wickra-core/src/indicators/garman_klass.rs @@ -0,0 +1,286 @@ +//! Garman-Klass Volatility (OHLC estimator). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Garman-Klass Volatility — an OHLC realised-volatility estimator. +/// +/// Garman & Klass (1980) extended Parkinson's high-low estimator by adding +/// an open-to-close term, removing some of the bias introduced when the +/// closing price drifts within the bar. The per-bar sample is +/// +/// ```text +/// s_t = 0.5 · (ln(H_t / L_t))² − (2·ln 2 − 1) · (ln(C_t / O_t))² +/// ``` +/// +/// and the indicator returns the annualised square root of the rolling +/// mean of `s_t`: +/// +/// ```text +/// out = sqrt(max(mean(s_t over `period`), 0)) · sqrt(trading_periods) · 100 +/// ``` +/// +/// Garman & Klass showed the estimator is ~7.4× more statistically efficient +/// than the close-to-close estimator under driftless Geometric Brownian +/// Motion (Parkinson sits at ~5.0×). It is still biased when there is +/// significant overnight drift between bars — use the Yang-Zhang estimator +/// when the dataset has meaningful close-to-open gaps. +/// +/// The per-bar sample `s_t` can be slightly negative when the bar's range +/// is small relative to its open-to-close move; this matches the original +/// paper's algebra and is handled by clamping the rolling mean to zero +/// before taking the square root. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, GarmanKlassVolatility, Indicator}; +/// +/// let mut indicator = GarmanKlassVolatility::new(20, 252).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)) +/// .unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct GarmanKlassVolatility { + period: usize, + trading_periods: usize, + window: VecDeque, + sum: f64, + last: Option, +} + +/// `2 · ln 2 − 1` — the Garman-Klass open-to-close weight. +const GK_OC_COEFF: f64 = 0.386_294_361_119_890_6; + +impl GarmanKlassVolatility { + /// Construct a Garman-Klass Volatility estimator. + /// + /// `period` is the rolling window of bars; `trading_periods` is the + /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or + /// `1` for raw per-bar volatility). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either parameter is `0`. + pub fn new(period: usize, trading_periods: usize) -> Result { + if period == 0 || trading_periods == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + trading_periods, + window: VecDeque::with_capacity(period), + sum: 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 GarmanKlassVolatility { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // `Candle::new` enforces finite, positive OHLC with `high >= max(open, + // low, close)` and `low <= min(open, high, close)`, so every log + // ratio below is well-defined and `ln(H/L) >= 0`. + let log_hl = (candle.high / candle.low).ln(); + let log_co = (candle.close / candle.open).ln(); + let sample = 0.5 * log_hl * log_hl - GK_OC_COEFF * log_co * log_co; + + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + } + self.window.push_back(sample); + self.sum += sample; + + if self.window.len() < self.period { + return None; + } + + let n = self.period as f64; + // Rolling mean. Garman-Klass samples can be marginally negative on + // narrow-range bars with large O-to-C moves; the rolling mean is + // theoretically `>= 0` but a clamp absorbs FP cancellation and the + // pathological all-negative case. + let variance = (self.sum / n).max(0.0); + let sigma = variance.sqrt(); + let out = sigma * (self.trading_periods as f64).sqrt() * 100.0; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 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 { + "GarmanKlassVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle { + Candle::new(o, h, l, c, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + GarmanKlassVolatility::new(0, 252), + Err(Error::PeriodZero) + )); + assert!(matches!( + GarmanKlassVolatility::new(20, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let gk = GarmanKlassVolatility::new(20, 252).unwrap(); + assert_eq!(gk.periods(), (20, 252)); + assert_eq!(gk.value(), None); + assert_eq!(gk.warmup_period(), 20); + assert_eq!(gk.name(), "GarmanKlassVolatility"); + assert!(!gk.is_ready()); + } + + #[test] + fn zero_movement_yields_zero() { + // O == H == L == C -> both log terms are zero -> sigma is zero. + let candles: Vec = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect(); + let mut gk = GarmanKlassVolatility::new(14, 1).unwrap(); + for v in gk.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn constant_bar_shape_yields_constant_sigma() { + // Every bar has identical O/H/L/C ratios -> per-bar sample is a + // constant `k`, so the rolling mean is `k` and the output is + // `sqrt(k) * 100` (trading_periods = 1). + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect(); + let log_hl = (11.0_f64 / 9.0_f64).ln(); + let log_co = (10.2_f64 / 10.0_f64).ln(); + let k = 0.5 * log_hl * log_hl - GK_OC_COEFF * log_co * log_co; + let expected = k.max(0.0).sqrt() * 100.0; + + let mut gk = GarmanKlassVolatility::new(10, 1).unwrap(); + let out = gk.batch(&candles); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, expected, epsilon = 1e-9); + } + } + + #[test] + fn output_is_non_negative() { + let mut gk = GarmanKlassVolatility::new(14, 252).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0; + let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5; + let open = base - 0.1; + let close = base + 0.2; + candle(open, base + half, base - half, close, i64::from(i)) + }) + .collect(); + for v in gk.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "Garman-Klass must be non-negative: {v}"); + } + } + + #[test] + fn annualisation_scales_by_sqrt_trading_periods() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + let half = 1.0 + (f64::from(i) * 0.2).cos().abs(); + candle(base, base + half, base - half, base + 0.3, i64::from(i)) + }) + .collect(); + let raw = GarmanKlassVolatility::new(10, 1).unwrap().batch(&candles); + let annual = GarmanKlassVolatility::new(10, 252).unwrap().batch(&candles); + let scale = (252.0_f64).sqrt(); + for (r, a) in raw.iter().zip(annual.iter()) { + assert_eq!(r.is_some(), a.is_some(), "warmup mismatch"); + if let (Some(r), Some(a)) = (r, a) { + assert_relative_eq!(*a, r * scale, epsilon = 1e-9); + } + } + } + + #[test] + fn first_emission_at_warmup_period() { + let candles: Vec = (0..20).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect(); + let mut gk = GarmanKlassVolatility::new(5, 1).unwrap(); + let out = gk.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0; + let half = 1.0 + (f64::from(i) * 0.15).cos().abs(); + candle(base, base + half, base - half, base + 0.5, i64::from(i)) + }) + .collect(); + let batch = GarmanKlassVolatility::new(14, 252).unwrap().batch(&candles); + let mut streamer = GarmanKlassVolatility::new(14, 252).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect(); + let mut gk = GarmanKlassVolatility::new(14, 252).unwrap(); + gk.batch(&candles); + assert!(gk.is_ready()); + gk.reset(); + assert!(!gk.is_ready()); + assert_eq!(gk.value(), None); + assert_eq!(gk.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 10e8d486..857b37bd 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -39,6 +39,7 @@ mod ema; mod evwma; mod force_index; mod frama; +mod garman_klass; mod historical_volatility; mod hma; mod inertia; @@ -58,14 +59,17 @@ mod mfi; mod mom; mod natr; mod obv; +mod parkinson; mod percent_b; mod pgo; mod pmo; mod ppo; mod psar; mod roc; +mod rogers_satchell; mod rsi; mod rvi; +mod rvi_volatility; mod sma; mod smi; mod smma; @@ -92,6 +96,7 @@ mod vwma; mod weighted_close; mod williams_r; mod wma; +mod yang_zhang; mod z_score; mod zero_lag_macd; mod zlema; @@ -131,6 +136,7 @@ pub use ema::Ema; pub use evwma::Evwma; pub use force_index::ForceIndex; pub use frama::Frama; +pub use garman_klass::GarmanKlassVolatility; pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; pub use inertia::Inertia; @@ -150,14 +156,17 @@ pub use mfi::Mfi; pub use mom::Mom; pub use natr::Natr; pub use obv::Obv; +pub use parkinson::ParkinsonVolatility; pub use percent_b::PercentB; pub use pgo::Pgo; pub use pmo::Pmo; pub use ppo::Ppo; pub use psar::Psar; pub use roc::Roc; +pub use rogers_satchell::RogersSatchellVolatility; pub use rsi::Rsi; pub use rvi::Rvi; +pub use rvi_volatility::RviVolatility; pub use sma::Sma; pub use smi::Smi; pub use smma::Smma; @@ -184,6 +193,7 @@ pub use vwma::Vwma; pub use weighted_close::WeightedClose; pub use williams_r::WilliamsR; pub use wma::Wma; +pub use yang_zhang::YangZhangVolatility; pub use z_score::ZScore; pub use zero_lag_macd::{ZeroLagMacd, ZeroLagMacdOutput}; pub use zlema::Zlema; diff --git a/crates/wickra-core/src/indicators/parkinson.rs b/crates/wickra-core/src/indicators/parkinson.rs new file mode 100644 index 00000000..b81ee079 --- /dev/null +++ b/crates/wickra-core/src/indicators/parkinson.rs @@ -0,0 +1,276 @@ +//! Parkinson Volatility (high-low estimator). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Parkinson Volatility — a high-low realised-volatility estimator. +/// +/// Michael Parkinson (1980) noted that the extreme range of a bar carries +/// more variance information than the closing price alone: a wide bar that +/// closes near its open is far more "volatile" than a narrow bar that +/// happens to close at the same level. The estimator is +/// +/// ```text +/// sigma² = (1 / (4n · ln 2)) · Σ_{i=1..n} (ln(H_i / L_i))² +/// sigma = √sigma² +/// out = sigma · √trading_periods · 100 +/// ``` +/// +/// The output is annualised to a percent in the same style as +/// [`HistoricalVolatility`](crate::HistoricalVolatility) — `trading_periods` +/// of `252` for daily bars, `52` for weekly, `12` for monthly. Pass +/// `trading_periods = 1` for the raw per-bar `sigma · 100` figure. +/// +/// Under a driftless Geometric-Brownian-Motion assumption, Parkinson's +/// estimator has roughly `1/5` the variance of the close-to-close +/// estimator — i.e. five close-to-close samples give the same statistical +/// efficiency as one Parkinson sample. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, ParkinsonVolatility}; +/// +/// let mut indicator = ParkinsonVolatility::new(20, 252).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1.0, i64::from(i)) +/// .unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct ParkinsonVolatility { + period: usize, + trading_periods: usize, + window: VecDeque, + sum_sq: f64, + last: Option, +} + +/// `1 / (4 · ln 2)` — the Parkinson normalisation constant, evaluated once at +/// `const` to keep the per-update path branch-free. +const PARKINSON_FACTOR: f64 = 0.360_673_760_222_241_2; + +impl ParkinsonVolatility { + /// Construct a Parkinson Volatility estimator. + /// + /// `period` is the rolling window of bars; `trading_periods` is the + /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or + /// `1` for raw per-bar volatility). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either parameter is `0`. + pub fn new(period: usize, trading_periods: usize) -> Result { + if period == 0 || trading_periods == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + trading_periods, + window: VecDeque::with_capacity(period), + 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 ParkinsonVolatility { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // `Candle::new` already guarantees finite, positive `high` and `low` + // with `high >= low`, so the log ratio is always well-defined and + // non-negative. + let log_hl = (candle.high / candle.low).ln(); + let sample = log_hl * log_hl; + + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum_sq -= old; + } + self.window.push_back(sample); + self.sum_sq += sample; + + if self.window.len() < self.period { + return None; + } + + let n = self.period as f64; + let variance = (PARKINSON_FACTOR * self.sum_sq / n).max(0.0); + let sigma = variance.sqrt(); + let out = sigma * (self.trading_periods as f64).sqrt() * 100.0; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + 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 { + "ParkinsonVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle { + Candle::new(f64::midpoint(h, l), h, l, c, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + ParkinsonVolatility::new(0, 252), + Err(Error::PeriodZero) + )); + assert!(matches!( + ParkinsonVolatility::new(20, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let pv = ParkinsonVolatility::new(20, 252).unwrap(); + assert_eq!(pv.periods(), (20, 252)); + assert_eq!(pv.value(), None); + assert_eq!(pv.warmup_period(), 20); + assert_eq!(pv.name(), "ParkinsonVolatility"); + assert!(!pv.is_ready()); + } + + #[test] + fn zero_range_yields_zero() { + // H == L every bar -> ln(H/L) = 0 -> sigma = 0. + let candles: Vec = (0..30).map(|i| candle(10.0, 10.0, 10.0, i)).collect(); + let mut pv = ParkinsonVolatility::new(14, 1).unwrap(); + for v in pv.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn constant_range_yields_constant_sigma() { + // Every bar has the same H/L ratio -> every (ln H/L)² is the same + // constant -> the rolling sum is `n * k` and the variance simplifies + // to `factor * k`. The output is `sqrt(factor * k) * 100` (with + // trading_periods = 1). + let candles: Vec = (0..30).map(|i| candle(11.0, 9.0, 10.0, i)).collect(); + let mut pv = ParkinsonVolatility::new(10, 1).unwrap(); + let out = pv.batch(&candles); + + let k = (11.0_f64 / 9.0_f64).ln().powi(2); + let expected = (PARKINSON_FACTOR * k).sqrt() * 100.0; + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, expected, epsilon = 1e-9); + } + } + + #[test] + fn output_is_non_negative() { + let mut pv = ParkinsonVolatility::new(14, 252).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0; + let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5; + candle(base + half, base - half, base, i64::from(i)) + }) + .collect(); + for v in pv.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "Parkinson volatility must be non-negative: {v}"); + } + } + + #[test] + fn annualisation_scales_by_sqrt_trading_periods() { + // Same candles run through (period, 1) and (period, 252) -> the + // 252-version is `sqrt(252)` times the raw version, bar-for-bar. + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + let half = 1.0 + (f64::from(i) * 0.2).cos().abs(); + candle(base + half, base - half, base, i64::from(i)) + }) + .collect(); + let raw = ParkinsonVolatility::new(10, 1).unwrap().batch(&candles); + let annual = ParkinsonVolatility::new(10, 252).unwrap().batch(&candles); + let scale = (252.0_f64).sqrt(); + for (r, a) in raw.iter().zip(annual.iter()) { + assert_eq!(r.is_some(), a.is_some(), "warmup mismatch"); + if let (Some(r), Some(a)) = (r, a) { + assert_relative_eq!(*a, r * scale, epsilon = 1e-9); + } + } + } + + #[test] + fn first_emission_at_warmup_period() { + let candles: Vec = (0..20).map(|i| candle(11.0, 9.0, 10.0, i)).collect(); + let mut pv = ParkinsonVolatility::new(5, 1).unwrap(); + let out = pv.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0; + let half = 1.0 + (f64::from(i) * 0.15).cos().abs(); + candle(base + half, base - half, base, i64::from(i)) + }) + .collect(); + let batch = ParkinsonVolatility::new(14, 252).unwrap().batch(&candles); + let mut streamer = ParkinsonVolatility::new(14, 252).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30).map(|i| candle(11.0, 9.0, 10.0, i)).collect(); + let mut pv = ParkinsonVolatility::new(14, 252).unwrap(); + pv.batch(&candles); + assert!(pv.is_ready()); + pv.reset(); + assert!(!pv.is_ready()); + assert_eq!(pv.value(), None); + assert_eq!(pv.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/rogers_satchell.rs b/crates/wickra-core/src/indicators/rogers_satchell.rs new file mode 100644 index 00000000..0ea5e964 --- /dev/null +++ b/crates/wickra-core/src/indicators/rogers_satchell.rs @@ -0,0 +1,288 @@ +//! Rogers-Satchell Volatility (drift-free OHLC estimator). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Rogers-Satchell Volatility — a drift-free OHLC realised-volatility +/// estimator. +/// +/// Rogers, Satchell & Yoon (1994) extended the Garman-Klass framework to +/// handle non-zero drift between bars without introducing the bias the +/// Garman-Klass estimator picks up in trending markets. The per-bar sample +/// is +/// +/// ```text +/// s_t = ln(H_t / C_t) · ln(H_t / O_t) + ln(L_t / C_t) · ln(L_t / O_t) +/// ``` +/// +/// and the indicator returns the annualised square root of the rolling +/// mean of `s_t`: +/// +/// ```text +/// out = sqrt(max(mean(s_t over `period`), 0)) · sqrt(trading_periods) · 100 +/// ``` +/// +/// The estimator is exact under a Brownian Motion with arbitrary drift — +/// the drift component cancels out algebraically. Each per-bar sample is +/// also guaranteed non-negative (both products contribute non-negative +/// terms by construction: `H >= O,C` and `L <= O,C`), so the rolling mean +/// cannot drift below zero except through FP cancellation. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, RogersSatchellVolatility}; +/// +/// let mut indicator = RogersSatchellVolatility::new(20, 252).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)) +/// .unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct RogersSatchellVolatility { + period: usize, + trading_periods: usize, + window: VecDeque, + sum: f64, + last: Option, +} + +impl RogersSatchellVolatility { + /// Construct a Rogers-Satchell Volatility estimator. + /// + /// `period` is the rolling window of bars; `trading_periods` is the + /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or + /// `1` for raw per-bar volatility). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either parameter is `0`. + pub fn new(period: usize, trading_periods: usize) -> Result { + if period == 0 || trading_periods == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + trading_periods, + window: VecDeque::with_capacity(period), + sum: 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 RogersSatchellVolatility { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // `Candle::new` guarantees finite, positive OHLC with `high >= + // max(open, low, close)` and `low <= min(open, high, close)`. The + // factors below thus have predictable signs: + // ln(H/C) >= 0, ln(H/O) >= 0, ln(L/C) <= 0, ln(L/O) <= 0 + // so both products are non-negative and the per-bar sample is + // guaranteed `>= 0` by construction. + let log_hc = (candle.high / candle.close).ln(); + let log_ho = (candle.high / candle.open).ln(); + let log_lc = (candle.low / candle.close).ln(); + let log_lo = (candle.low / candle.open).ln(); + let sample = log_hc.mul_add(log_ho, log_lc * log_lo); + + if self.window.len() == self.period { + let old = self.window.pop_front().expect("window is non-empty"); + self.sum -= old; + } + self.window.push_back(sample); + self.sum += sample; + + if self.window.len() < self.period { + return None; + } + + let n = self.period as f64; + // The clamp absorbs FP cancellation; the mathematical value is + // already `>= 0` by the sign argument above. + let variance = (self.sum / n).max(0.0); + let sigma = variance.sqrt(); + let out = sigma * (self.trading_periods as f64).sqrt() * 100.0; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 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 { + "RogersSatchellVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle { + Candle::new(o, h, l, c, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + RogersSatchellVolatility::new(0, 252), + Err(Error::PeriodZero) + )); + assert!(matches!( + RogersSatchellVolatility::new(20, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn accessors_and_metadata() { + let rs = RogersSatchellVolatility::new(20, 252).unwrap(); + assert_eq!(rs.periods(), (20, 252)); + assert_eq!(rs.value(), None); + assert_eq!(rs.warmup_period(), 20); + assert_eq!(rs.name(), "RogersSatchellVolatility"); + assert!(!rs.is_ready()); + } + + #[test] + fn zero_movement_yields_zero() { + let candles: Vec = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect(); + let mut rs = RogersSatchellVolatility::new(14, 1).unwrap(); + for v in rs.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn constant_bar_shape_yields_constant_sigma() { + // Each bar has identical OHLC -> per-bar sample is a constant `k`. + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect(); + let log_hc = (11.0_f64 / 10.5_f64).ln(); + let log_ho = (11.0_f64 / 10.0_f64).ln(); + let log_lc = (9.0_f64 / 10.5_f64).ln(); + let log_lo = (9.0_f64 / 10.0_f64).ln(); + let k = log_hc * log_ho + log_lc * log_lo; + let expected = k.max(0.0).sqrt() * 100.0; + + let mut rs = RogersSatchellVolatility::new(10, 1).unwrap(); + let out = rs.batch(&candles); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, expected, epsilon = 1e-9); + } + } + + #[test] + fn output_is_non_negative() { + let mut rs = RogersSatchellVolatility::new(14, 252).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0; + let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5; + let open = base - 0.1; + let close = base + 0.2; + candle(open, base + half, base - half, close, i64::from(i)) + }) + .collect(); + for v in rs.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "Rogers-Satchell must be non-negative: {v}"); + } + } + + #[test] + fn annualisation_scales_by_sqrt_trading_periods() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + let half = 1.0 + (f64::from(i) * 0.2).cos().abs(); + candle(base, base + half, base - half, base + 0.3, i64::from(i)) + }) + .collect(); + let raw = RogersSatchellVolatility::new(10, 1) + .unwrap() + .batch(&candles); + let annual = RogersSatchellVolatility::new(10, 252) + .unwrap() + .batch(&candles); + let scale = (252.0_f64).sqrt(); + for (r, a) in raw.iter().zip(annual.iter()) { + assert_eq!(r.is_some(), a.is_some(), "warmup mismatch"); + if let (Some(r), Some(a)) = (r, a) { + assert_relative_eq!(*a, r * scale, epsilon = 1e-9); + } + } + } + + #[test] + fn first_emission_at_warmup_period() { + let candles: Vec = (0..20).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect(); + let mut rs = RogersSatchellVolatility::new(5, 1).unwrap(); + let out = rs.batch(&candles); + for v in out.iter().take(4) { + assert!(v.is_none()); + } + assert!(out[4].is_some()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0; + let half = 1.0 + (f64::from(i) * 0.15).cos().abs(); + candle(base, base + half, base - half, base + 0.5, i64::from(i)) + }) + .collect(); + let batch = RogersSatchellVolatility::new(14, 252) + .unwrap() + .batch(&candles); + let mut streamer = RogersSatchellVolatility::new(14, 252).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect(); + let mut rs = RogersSatchellVolatility::new(14, 252).unwrap(); + rs.batch(&candles); + assert!(rs.is_ready()); + rs.reset(); + assert!(!rs.is_ready()); + assert_eq!(rs.value(), None); + assert_eq!(rs.update(candles[0]), None); + } +} diff --git a/crates/wickra-core/src/indicators/rvi_volatility.rs b/crates/wickra-core/src/indicators/rvi_volatility.rs new file mode 100644 index 00000000..0a471df9 --- /dev/null +++ b/crates/wickra-core/src/indicators/rvi_volatility.rs @@ -0,0 +1,338 @@ +//! Relative Volatility Index (Donald Dorsey). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Relative Volatility Index — Donald Dorsey's RSI-shaped volatility gauge. +/// +/// Where RSI partitions price changes into gains and losses and Wilder-smooths +/// each side, RVI partitions the rolling standard deviation of price into "up +/// volatility" (when price rose since the previous bar) and "down volatility" +/// (when price fell), then applies the same Wilder smoothing and ratio: +/// +/// ```text +/// sd_t = stddev_pop(close over `period`) // single scalar each bar +/// up_t = sd_t if close_t > close_{t-1}, else 0 +/// down_t = sd_t if close_t < close_{t-1}, else 0 +/// AvgUp_t = Wilder(up, period) +/// AvgDown_t = Wilder(down, period) +/// RVI_t = 100 · AvgUp_t / (AvgUp_t + AvgDown_t) +/// ``` +/// +/// The output is bounded on `[0, 100]`. A series with no down-bars saturates +/// at `100`; a series with no up-bars saturates at `0`. A completely flat +/// series (no movement, both averages zero) returns `50` by the same +/// undefined-RS convention as `RSI` (`crates/wickra-core/src/indicators/rsi.rs`). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, RviVolatility}; +/// +/// let mut indicator = RviVolatility::new(10).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 RviVolatility { + period: usize, + // Rolling-stddev state. + window: VecDeque, + sum: f64, + sum_sq: f64, + // Direction tracking. + prev_close: Option, + // Wilder-smoothed up/down volatility. + seed_up: Vec, + seed_down: Vec, + avg_up: Option, + avg_down: Option, + last_value: Option, +} + +impl RviVolatility { + /// Construct an RVI with the given period. + /// + /// `period` is used both as the standard-deviation window length and as + /// the Wilder smoothing constant for the up/down averages. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`, or + /// [`Error::InvalidPeriod`] if `period == 1` (a 1-bar rolling standard + /// deviation is always zero and the indicator would never produce a + /// meaningful reading). + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + if period < 2 { + return Err(Error::InvalidPeriod { + message: "RVI period must be >= 2", + }); + } + Ok(Self { + period, + window: VecDeque::with_capacity(period), + sum: 0.0, + sum_sq: 0.0, + prev_close: None, + seed_up: Vec::with_capacity(period), + seed_down: Vec::with_capacity(period), + avg_up: None, + avg_down: None, + last_value: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last_value + } + + fn ratio(avg_up: f64, avg_down: f64) -> f64 { + let denom = avg_up + avg_down; + if denom == 0.0 { + // No volatility on either side. Match RSI's undefined-RS convention. + 50.0 + } else { + 100.0 * avg_up / denom + } + } +} + +impl Indicator for RviVolatility { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input leaves state untouched, mirrors `StdDev` / `Rsi`. + return self.last_value; + } + + // 1. Roll the standard-deviation window. + 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 { + // Track previous close from the very first input so that the first + // ready stddev sample is paired with a valid direction. + self.prev_close = Some(input); + return None; + } + + let n = self.period as f64; + let mean = self.sum / n; + // Population variance with a non-negativity clamp for FP cancellation. + let variance = (self.sum_sq / n - mean * mean).max(0.0); + let sd = variance.sqrt(); + + // 2. Classify the stddev sample as up- or down-volatility. + let prev = self + .prev_close + .expect("prev_close is set on every input before this point"); + let (up, down) = if input > prev { + (sd, 0.0) + } else if input < prev { + (0.0, sd) + } else { + (0.0, 0.0) + }; + self.prev_close = Some(input); + + // 3. Wilder-smooth the up/down series. + if let (Some(au), Some(ad)) = (self.avg_up, self.avg_down) { + let new_au = au.mul_add(n - 1.0, up) / n; + let new_ad = ad.mul_add(n - 1.0, down) / n; + self.avg_up = Some(new_au); + self.avg_down = Some(new_ad); + let v = Self::ratio(new_au, new_ad); + self.last_value = Some(v); + return Some(v); + } + + self.seed_up.push(up); + self.seed_down.push(down); + if self.seed_up.len() == self.period { + let au = self.seed_up.iter().sum::() / n; + let ad = self.seed_down.iter().sum::() / n; + self.avg_up = Some(au); + self.avg_down = Some(ad); + let v = Self::ratio(au, ad); + self.last_value = Some(v); + return Some(v); + } + None + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.sum_sq = 0.0; + self.prev_close = None; + self.seed_up.clear(); + self.seed_down.clear(); + self.avg_up = None; + self.avg_down = None; + self.last_value = None; + } + + fn warmup_period(&self) -> usize { + // `period` bars to fill the stddev window plus another `period − 1` + // bars to seed the Wilder averages with up/down samples. The two + // phases overlap by one bar (the `period`-th input produces both the + // first stddev sample and the first up/down classification), so the + // first ready RVI lands at index `2 · period − 2`, i.e. the + // `(2·period − 1)`-th input. + 2 * self.period - 1 + } + + fn is_ready(&self) -> bool { + self.last_value.is_some() + } + + fn name(&self) -> &'static str { + "RVIVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(RviVolatility::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_period_one() { + assert!(matches!( + RviVolatility::new(1), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let rvi = RviVolatility::new(14).unwrap(); + assert_eq!(rvi.period(), 14); + assert_eq!(rvi.name(), "RVIVolatility"); + assert_eq!(rvi.value(), None); + assert_eq!(rvi.warmup_period(), 27); + assert!(!rvi.is_ready()); + } + + #[test] + fn constant_series_yields_fifty() { + // Flat input -> stddev is zero every bar and direction is "unchanged", + // so both avg_up and avg_down stay at zero -> the undefined-RS + // convention returns 50. + let mut rvi = RviVolatility::new(5).unwrap(); + let out = rvi.batch(&[42.0; 40]); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, 50.0, epsilon = 1e-12); + } + } + + #[test] + fn pure_uptrend_saturates_to_one_hundred() { + // Every bar's close is above the previous -> every stddev sample is + // classified as up, every down sample is zero -> RVI = 100. + let mut rvi = RviVolatility::new(5).unwrap(); + let prices: Vec = (1..=40).map(f64::from).collect(); + let out = rvi.batch(&prices); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, 100.0, epsilon = 1e-9); + } + } + + #[test] + fn pure_downtrend_saturates_to_zero() { + let mut rvi = RviVolatility::new(5).unwrap(); + let prices: Vec = (1..=40).rev().map(f64::from).collect(); + let out = rvi.batch(&prices); + for v in out.iter().skip(9).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn output_is_bounded() { + let mut rvi = RviVolatility::new(10).unwrap(); + let prices: Vec = (0..200) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0) + .collect(); + for v in rvi.batch(&prices).into_iter().flatten() { + assert!((0.0..=100.0).contains(&v), "RVI out of range: {v}"); + } + } + + #[test] + fn first_emission_at_warmup_period() { + let mut rvi = RviVolatility::new(5).unwrap(); + assert_eq!(rvi.warmup_period(), 9); + let prices: Vec = (0..30) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 3.0) + .collect(); + let out = rvi.batch(&prices); + for v in out.iter().take(8) { + assert!(v.is_none(), "indicator must still be warming up"); + } + assert!( + out[8].is_some(), + "first value lands at warmup_period - 1 = 8" + ); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (0..120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = RviVolatility::new(10).unwrap().batch(&prices); + let mut streamer = RviVolatility::new(10).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| streamer.update(*p)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let mut rvi = RviVolatility::new(5).unwrap(); + rvi.batch(&(1..=40).map(f64::from).collect::>()); + assert!(rvi.is_ready()); + rvi.reset(); + assert!(!rvi.is_ready()); + assert_eq!(rvi.value(), None); + assert_eq!(rvi.update(1.0), None); + } + + #[test] + fn ignores_non_finite_input() { + let mut rvi = RviVolatility::new(5).unwrap(); + let out = rvi.batch(&(1..=40).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(rvi.update(f64::NAN), last); + assert_eq!(rvi.update(f64::INFINITY), last); + } +} diff --git a/crates/wickra-core/src/indicators/yang_zhang.rs b/crates/wickra-core/src/indicators/yang_zhang.rs new file mode 100644 index 00000000..20abcd68 --- /dev/null +++ b/crates/wickra-core/src/indicators/yang_zhang.rs @@ -0,0 +1,406 @@ +//! Yang-Zhang Volatility (drift- and gap-robust OHLC estimator). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Yang-Zhang Volatility — combines overnight, open-to-close and +/// Rogers-Satchell volatilities into a single drift- and gap-robust +/// estimator. +/// +/// Yang & Zhang (2000) showed that the three estimators below are +/// independent under a driftless GBM with overnight gaps, so a convex +/// combination of their (sample) variances has minimum estimation variance +/// at a specific blending factor `k`: +/// +/// ```text +/// k = 0.34 / (1.34 + (n + 1) / (n − 1)) +/// σ²_on = sample_var(ln(O_t / C_{t-1}) over n bars) // overnight +/// σ²_oc = sample_var(ln(C_t / O_t) over n bars) // open-to-close +/// σ²_rs = mean(ln(H/C)·ln(H/O) + ln(L/C)·ln(L/O) over n bars) // Rogers-Satchell +/// σ²_YZ = σ²_on + k · σ²_oc + (1 − k) · σ²_rs +/// out = √max(σ²_YZ, 0) · √trading_periods · 100 +/// ``` +/// +/// The "sample" variance uses Bessel's correction (divisor `n − 1`), the +/// same convention as [`HistoricalVolatility`](crate::HistoricalVolatility). +/// +/// This is the gold-standard OHLC estimator for assets with both +/// overnight gaps and intraday drift — equities, futures, and any +/// market that doesn't trade continuously. For pure intraday data +/// (where `C_{t-1} == O_t`), the overnight term vanishes and +/// Rogers-Satchell alone is sufficient. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, YangZhangVolatility}; +/// +/// let mut indicator = YangZhangVolatility::new(20, 252).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let base = 100.0 + f64::from(i); +/// let candle = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)) +/// .unwrap(); +/// last = indicator.update(candle); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct YangZhangVolatility { + period: usize, + trading_periods: usize, + k: f64, + prev_close: Option, + // Each window stores one f64 per bar in the rolling window. + overnight: VecDeque, + open_close: VecDeque, + rs_samples: VecDeque, + sum_on: f64, + sum_sq_on: f64, + sum_oc: f64, + sum_sq_oc: f64, + sum_rs: f64, + last: Option, +} + +impl YangZhangVolatility { + /// Construct a Yang-Zhang Volatility estimator. + /// + /// `period` is the rolling window of bars; `trading_periods` is the + /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or + /// `1` for raw per-bar volatility). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either parameter is `0`, or + /// [`Error::InvalidPeriod`] if `period < 2` (the sample variances + /// inside Yang-Zhang need at least two samples). + 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: "Yang-Zhang period must be >= 2", + }); + } + let n = period as f64; + let k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0)); + Ok(Self { + period, + trading_periods, + k, + prev_close: None, + overnight: VecDeque::with_capacity(period), + open_close: VecDeque::with_capacity(period), + rs_samples: VecDeque::with_capacity(period), + sum_on: 0.0, + sum_sq_on: 0.0, + sum_oc: 0.0, + sum_sq_oc: 0.0, + sum_rs: 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 + } + + /// The Yang-Zhang blending factor `k` for this configuration. + pub const fn k(&self) -> f64 { + self.k + } +} + +impl Indicator for YangZhangVolatility { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + // The overnight log-return needs the previous bar's close. On the + // first candle there is no previous close, so we only seed + // `prev_close` and return None without touching any window. + let Some(prev_c) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + self.prev_close = Some(candle.close); + + // Per-bar samples. `Candle::new` guarantees finite, positive OHLC + // and the ordering invariants, so every ratio is well-defined. + let on_sample = (candle.open / prev_c).ln(); + let oc_sample = (candle.close / candle.open).ln(); + let log_hc = (candle.high / candle.close).ln(); + let log_ho = (candle.high / candle.open).ln(); + let log_lc = (candle.low / candle.close).ln(); + let log_lo = (candle.low / candle.open).ln(); + let rs_sample = log_hc.mul_add(log_ho, log_lc * log_lo); + + // Roll the three windows. + if self.overnight.len() == self.period { + let old_on = self.overnight.pop_front().expect("window non-empty"); + self.sum_on -= old_on; + self.sum_sq_on -= old_on * old_on; + let old_oc = self.open_close.pop_front().expect("window non-empty"); + self.sum_oc -= old_oc; + self.sum_sq_oc -= old_oc * old_oc; + let old_rs = self.rs_samples.pop_front().expect("window non-empty"); + self.sum_rs -= old_rs; + } + self.overnight.push_back(on_sample); + self.sum_on += on_sample; + self.sum_sq_on += on_sample * on_sample; + self.open_close.push_back(oc_sample); + self.sum_oc += oc_sample; + self.sum_sq_oc += oc_sample * oc_sample; + self.rs_samples.push_back(rs_sample); + self.sum_rs += rs_sample; + + if self.overnight.len() < self.period { + return None; + } + + let n = self.period as f64; + let mean_on = self.sum_on / n; + let mean_oc = self.sum_oc / n; + // Sample variances (Bessel's correction). Clamp to zero against + // FP cancellation noise. + let var_on = ((self.sum_sq_on - n * mean_on * mean_on) / (n - 1.0)).max(0.0); + let var_oc = ((self.sum_sq_oc - n * mean_oc * mean_oc) / (n - 1.0)).max(0.0); + // Rogers-Satchell mean: each per-bar sample is already >= 0 by + // construction, so the mean cannot be negative outside of FP. + let var_rs = (self.sum_rs / n).max(0.0); + + let total = var_on + self.k * var_oc + (1.0 - self.k) * var_rs; + let sigma = total.max(0.0).sqrt(); + let out = sigma * (self.trading_periods as f64).sqrt() * 100.0; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.prev_close = None; + self.overnight.clear(); + self.open_close.clear(); + self.rs_samples.clear(); + self.sum_on = 0.0; + self.sum_sq_on = 0.0; + self.sum_oc = 0.0; + self.sum_sq_oc = 0.0; + self.sum_rs = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // One bar to seed `prev_close`, then `period` more bars to fill + // the rolling windows. First emit lands at index `period`, i.e. + // the `(period + 1)`-th input. + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "YangZhangVolatility" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle { + Candle::new(o, h, l, c, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!( + YangZhangVolatility::new(0, 252), + Err(Error::PeriodZero) + )); + assert!(matches!( + YangZhangVolatility::new(20, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_period_one() { + assert!(matches!( + YangZhangVolatility::new(1, 252), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let yz = YangZhangVolatility::new(20, 252).unwrap(); + assert_eq!(yz.periods(), (20, 252)); + assert_eq!(yz.value(), None); + assert_eq!(yz.warmup_period(), 21); + assert_eq!(yz.name(), "YangZhangVolatility"); + assert!(!yz.is_ready()); + + // k = 0.34 / (1.34 + 21/19) ≈ 0.139 + let n = 20.0; + let expected_k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0)); + assert_relative_eq!(yz.k(), expected_k, epsilon = 1e-12); + } + + #[test] + fn zero_movement_yields_zero() { + // O == H == L == C and constant across bars -> every per-bar sample + // is zero, all three variances are zero, output is zero. + let candles: Vec = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect(); + let mut yz = YangZhangVolatility::new(14, 1).unwrap(); + for v in yz.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_is_non_negative() { + let mut yz = YangZhangVolatility::new(14, 252).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0; + let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5; + let open = base - 0.1; + let close = base + 0.2; + candle(open, base + half, base - half, close, i64::from(i)) + }) + .collect(); + for v in yz.batch(&candles).into_iter().flatten() { + assert!(v >= 0.0, "Yang-Zhang must be non-negative: {v}"); + } + } + + #[test] + fn annualisation_scales_by_sqrt_trading_periods() { + let candles: Vec = (0..40) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0; + let half = 1.0 + (f64::from(i) * 0.2).cos().abs(); + candle( + base - 0.05, + base + half, + base - half, + base + 0.3, + i64::from(i), + ) + }) + .collect(); + let raw = YangZhangVolatility::new(10, 1).unwrap().batch(&candles); + let annual = YangZhangVolatility::new(10, 252).unwrap().batch(&candles); + let scale = (252.0_f64).sqrt(); + for (r, a) in raw.iter().zip(annual.iter()) { + assert_eq!(r.is_some(), a.is_some(), "warmup mismatch"); + if let (Some(r), Some(a)) = (r, a) { + assert_relative_eq!(*a, r * scale, epsilon = 1e-9); + } + } + } + + #[test] + fn first_emission_at_warmup_period() { + // period = 5 -> first ready at index 5 (the 6th candle): one bar + // seeds prev_close, the next 5 fill the rolling window. + let candles: Vec = (0..20_i64) + .map(|i| { + let base = 100.0 + (i as f64 * 0.4).sin() * 3.0; + candle(base, base + 1.0, base - 1.0, base + 0.2, i) + }) + .collect(); + let mut yz = YangZhangVolatility::new(5, 1).unwrap(); + assert_eq!(yz.warmup_period(), 6); + let out = yz.batch(&candles); + for v in out.iter().take(5) { + assert!(v.is_none(), "indicator must still be warming up"); + } + assert!( + out[5].is_some(), + "first value lands at warmup_period - 1 = 5" + ); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0; + let half = 1.0 + (f64::from(i) * 0.15).cos().abs(); + candle( + base - 0.05, + base + half, + base - half, + base + 0.5, + i64::from(i), + ) + }) + .collect(); + let batch = YangZhangVolatility::new(14, 252).unwrap().batch(&candles); + let mut streamer = YangZhangVolatility::new(14, 252).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect(); + assert_eq!(batch, streamed); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect(); + let mut yz = YangZhangVolatility::new(14, 252).unwrap(); + yz.batch(&candles); + assert!(yz.is_ready()); + yz.reset(); + assert!(!yz.is_ready()); + assert_eq!(yz.value(), None); + assert_eq!(yz.update(candles[0]), None); + } + + #[test] + fn intraday_data_collapses_to_rs_only() { + // If `O_t == C_{t-1}` for every bar (perfect intraday continuity), + // the overnight log-return is zero and `var_on == 0`. If the + // open-to-close return is also constant across bars, `var_oc == 0`. + // Yang-Zhang then reduces to `(1-k) · var_rs`. The arithmetic + // checks out against the closed form. + // + // Construct a series where every bar opens at the previous close + // and has a constant intraday shape: O=10, H=11, L=9, C=10 every + // bar. Then ln(O_t/C_{t-1}) = 0, ln(C/O) = 0, and the RS sample + // is `2 · (ln(11/10) · ln(11/10))` (the ln(9/10)·ln(9/10) term + // matches numerically). + let candles: Vec = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.0, i)).collect(); + let mut yz = YangZhangVolatility::new(10, 1).unwrap(); + let out = yz.batch(&candles); + + let log_hc = (11.0_f64 / 10.0_f64).ln(); + let log_ho = (11.0_f64 / 10.0_f64).ln(); + let log_lc = (9.0_f64 / 10.0_f64).ln(); + let log_lo = (9.0_f64 / 10.0_f64).ln(); + let rs_sample = log_hc * log_ho + log_lc * log_lo; + let n = 10.0; + let k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0)); + // var_on = var_oc = 0 because every sample equals the mean (0). + let total = (1.0 - k) * rs_sample; + let expected = total.max(0.0).sqrt() * 100.0; + + for v in out.iter().skip(11).flatten() { + assert_relative_eq!(*v, expected, epsilon = 1e-9); + } + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 4de18042..20203d9e 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -50,14 +50,15 @@ pub use indicators::{ BollingerOutput, Cci, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, Cmo, ConnorsRsi, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, ElderImpulse, - Ema, Evwma, ForceIndex, Frama, HistoricalVolatility, Hma, Inertia, Jma, Kama, Keltner, - KeltnerOutput, Kst, KstOutput, LaguerreRsi, LinRegAngle, LinRegSlope, LinearRegression, - MacdIndicator, MacdOutput, MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Obv, - PercentB, Pgo, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Rvi, Sma, Smi, Smma, Stc, StdDev, - StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima, Trix, - TrueRange, Tsi, TypicalPrice, UlcerIndex, UltimateOscillator, VerticalHorizontalFilter, Vidya, - VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, WeightedClose, WilliamsR, Wma, ZScore, - ZeroLagMacd, ZeroLagMacdOutput, Zlema, T3, + Ema, Evwma, ForceIndex, Frama, GarmanKlassVolatility, HistoricalVolatility, Hma, Inertia, Jma, + Kama, Keltner, KeltnerOutput, Kst, KstOutput, LaguerreRsi, LinRegAngle, LinRegSlope, + LinearRegression, MacdIndicator, MacdOutput, MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, + Natr, Obv, ParkinsonVolatility, PercentB, Pgo, Pmo, Ppo, Psar, Roc, RogersSatchellVolatility, + RollingVwap, Rsi, Rvi, RviVolatility, Sma, Smi, Smma, Stc, StdDev, StochRsi, Stochastic, + StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima, Trix, TrueRange, Tsi, + TypicalPrice, UlcerIndex, UltimateOscillator, VerticalHorizontalFilter, Vidya, + VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, WeightedClose, WilliamsR, Wma, + YangZhangVolatility, ZScore, ZeroLagMacd, ZeroLagMacdOutput, Zlema, T3, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator}; diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 91cd9720..810b2a9b 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -19,8 +19,9 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use std::hint::black_box; use wickra::{ - Alma, Atr, BatchExt, BollingerBands, Candle, Ema, Frama, Indicator, Jma, MacdIndicator, - McGinleyDynamic, Obv, Pgo, Rsi, Rvi, Sma, Stochastic, Vidya, Wma, + Alma, Atr, BatchExt, BollingerBands, Candle, Ema, Frama, GarmanKlassVolatility, Indicator, Jma, + MacdIndicator, McGinleyDynamic, Obv, ParkinsonVolatility, Pgo, RogersSatchellVolatility, Rsi, + Rvi, RviVolatility, Sma, Stochastic, Vidya, Wma, YangZhangVolatility, }; use wickra_data::csv::CandleReader; @@ -151,6 +152,21 @@ fn benches(c: &mut Criterion) { bench_candle_input(c, "atr", &candles, || Atr::new(14).unwrap()); bench_candle_input(c, "stochastic", &candles, Stochastic::classic); bench_candle_input(c, "obv", &candles, Obv::new); + bench_scalar(c, "rvi_volatility", &closes, || { + RviVolatility::new(10).unwrap() + }); + bench_candle_input(c, "parkinson", &candles, || { + ParkinsonVolatility::new(20, 252).unwrap() + }); + bench_candle_input(c, "garman_klass", &candles, || { + GarmanKlassVolatility::new(20, 252).unwrap() + }); + bench_candle_input(c, "rogers_satchell", &candles, || { + RogersSatchellVolatility::new(20, 252).unwrap() + }); + bench_candle_input(c, "yang_zhang", &candles, || { + YangZhangVolatility::new(20, 252).unwrap() + }); bench_candle_input(c, "rvi", &candles, || Rvi::new(10).unwrap()); bench_candle_input(c, "pgo", &candles, || Pgo::new(14).unwrap()); } diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index b30a9fd0..2147330d 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -17,9 +17,9 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ Alma, Apo, BatchExt, BollingerBands, Cfo, Cmo, ConnorsRsi, Coppock, Dema, Dpo, ElderImpulse, Ema, Frama, HistoricalVolatility, Hma, Indicator, Jma, Kama, Kst, LaguerreRsi, LinRegAngle, - LinRegSlope, LinearRegression, MacdIndicator, McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, Sma, - Smma, Stc, StdDev, StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, - Vidya, Wma, ZScore, ZeroLagMacd, Zlema, + LinRegSlope, LinearRegression, MacdIndicator, McGinleyDynamic, Mom, Pmo, Ppo, Roc, Rsi, + RviVolatility, Sma, Smma, Stc, StdDev, StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, + VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, Zlema, }; /// Drive a single streaming + batch run through one scalar indicator. Marked @@ -80,6 +80,7 @@ fuzz_target!(|data: Vec| { drive(|| LinRegAngle::new(14).unwrap(), &data); drive(|| VerticalHorizontalFilter::new(14).unwrap(), &data); drive(|| ZScore::new(14).unwrap(), &data); + drive(|| RviVolatility::new(10).unwrap(), &data); drive(|| LaguerreRsi::new(0.5).unwrap(), &data); drive(|| ConnorsRsi::classic(), &data); diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index e1fc2edf..9d7ad8e5 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -27,10 +27,10 @@ use wickra_core::{ AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, Donchian, EaseOfMovement, - Evwma, ForceIndex, Indicator, Inertia, Keltner, MassIndex, MedianPrice, Mfi, Natr, Obv, Pgo, - Psar, RollingVwap, Rvi, Smi, + Evwma, ForceIndex, GarmanKlassVolatility, Indicator, Inertia, Keltner, MassIndex, MedianPrice, + Mfi, Natr, Obv, ParkinsonVolatility, Pgo, Psar, RogersSatchellVolatility, RollingVwap, Rvi, Smi, Stochastic, SuperTrend, TrueRange, TypicalPrice, UltimateOscillator, VolumePriceTrend, Vortex, - Vwap, Vwma, WeightedClose, WilliamsR, + Vwap, Vwma, WeightedClose, WilliamsR, YangZhangVolatility, }; /// Convert a flat `f64` stream into a `Vec` by chunking it into @@ -75,6 +75,10 @@ fuzz_target!(|data: Vec| { drive(|| Natr::new(14).unwrap(), &candles); drive(TrueRange::new, &candles); drive(|| ChaikinVolatility::new(10, 10).unwrap(), &candles); + drive(|| ParkinsonVolatility::new(20, 252).unwrap(), &candles); + drive(|| GarmanKlassVolatility::new(20, 252).unwrap(), &candles); + drive(|| RogersSatchellVolatility::new(20, 252).unwrap(), &candles); + drive(|| YangZhangVolatility::new(20, 252).unwrap(), &candles); // --- Bands & Channels --- drive(|| Keltner::new(20, 10, 2.0).unwrap(), &candles);