feat: Family 04 Volatility — RVI / Parkinson / Garman-Klass / Rogers-Satchell / Yang-Zhang (#42)

* 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) ...`.
This commit is contained in:
kingchenc
2026-05-25 18:18:20 +02:00
committed by GitHub
parent d9d3ad18aa
commit 3ea0f12b7a
20 changed files with 2690 additions and 20 deletions
+43
View File
@@ -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 `n1`), 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
+3 -3
View File
@@ -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/
@@ -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.
+6 -1
View File
@@ -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
+281
View File
@@ -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<Self> {
Ok(Self {
inner: wc::RviVolatility::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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<Self> {
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<Option<f64>> {
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<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<f64>> {
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<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<f64>> {
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<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<f64>> {
Ok(self.inner.update(cnd(high, low, low, 0.0)?))
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
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")]
+10
View File
@@ -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",
+361
View File
@@ -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<Self> {
Ok(Self {
inner: wc::YangZhangVolatility::new(period, trading_periods).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
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<Bound<'py, PyArray1<f64>>> {
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<Self> {
Ok(Self {
inner: wc::RogersSatchellVolatility::new(period, trading_periods).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
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<Bound<'py, PyArray1<f64>>> {
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<Self> {
Ok(Self {
inner: wc::GarmanKlassVolatility::new(period, trading_periods).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
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<Bound<'py, PyArray1<f64>>> {
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<Self> {
Ok(Self {
inner: wc::ParkinsonVolatility::new(period, trading_periods).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
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<Bound<'py, PyArray1<f64>>> {
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<Self> {
Ok(Self {
inner: wc::RviVolatility::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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<f64> {
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::<PyZeroLagMacd>()?;
m.add_class::<PyElderImpulse>()?;
m.add_class::<PyStc>()?;
m.add_class::<PyRviVolatility>()?;
m.add_class::<PyParkinsonVolatility>()?;
m.add_class::<PyGarmanKlassVolatility>()?;
m.add_class::<PyRogersSatchellVolatility>()?;
m.add_class::<PyYangZhangVolatility>()?;
Ok(())
}
@@ -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)
@@ -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),
),
}
+218
View File
@@ -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<WasmYangZhangVolatility, JsError> {
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<Option<f64>, 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<Float64Array, JsError> {
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<WasmRogersSatchellVolatility, JsError> {
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<Option<f64>, 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<Float64Array, JsError> {
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<WasmGarmanKlassVolatility, JsError> {
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<Option<f64>, 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<Float64Array, JsError> {
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<WasmParkinsonVolatility, JsError> {
Ok(Self {
inner: wc::ParkinsonVolatility::new(period, trading_periods).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64) -> Result<Option<f64>, 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<Float64Array, JsError> {
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)]
@@ -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<f64>,
sum: f64,
last: Option<f64>,
}
/// `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<Self> {
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<f64> {
self.last
}
}
impl Indicator for GarmanKlassVolatility {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// `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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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);
}
}
+10
View File
@@ -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;
@@ -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<f64>,
sum_sq: f64,
last: Option<f64>,
}
/// `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<Self> {
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<f64> {
self.last
}
}
impl Indicator for ParkinsonVolatility {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// `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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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);
}
}
@@ -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<f64>,
sum: f64,
last: Option<f64>,
}
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<Self> {
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<f64> {
self.last
}
}
impl Indicator for RogersSatchellVolatility {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// `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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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);
}
}
@@ -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<f64>,
sum: f64,
sum_sq: f64,
// Direction tracking.
prev_close: Option<f64>,
// Wilder-smoothed up/down volatility.
seed_up: Vec<f64>,
seed_down: Vec<f64>,
avg_up: Option<f64>,
avg_down: Option<f64>,
last_value: Option<f64>,
}
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<Self> {
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<f64> {
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<f64> {
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::<f64>() / n;
let ad = self.seed_down.iter().sum::<f64>() / 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<f64> = (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<f64> = (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<f64> = (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<f64> = (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<f64> = (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::<Vec<_>>());
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::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(rvi.update(f64::NAN), last);
assert_eq!(rvi.update(f64::INFINITY), last);
}
}
@@ -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<f64>,
// Each window stores one f64 per bar in the rolling window.
overnight: VecDeque<f64>,
open_close: VecDeque<f64>,
rs_samples: VecDeque<f64>,
sum_on: f64,
sum_sq_on: f64,
sum_oc: f64,
sum_sq_oc: f64,
sum_rs: f64,
last: Option<f64>,
}
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<Self> {
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<f64> {
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<f64> {
// 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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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);
}
}
}
+9 -8
View File
@@ -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};
+18 -2
View File
@@ -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());
}
+4 -3
View File
@@ -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<f64>| {
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);
+7 -3
View File
@@ -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<Candle>` by chunking it into
@@ -75,6 +75,10 @@ fuzz_target!(|data: Vec<f64>| {
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);