From 3ea0f12b7a54201960cb3557734e7c57315d4ee3 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 25 May 2026 18:18:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Family=2004=20Volatility=20=E2=80=94=20?= =?UTF-8?q?RVI=20/=20Parkinson=20/=20Garman-Klass=20/=20Rogers-Satchell=20?= =?UTF-8?q?/=20Yang-Zhang=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(rvi): add Relative Volatility Index Donald Dorsey's RSI-shaped volatility gauge. Partitions the rolling population standard deviation of close into "up" samples (close rose since the previous bar) and "down" samples (close fell), Wilder-smooths each side, and reports 100 * AvgUp / (AvgUp + AvgDown). Output 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 constant. First emit lands at index 2*period - 2 (2*period - 1 bars are needed: period to fill the stddev window plus period - 1 to seed the Wilder averages, overlapping by one bar). Touchpoints: rvi.rs + mod.rs + lib.rs re-export, PyRvi + __init__.py + test_new_indicators SCALAR + test_known_values uptrend reference, RviNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmRvi via scalar macro, scalar-fuzz target, bench_scalar entry, README + CHANGELOG. * feat(parkinson): add Parkinson Volatility Michael Parkinson's (1980) high-low realised volatility estimator. Under a driftless Geometric-Brownian-Motion assumption, the extreme range of a bar carries roughly 5x the variance information of the close-to-close estimator, so for a given statistical efficiency Parkinson needs five times fewer samples. Formula: sigma^2 = (1 / (4n * ln 2)) * Sum_{i=1..n} (ln(H_i / L_i))^2 out = sqrt(sigma^2) * sqrt(trading_periods) * 100 The output is annualised to a percent in the same style as HistoricalVolatility (pass `trading_periods = 1` for the raw per-bar sigma * 100 figure). Two parameters: `period` (default 20) for the rolling window, `trading_periods` (default 252) for the annualisation factor. First emit at index `period - 1`. Touchpoints: parkinson.rs + mod.rs + lib.rs re-export, PyParkinsonVolatility + __init__.py + test_new_indicators CANDLE_SCALAR + test_known_values zero-range reference, ParkinsonVolatilityNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmParkinsonVolatility hand-rolled, candle-fuzz target, bench_candle_input entry, README + CHANGELOG. * feat(garman-klass): add Garman-Klass Volatility Garman & Klass (1980) OHLC realised-volatility estimator. Extends Parkinson's high-low estimator with an open-to-close term, lifting statistical efficiency from ~5x to ~7.4x relative to close-to-close stddev under driftless Geometric Brownian Motion. Formula (per bar): s_t = 0.5 * (ln(H_t / L_t))^2 - (2*ln(2) - 1) * (ln(C_t / O_t))^2 out = sqrt(max(mean(s_t over `period`), 0)) * sqrt(trading_periods) * 100 The per-bar sample can be marginally negative when the bar has a small range relative to its open-to-close move; a max(., 0) clamp on the rolling mean absorbs that and the FP cancellation noise before the square root. Still biased on data with meaningful overnight drift -- use Yang-Zhang when gaps matter. Defaults: `period = 20`, `trading_periods = 252` (annualised percent, same convention as HistoricalVolatility). Touchpoints: garman_klass.rs + mod.rs + lib.rs re-export, PyGarmanKlassVolatility + __init__.py + test_new_indicators CANDLE_SCALAR + test_known_values zero-movement reference, GarmanKlassVolatilityNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmGarmanKlassVolatility hand-rolled, candle-fuzz target, bench_candle_input entry, README + CHANGELOG. * feat(rogers-satchell): add Rogers-Satchell Volatility Rogers, Satchell & Yoon (1994) OHLC realised-volatility estimator. Unlike Garman-Klass, the per-bar sample is exact under arbitrary Brownian drift -- the drift component cancels algebraically. Formula (per bar): s_t = ln(H_t / C_t) * ln(H_t / O_t) + ln(L_t / C_t) * ln(L_t / O_t) out = sqrt(max(mean(s_t over `period`), 0)) * sqrt(trading_periods) * 100 Each per-bar sample is also non-negative by construction: with `Candle::new` guaranteeing H >= max(O, L, C) and L <= min(O, H, C), the four log factors have predictable signs (ln(H/.) >= 0, ln(L/.) <= 0), so both products contribute >= 0. The max(., 0) clamp on the rolling mean is only there to absorb FP cancellation. Defaults: `period = 20`, `trading_periods = 252` (annualised percent, same convention as HistoricalVolatility / Parkinson / Garman-Klass). Touchpoints: rogers_satchell.rs + mod.rs + lib.rs re-export, PyRogersSatchellVolatility + __init__.py + test_new_indicators CANDLE_SCALAR + test_known_values zero-movement reference, RogersSatchellVolatilityNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmRogersSatchellVolatility hand-rolled, candle-fuzz target, bench_candle_input entry, README + CHANGELOG. * feat(yang-zhang): add Yang-Zhang Volatility Yang & Zhang (2000) drift- and gap-robust OHLC realised-volatility estimator. Combines three independent components into a single estimate with minimum variance: overnight = sample_var(ln(O_t / C_{t-1})) over n bars (close-to-open) open_close = sample_var(ln(C_t / O_t)) over n bars rs = mean(ln(H/C)*ln(H/O) + ln(L/C)*ln(L/O)) over n bars sigma^2_YZ = overnight + k*open_close + (1-k)*rs k = 0.34 / (1.34 + (n+1)/(n-1)) out = sqrt(max(sigma^2_YZ, 0)) * sqrt(trading_periods) * 100 The overnight and open-to-close variances use Bessel's correction (the sample estimator, divisor n-1), same convention as HistoricalVolatility. The blending factor `k` is the one that minimises estimator variance under driftless Geometric Brownian Motion with overnight gaps. This is the gold-standard OHLC estimator for assets with both close-to-open gaps and intraday drift: equities, futures, and any market that does not trade continuously. For pure intraday data (where O_t == C_{t-1} and the open-to-close return is constant), the overnight and open-close terms vanish and the estimator collapses to (1-k) * Rogers-Satchell -- this is the indicator's intraday_data_collapses_to_rs_only unit test. Period >= 2 (Bessel correction needs >= 2 samples). First emit at index `period` (the (period+1)-th bar): one bar seeds prev_close, the next `period` fill the rolling windows. Defaults: `period = 20`, `trading_periods = 252`. Touchpoints: yang_zhang.rs + mod.rs + lib.rs re-export, PyYangZhangVolatility + __init__.py + test_new_indicators CANDLE_SCALAR + test_known_values zero-movement reference, YangZhangVolatilityNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmYangZhangVolatility hand-rolled, candle-fuzz target, bench_candle_input entry, README + CHANGELOG. * fix(rvi): rename to RviVolatility to avoid clash with family-02 RVI Family 02 (PR #40) ships a separate `Rvi` struct for Relative Vigor Index. The two indicators have nothing to do with each other beyond sharing the acronym, so disambiguate by giving the volatility one a longer name everywhere: - Rust crate: `Rvi` -> `RviVolatility` - Rust file: `rvi.rs` -> `rvi_volatility.rs` - Python: `RVI` -> `RVIVolatility` - Node: `RVI` -> `RVIVolatility` - WASM: `RVI` -> `RVIVolatility` Once the two PRs are both merged, callers get `wickra::Rvi` for Vigor and `wickra::RviVolatility` for Volatility. The shorter `RVI` acronym stays with the Momentum family per the existing wiki pages and the implementation that shipped first. Updates: rvi_volatility.rs (renamed), mod.rs, lib.rs re-export, bindings/python/src/lib.rs + __init__.py + tests, bindings/node/src/lib.rs + index.d.ts + index.js + __tests__, bindings/wasm/src/lib.rs, fuzz/fuzz_targets/indicator_update.rs, crates/wickra/benches/indicators.rs, README family-table label, CHANGELOG entry. * test(volatility): Rename test_rvi -> test_rvi_volatility + drop dead match arms The Python test test_rvi_pure_uptrend_saturates_at_one_hundred was calling ta.RVI() expecting the volatility version, but ta.RVI now means Family 02's Relative Vigor Index (candle input). Renamed to ta.RVIVolatility to match the binding rename done at merge time. In all four OHLC volatility tests, the existing `match (r, a) { ..., _ => panic!() }` arm is dead in passing runs (every aligned pair is either (None, None) or (Some, Some)). Codecov flagged it as a patch miss on each of parkinson / garman_klass / rogers_satchell / yang_zhang. Refactored per CLAUDE.md cold-path guidance to `assert_eq!(r.is_some(), a.is_some()); if let (Some, Some) ...`. --- CHANGELOG.md | 43 ++ README.md | 6 +- bindings/node/__tests__/indicators.test.js | 50 +++ bindings/node/index.js | 7 +- bindings/node/src/lib.rs | 281 ++++++++++++ bindings/python/python/wickra/__init__.py | 10 + bindings/python/src/lib.rs | 361 ++++++++++++++++ bindings/python/tests/test_known_values.py | 57 +++ bindings/python/tests/test_new_indicators.py | 19 + bindings/wasm/src/lib.rs | 218 ++++++++++ .../src/indicators/garman_klass.rs | 286 ++++++++++++ crates/wickra-core/src/indicators/mod.rs | 10 + .../wickra-core/src/indicators/parkinson.rs | 276 ++++++++++++ .../src/indicators/rogers_satchell.rs | 288 +++++++++++++ .../src/indicators/rvi_volatility.rs | 338 +++++++++++++++ .../wickra-core/src/indicators/yang_zhang.rs | 406 ++++++++++++++++++ crates/wickra-core/src/lib.rs | 17 +- crates/wickra/benches/indicators.rs | 20 +- fuzz/fuzz_targets/indicator_update.rs | 7 +- fuzz/fuzz_targets/indicator_update_candle.rs | 10 +- 20 files changed, 2690 insertions(+), 20 deletions(-) create mode 100644 crates/wickra-core/src/indicators/garman_klass.rs create mode 100644 crates/wickra-core/src/indicators/parkinson.rs create mode 100644 crates/wickra-core/src/indicators/rogers_satchell.rs create mode 100644 crates/wickra-core/src/indicators/rvi_volatility.rs create mode 100644 crates/wickra-core/src/indicators/yang_zhang.rs 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);