B5 volatility & bands batch (423 -> 429) (#189)

Adds six **Volatility & Bands** indicators (Part B5 of the expansion roadmap), 423 → 429.

| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `EwmaVolatility` | `f64` → `f64` | RiskMetrics exponentially-weighted volatility (λ decay) |
| `Garch11` | `f64` → `f64` | GARCH(1,1) conditional volatility with a long-run-variance anchor |
| `BipowerVariation` | `f64` → `f64` | jump-robust realized bipower variation (π/2 · Σ\|rₜ\|\|rₜ₋₁\|) |
| `VolatilityRatio` | `Candle` → `f64` | Schwager's true range over the EMA of prior true ranges (>2 = wide-ranging day) |
| `VolatilityCone` | `Candle` → `VolatilityConeOutput` | current realized volatility within its min/median/max envelope + percentile |
| `VolatilityOfVolatility` | `f64` → `f64` | sample stddev of a rolling realized-volatility series |

### Notes
- Two B5 roadmap items were dropped as duplicates/by-construction: `RealizedVolatility` already ships (v0.5.4); `Downside Semi-Deviation` is internal to Sortino. `Bipower Variation` confirmed distinct from `JumpIndicator` (a ±1 flag, not a variance measure).
- `VolatilityRatio` implements the widely-charted EMA-of-true-range convention (denominator excludes the current bar so the 2.0 threshold means "twice typical"), distinct from the existing pairwise `variance_ratio`.
- `Garch11` mean-reverts to `ω/(1−β)` on a flat series (does not decay to 0 like EWMA) — pinned by a dedicated test.

### Coverage / verification
- Full core + Python/Node/WASM bindings, fuzz drivers (scalar + candle), registries, CHANGELOG, README + docs counter sync.
- 100% unit-test coverage per indicator (every branch).
- Green locally: `cargo clippy --workspace --all-targets --all-features -D warnings`, core lib (3479) + doc (387), node (504), python (830).

Deep-dive docs for all six are staged for `wickra-docs` and pushed after release (gated).
This commit is contained in:
kingchenc
2026-06-06 22:38:34 +02:00
committed by GitHub
parent db186b18d3
commit 6b8c6a0e7f
21 changed files with 2817 additions and 74 deletions
@@ -0,0 +1,281 @@
//! Realized Bipower Variation — a jump-robust quadratic-variation estimator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Realized Bipower Variation — the sum of *adjacent* absolute log-return
/// products over the trailing `period` returns, scaled to estimate integrated
/// variance.
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// BV = (π / 2) · Σ |r_t| · |r_{t1}| over the window
/// ```
///
/// Bipower variation (Barndorff-Nielsen & Shephard 2004) estimates the same
/// integrated variance as [`RealizedVolatility`](crate::RealizedVolatility)'s
/// `Σ r²`, but by multiplying *neighbouring* absolute returns rather than
/// squaring a single one. A price jump inflates exactly one return; because that
/// return appears in a product with its (ordinary) neighbour rather than squared,
/// its contribution stays bounded — so `BV` is **robust to jumps** while realized
/// variance is not. The constant `π / 2 = μ₁⁻²` (with `μ₁ = E|Z| = √(2/π)` for a
/// standard normal) debiases the product of two half-normal magnitudes back to a
/// variance scale.
///
/// The output is on the **variance** scale (the jump-robust counterpart of
/// realized *variance*, not volatility); take its square root for a volatility,
/// and compare `RV BV` to isolate the jump contribution. A window of `period`
/// returns contributes `period 1` adjacent products; each `update` is O(1) via
/// a running sum.
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last value
/// is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{BipowerVariation, Indicator};
///
/// let mut indicator = BipowerVariation::new(20).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BipowerVariation {
period: usize,
prev_price: Option<f64>,
/// Rolling window of the last `period` log returns.
window: VecDeque<f64>,
/// Running sum of adjacent absolute-return products inside the window.
sum_adjacent: f64,
last: Option<f64>,
}
impl BipowerVariation {
/// Construct a new bipower-variation indicator.
///
/// `period` is the number of log returns in the rolling window; the estimate
/// uses the `period 1` adjacent products between them.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidPeriod`] if `period == 1` (an adjacent product needs at
/// least two returns).
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if period < 2 {
return Err(Error::InvalidPeriod {
message: "bipower variation period must be >= 2",
});
}
Ok(Self {
period,
prev_price: None,
window: VecDeque::with_capacity(period),
sum_adjacent: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
/// `μ₁⁻² = π / 2`, the debiasing constant for a product of half-normal returns.
const MU1_INV_SQ: f64 = std::f64::consts::FRAC_PI_2;
impl Indicator for BipowerVariation {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the return window.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
// The incoming return forms a product with the current last return.
if let Some(&back) = self.window.back() {
self.sum_adjacent += back.abs() * r.abs();
}
self.window.push_back(r);
if self.window.len() > self.period {
let first = self.window.pop_front().expect("window is non-empty");
// The product between the dropped return and the new front leaves.
let second = *self.window.front().expect("window still has >= 1 element");
self.sum_adjacent -= first.abs() * second.abs();
}
if self.window.len() < self.period {
return None;
}
// Products are non-negative; the rolling subtraction can leave a tiny
// negative residual when returns are ~0, so clamp before scaling.
let bv = MU1_INV_SQ * self.sum_adjacent.max(0.0);
self.last = Some(bv);
Some(bv)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum_adjacent = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price, then the window fills.
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BipowerVariation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(BipowerVariation::new(0), Err(Error::PeriodZero)));
}
#[test]
fn rejects_period_one() {
assert!(matches!(
BipowerVariation::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bv = BipowerVariation::new(20).unwrap();
assert_eq!(bv.period(), 20);
assert_eq!(bv.warmup_period(), 21);
assert_eq!(bv.name(), "BipowerVariation");
assert!(!bv.is_ready());
}
#[test]
fn first_emission_at_warmup_period() {
let mut bv = BipowerVariation::new(5).unwrap();
let out = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn known_value() {
// period = 2: one adjacent product. r1 = ln(1.1), r2 = ln(0.9).
// BV = (π/2)·|r1|·|r2|.
let mut bv = BipowerVariation::new(2).unwrap();
let out = bv.batch(&[100.0, 110.0, 99.0]);
assert!(out[1].is_none());
let r1 = (110.0_f64 / 100.0).ln();
let r2 = (99.0_f64 / 110.0).ln();
let expected = std::f64::consts::FRAC_PI_2 * r1.abs() * r2.abs();
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn rolling_window_drops_oldest_product() {
// period = 2, four prices -> two emissions, each a single product.
let mut bv = BipowerVariation::new(2).unwrap();
let out = bv.batch(&[100.0, 110.0, 99.0, 105.0]);
let r2 = (99.0_f64 / 110.0).ln();
let r3 = (105.0_f64 / 99.0).ln();
let expected = std::f64::consts::FRAC_PI_2 * r2.abs() * r3.abs();
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut bv = BipowerVariation::new(10).unwrap();
for v in bv.batch(&[100.0; 40]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut bv = BipowerVariation::new(20).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in bv.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "bipower variation must be non-negative, got {v}");
}
}
#[test]
fn ignores_non_finite_input() {
let mut bv = BipowerVariation::new(5).unwrap();
let out = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(bv.update(f64::NAN), last);
assert_eq!(bv.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut bv = BipowerVariation::new(5).unwrap();
let warmup = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(bv.update(-5.0), Some(baseline));
assert_eq!(bv.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = bv.clone();
let after = bv.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after);
}
#[test]
fn reset_clears_state() {
let mut bv = BipowerVariation::new(5).unwrap();
bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(bv.is_ready());
bv.reset();
assert!(!bv.is_ready());
assert_eq!(bv.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = BipowerVariation::new(20).unwrap().batch(&prices);
let mut b = BipowerVariation::new(20).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,264 @@
//! EWMA Volatility — `RiskMetrics` exponentially-weighted volatility.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// EWMA Volatility — the `RiskMetrics` exponentially-weighted estimate of the
/// volatility of log returns.
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// σ²_t = λ · σ²_{t1} + (1 λ) · r²_t
/// EWMA = √σ²_t
/// ```
///
/// Unlike [`HistoricalVolatility`](crate::HistoricalVolatility) — an equally
/// weighted, mean-centred sample standard deviation over a fixed window — the
/// EWMA estimator weights recent squared returns geometrically by the decay
/// factor `λ`. The most recent return carries weight `1 λ`, the one before it
/// `λ(1 λ)`, and so on, so the estimate reacts to a volatility shock
/// immediately and then forgets it at rate `λ`. This is the J.P. Morgan
/// `RiskMetrics` one-parameter model; the standard daily decay is `λ = 0.94`
/// (monthly `0.97`). No mean is subtracted: squared returns *are* the variance
/// contribution, which matches the `RiskMetrics` assumption of a zero conditional
/// mean over short horizons.
///
/// The recursion is seeded with the first squared return (`σ²₁ = r²₁`) and emits
/// from the first return onward, so the very first reading is a one-observation
/// estimate that the decay then refines. Each `update` is O(1).
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last value
/// is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{EwmaVolatility, Indicator};
///
/// let mut indicator = EwmaVolatility::new(0.94).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 EwmaVolatility {
lambda: f64,
prev_price: Option<f64>,
/// Exponentially-weighted variance of log returns; `None` until seeded.
variance: Option<f64>,
last: Option<f64>,
}
impl EwmaVolatility {
/// Construct a new EWMA-volatility indicator.
///
/// `lambda` is the decay factor, strictly between `0` and `1` (`RiskMetrics`
/// uses `0.94` for daily data). Larger `lambda` means a longer memory and a
/// smoother estimate.
///
/// # Errors
/// Returns [`Error::InvalidParameter`] if `lambda` is not finite or not in
/// the open interval `(0, 1)`.
pub fn new(lambda: f64) -> Result<Self> {
if !lambda.is_finite() || lambda <= 0.0 || lambda >= 1.0 {
return Err(Error::InvalidParameter {
message: "EWMA volatility lambda must be in the open interval (0, 1)",
});
}
Ok(Self {
lambda,
prev_price: None,
variance: None,
last: None,
})
}
/// Configured decay factor.
pub const fn lambda(&self) -> f64 {
self.lambda
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for EwmaVolatility {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the variance recursion.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
let var = match self.variance {
// Seed the recursion with the first squared return.
None => r * r,
Some(prev_var) => self.lambda * prev_var + (1.0 - self.lambda) * r * r,
};
self.variance = Some(var);
// `var` is a convex combination of non-negative terms, but rounding can
// leave a tiny negative residual when every return is ~0; clamp first.
let vol = var.max(0.0).sqrt();
self.last = Some(vol);
Some(vol)
}
fn reset(&mut self) {
self.prev_price = None;
self.variance = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price; the estimate is seeded
// and emitted on that first return.
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"EwmaVolatility"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_lambda() {
for bad in [0.0, 1.0, -0.5, 1.5, f64::NAN, f64::INFINITY] {
assert!(matches!(
EwmaVolatility::new(bad),
Err(Error::InvalidParameter { .. })
));
}
}
#[test]
fn accessors_and_metadata() {
let ewma = EwmaVolatility::new(0.94).unwrap();
assert_relative_eq!(ewma.lambda(), 0.94);
assert_eq!(ewma.warmup_period(), 2);
assert_eq!(ewma.name(), "EwmaVolatility");
assert!(!ewma.is_ready());
assert_eq!(ewma.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
assert_eq!(ewma.update(100.0), None);
let out = ewma.update(110.0);
assert!(out.is_some());
assert!(ewma.is_ready());
}
#[test]
fn known_value() {
// r1 = ln(110/100), r2 = ln(99/110). Seed σ²₁ = r1²; then
// σ²₂ = λ·r1² + (1−λ)·r2².
let lambda = 0.94;
let mut ewma = EwmaVolatility::new(lambda).unwrap();
let out = ewma.batch(&[100.0, 110.0, 99.0]);
let r1 = (110.0_f64 / 100.0).ln();
let r2 = (99.0_f64 / 110.0).ln();
assert_relative_eq!(out[1].unwrap(), r1.abs(), epsilon = 1e-12);
let var2 = lambda * r1 * r1 + (1.0 - lambda) * r2 * r2;
assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut ewma = EwmaVolatility::new(0.9).unwrap();
for v in ewma.batch(&[100.0; 40]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in ewma.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "EWMA volatility must be non-negative, got {v}");
}
}
#[test]
fn ignores_non_finite_input() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
let out = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(ewma.update(f64::NAN), last);
assert_eq!(ewma.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
let warmup = ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(ewma.update(-5.0), Some(baseline));
assert_eq!(ewma.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = ewma.clone();
let after = ewma.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after);
}
#[test]
fn skips_non_positive_before_first_price() {
// The skip guard fires before any previous price exists.
let mut ewma = EwmaVolatility::new(0.94).unwrap();
assert_eq!(ewma.update(0.0), None);
assert_eq!(ewma.update(f64::NAN), None);
assert_eq!(ewma.update(100.0), None);
assert!(ewma.update(110.0).is_some());
}
#[test]
fn reset_clears_state() {
let mut ewma = EwmaVolatility::new(0.94).unwrap();
ewma.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(ewma.is_ready());
ewma.reset();
assert!(!ewma.is_ready());
assert_eq!(ewma.value(), None);
assert_eq!(ewma.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = EwmaVolatility::new(0.94).unwrap().batch(&prices);
let mut b = EwmaVolatility::new(0.94).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,325 @@
//! GARCH(1,1) — conditional volatility with a long-run-variance anchor.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// GARCH(1,1) conditional volatility — the square root of the
/// generalized-autoregressive-conditional-heteroskedasticity variance recursion.
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// σ²_t = ω + α · r²_{t1} + β · σ²_{t1}
/// out = √σ²_t
/// ```
///
/// GARCH(1,1) (Bollerslev 1986) generalizes the
/// [`EwmaVolatility`](crate::EwmaVolatility) recursion by adding a constant `ω`,
/// which pins the process to a finite long-run (unconditional) variance
/// `ω / (1 α β)`. The `α` term gives weight to the latest squared return
/// (the "ARCH" shock) and `β` to the previous variance (the "GARCH"
/// persistence). When `ω = 0` and `α + β = 1` the model degenerates to EWMA; a
/// proper GARCH keeps `ω > 0` and `α + β < 1` so volatility mean-reverts rather
/// than drifting.
///
/// The recursion is seeded with the unconditional variance (`σ²₁ = ω / (1 α
/// β)`) and emits from the first log return onward. Unlike EWMA — which decays to
/// zero on a flat series — a flat series here mean-reverts toward `ω / (1 β)`
/// (the `α`-term vanishes but the `ω` floor and the `β` carry remain), so the
/// output is always strictly positive. Each `update` is O(1).
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last value
/// is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{Garch11, Indicator};
///
/// // Typical equity daily estimate.
/// let mut indicator = Garch11::new(0.000_002, 0.10, 0.88).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 Garch11 {
omega: f64,
alpha: f64,
beta: f64,
unconditional: f64,
prev_price: Option<f64>,
/// `(σ²_{t1}, r²_{t1})` — previous variance and previous squared return.
state: Option<(f64, f64)>,
last: Option<f64>,
}
impl Garch11 {
/// Construct a new GARCH(1,1) indicator from its three parameters.
///
/// `omega` (`ω`) is the constant variance floor, `alpha` (`α`) the weight on
/// the latest squared return, and `beta` (`β`) the persistence of the
/// previous variance.
///
/// # Errors
/// Returns [`Error::InvalidParameter`] unless every parameter is finite,
/// `omega > 0`, `alpha >= 0`, `beta >= 0`, and `alpha + beta < 1` (the
/// covariance-stationarity condition that gives a finite long-run variance).
pub fn new(omega: f64, alpha: f64, beta: f64) -> Result<Self> {
if !omega.is_finite() || !alpha.is_finite() || !beta.is_finite() {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) parameters must be finite",
});
}
if omega <= 0.0 {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) omega must be > 0",
});
}
if alpha < 0.0 || beta < 0.0 {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) alpha and beta must be >= 0",
});
}
if alpha + beta >= 1.0 {
return Err(Error::InvalidParameter {
message: "GARCH(1,1) requires alpha + beta < 1 (covariance stationarity)",
});
}
Ok(Self {
omega,
alpha,
beta,
unconditional: omega / (1.0 - alpha - beta),
prev_price: None,
state: None,
last: None,
})
}
/// Configured `(omega, alpha, beta)`.
pub const fn params(&self) -> (f64, f64, f64) {
(self.omega, self.alpha, self.beta)
}
/// Long-run (unconditional) variance `ω / (1 α β)`.
pub const fn unconditional_variance(&self) -> f64 {
self.unconditional
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Garch11 {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the variance recursion.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
let r_sq = r * r;
let var = match self.state {
// Seed the recursion with the unconditional variance.
None => self.unconditional,
Some((prev_var, prev_r_sq)) => {
self.omega + self.alpha * prev_r_sq + self.beta * prev_var
}
};
self.state = Some((var, r_sq));
// `var` is `omega (> 0) + non-negative terms`, so it is strictly
// positive — the square root is always well-defined.
let vol = var.sqrt();
self.last = Some(vol);
Some(vol)
}
fn reset(&mut self) {
self.prev_price = None;
self.state = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price; the estimate is seeded
// with the unconditional variance and emitted on that first return.
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"Garch11"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_params() {
assert!(matches!(
Garch11::new(0.0, 0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(-1.0, 0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, -0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, 0.1, -0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, 0.5, 0.5),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(f64::NAN, 0.1, 0.8),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Garch11::new(0.001, f64::INFINITY, 0.8),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let g = Garch11::new(0.001, 0.1, 0.85).unwrap();
assert_eq!(g.params(), (0.001, 0.1, 0.85));
assert_relative_eq!(g.unconditional_variance(), 0.001 / 0.05, epsilon = 1e-12);
assert_eq!(g.warmup_period(), 2);
assert_eq!(g.name(), "Garch11");
assert!(!g.is_ready());
assert_eq!(g.value(), None);
}
#[test]
fn first_emission_is_unconditional() {
// The first log return emits the seed = sqrt(unconditional variance),
// independent of the return value.
let g = Garch11::new(0.002, 0.1, 0.85);
let mut g = g.unwrap();
assert_eq!(g.update(100.0), None);
let out = g.update(110.0).unwrap();
assert_relative_eq!(out, (0.002_f64 / 0.05).sqrt(), epsilon = 1e-12);
}
#[test]
fn known_value() {
// σ²₁ = uncond; σ²₂ = ω + α·r1² + β·uncond.
let (omega, alpha, beta) = (0.002, 0.1, 0.85);
let mut g = Garch11::new(omega, alpha, beta).unwrap();
let out = g.batch(&[100.0, 110.0, 99.0]);
let uncond = omega / (1.0 - alpha - beta);
let r1 = (110.0_f64 / 100.0).ln();
assert_relative_eq!(out[1].unwrap(), uncond.sqrt(), epsilon = 1e-12);
let var2 = omega + alpha * r1 * r1 + beta * uncond;
assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
}
#[test]
fn flat_series_converges_to_long_run() {
// With zero returns the alpha term vanishes; the variance mean-reverts
// to the fixed point ω / (1 β), NOT to zero (the key GARCH/EWMA
// distinction).
let (omega, beta) = (0.002, 0.85);
let mut g = Garch11::new(omega, 0.10, beta).unwrap();
let out = g.batch(&[100.0; 400]);
let fixed_point = (omega / (1.0 - beta)).sqrt();
assert_relative_eq!(out.last().unwrap().unwrap(), fixed_point, epsilon = 1e-9);
}
#[test]
fn output_is_strictly_positive() {
let mut g = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in g.batch(&prices).into_iter().flatten() {
assert!(
v > 0.0,
"GARCH volatility must be strictly positive, got {v}"
);
}
}
#[test]
fn ignores_non_finite_input() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
let out = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(g.update(f64::NAN), last);
assert_eq!(g.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
let warmup = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(g.update(-5.0), Some(baseline));
assert_eq!(g.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = g.clone();
let after = g.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after);
}
#[test]
fn skips_non_positive_before_first_price() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
assert_eq!(g.update(0.0), None);
assert_eq!(g.update(f64::NAN), None);
assert_eq!(g.update(100.0), None);
assert!(g.update(110.0).is_some());
}
#[test]
fn reset_clears_state() {
let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(g.is_ready());
g.reset();
assert!(!g.is_ready());
assert_eq!(g.value(), None);
assert_eq!(g.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = Garch11::new(0.000_002, 0.1, 0.88).unwrap().batch(&prices);
let mut b = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+19 -1
View File
@@ -48,6 +48,7 @@ mod bat;
mod belt_hold;
mod beta;
mod beta_neutral_spread;
mod bipower_variation;
mod body_size_pct;
mod bollinger;
mod bollinger_bandwidth;
@@ -118,6 +119,7 @@ mod empirical_mode_decomposition;
mod engulfing;
mod evening_doji_star;
mod evwma;
mod ewma_volatility;
mod expectancy;
mod falling_three_methods;
mod fama;
@@ -143,6 +145,7 @@ mod funding_rate_mean;
mod funding_rate_zscore;
mod gain_loss_ratio;
mod gap_side_by_side_white;
mod garch11;
mod garman_klass;
mod gartley;
mod gator_oscillator;
@@ -404,6 +407,9 @@ mod variance;
mod variance_ratio;
mod vertical_horizontal_filter;
mod vidya;
mod volatility_cone;
mod volatility_of_volatility;
mod volatility_ratio;
mod volty_stop;
mod volume_by_time_profile;
mod volume_oscillator;
@@ -471,6 +477,7 @@ pub use bat::Bat;
pub use belt_hold::BeltHold;
pub use beta::Beta;
pub use beta_neutral_spread::BetaNeutralSpread;
pub use bipower_variation::BipowerVariation;
pub use body_size_pct::BodySizePct;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
@@ -541,6 +548,7 @@ pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use engulfing::Engulfing;
pub use evening_doji_star::EveningDojiStar;
pub use evwma::Evwma;
pub use ewma_volatility::EwmaVolatility;
pub use expectancy::Expectancy;
pub use falling_three_methods::FallingThreeMethods;
pub use fama::Fama;
@@ -566,6 +574,7 @@ pub use funding_rate_mean::FundingRateMean;
pub use funding_rate_zscore::FundingRateZScore;
pub use gain_loss_ratio::GainLossRatio;
pub use gap_side_by_side_white::GapSideBySideWhite;
pub use garch11::Garch11;
pub use garman_klass::GarmanKlassVolatility;
pub use gartley::Gartley;
pub use gator_oscillator::{GatorOscillator, GatorOscillatorOutput};
@@ -827,6 +836,9 @@ pub use variance::Variance;
pub use variance_ratio::VarianceRatio;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vidya::Vidya;
pub use volatility_cone::{VolatilityCone, VolatilityConeOutput};
pub use volatility_of_volatility::VolatilityOfVolatility;
pub use volatility_ratio::VolatilityRatio;
pub use volty_stop::VoltyStop;
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
pub use volume_oscillator::VolumeOscillator;
@@ -1006,6 +1018,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"YangZhangVolatility",
"JumpIndicator",
"RegimeLabel",
"EwmaVolatility",
"Garch11",
"VolatilityOfVolatility",
"BipowerVariation",
"VolatilityRatio",
"VolatilityCone",
],
),
(
@@ -1423,6 +1441,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 423, "FAMILIES total drifted from indicator count");
assert_eq!(total, 429, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,381 @@
//! Volatility Cone — current realized volatility within its historical envelope.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`VolatilityCone`]: the current realized volatility together with
/// the envelope (the "cone") it sits inside over the lookback window.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VolatilityConeOutput {
/// Latest realized volatility (sample stddev of log returns over `window`).
pub current: f64,
/// Lowest realized volatility seen over the `lookback` window.
pub min: f64,
/// Median realized volatility over the `lookback` window.
pub median: f64,
/// Highest realized volatility seen over the `lookback` window.
pub max: f64,
/// Percentile rank of `current` within the lookback distribution, in
/// `[0, 100]` — the share of stored volatilities `<= current`, times 100.
pub percentile: f64,
}
/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
let n = count as f64;
let mean = sum / n;
let variance = ((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
variance.sqrt()
}
/// Volatility Cone — the current realized volatility positioned within the
/// historical range ("cone") of realized volatilities over a lookback window.
///
/// ```text
/// r_t = ln(close_t / close_{t1})
/// vol_t = stddev_sample(r over window) (rolling realized volatility)
/// cone = { min, median, max, percentile } of vol over the last `lookback`
/// ```
///
/// A volatility cone (Burghardt & Lane 1990) shows whether current volatility is
/// high or low *relative to its own history*, rather than as an absolute number.
/// This streaming form tracks one horizon: it maintains the rolling realized
/// volatility of log returns over `window`, then reports the latest reading
/// (`current`) alongside the `min`, `median`, `max` and percentile rank of that
/// volatility series over the trailing `lookback`. `current` always lies within
/// `[min, max]` because it is itself the newest member of the lookback set.
///
/// Only the candle's **close** is used (the log-return series); the high and low
/// are ignored. The volatility is per-period (sample stddev of log returns, not
/// annualised) — multiply by `√trading_periods` for an annual figure. Each
/// `update` is O(`lookback log lookback`) from sorting the envelope.
///
/// Non-positive closes are ignored (the log return would be undefined): the tick
/// is dropped, state is left untouched, and the last value is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolatilityCone};
///
/// let mut indicator = VolatilityCone::new(20, 60).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// let c = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
/// let candle = Candle::new(c, c + 1.0, c - 1.0, c, 1_000.0, 0).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolatilityCone {
window: usize,
lookback: usize,
prev_close: Option<f64>,
/// Rolling window of log returns for the inner realized-volatility series.
returns: VecDeque<f64>,
ret_sum: f64,
ret_sum_sq: f64,
/// Rolling window of realized-volatility readings (the cone envelope).
vols: VecDeque<f64>,
last: Option<VolatilityConeOutput>,
}
impl VolatilityCone {
/// Construct a new volatility-cone indicator.
///
/// `window` is the realized-volatility estimation window; `lookback` is the
/// number of volatility readings forming the historical cone.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if either argument is `0`, or
/// [`Error::InvalidPeriod`] if `window < 2` (a sample stddev needs two
/// returns) or `lookback < 2` (an envelope needs at least two readings).
pub fn new(window: usize, lookback: usize) -> Result<Self> {
if window == 0 || lookback == 0 {
return Err(Error::PeriodZero);
}
if window < 2 || lookback < 2 {
return Err(Error::InvalidPeriod {
message: "volatility cone window and lookback must both be >= 2",
});
}
Ok(Self {
window,
lookback,
prev_close: None,
returns: VecDeque::with_capacity(window),
ret_sum: 0.0,
ret_sum_sq: 0.0,
vols: VecDeque::with_capacity(lookback),
last: None,
})
}
/// Configured `(window, lookback)`.
pub const fn windows(&self) -> (usize, usize) {
(self.window, self.lookback)
}
/// Current value if available.
pub const fn value(&self) -> Option<VolatilityConeOutput> {
self.last
}
}
impl Indicator for VolatilityCone {
type Input = Candle;
type Output = VolatilityConeOutput;
fn update(&mut self, candle: Candle) -> Option<VolatilityConeOutput> {
let price = candle.close;
// A log return is undefined for a non-positive close; skip the tick.
if price <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_close else {
self.prev_close = Some(price);
return None;
};
self.prev_close = Some(price);
// `prev` came from `self.prev_close`, gated by the guard above, so it is
// positive — the log return is always well-defined.
let r = (price / prev).ln();
// Stage one: rolling sample volatility of log returns.
if self.returns.len() == self.window {
let old = self.returns.pop_front().expect("returns window non-empty");
self.ret_sum -= old;
self.ret_sum_sq -= old * old;
}
self.returns.push_back(r);
self.ret_sum += r;
self.ret_sum_sq += r * r;
if self.returns.len() < self.window {
return None;
}
let current = sample_stddev(self.ret_sum, self.ret_sum_sq, self.window);
// Stage two: maintain the lookback envelope of volatility readings.
if self.vols.len() == self.lookback {
self.vols.pop_front();
}
self.vols.push_back(current);
if self.vols.len() < self.lookback {
return None;
}
let mut sorted: Vec<f64> = self.vols.iter().copied().collect();
sorted.sort_by(f64::total_cmp);
let min = sorted[0];
let max = sorted[self.lookback - 1];
let mid = self.lookback / 2;
let median = if self.lookback % 2 == 1 {
sorted[mid]
} else {
f64::midpoint(sorted[mid - 1], sorted[mid])
};
let count_le = self.vols.iter().filter(|&&v| v <= current).count();
let percentile = count_le as f64 / self.lookback as f64 * 100.0;
let out = VolatilityConeOutput {
current,
min,
median,
max,
percentile,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.prev_close = None;
self.returns.clear();
self.ret_sum = 0.0;
self.ret_sum_sq = 0.0;
self.vols.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
// One previous close for the first return, `window` returns for the
// first volatility, then `lookback` volatilities for the envelope.
self.window + self.lookback
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolatilityCone"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle whose close drives the indicator (open = high = low = close here).
fn close_candle(close: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
}
#[test]
fn rejects_zero_window() {
assert!(matches!(VolatilityCone::new(0, 10), Err(Error::PeriodZero)));
assert!(matches!(VolatilityCone::new(10, 0), Err(Error::PeriodZero)));
}
#[test]
fn rejects_window_one() {
assert!(matches!(
VolatilityCone::new(1, 10),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
VolatilityCone::new(10, 1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let vc = VolatilityCone::new(20, 60).unwrap();
assert_eq!(vc.windows(), (20, 60));
assert_eq!(vc.warmup_period(), 80);
assert_eq!(vc.name(), "VolatilityCone");
assert!(!vc.is_ready());
assert_eq!(vc.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut vc = VolatilityCone::new(2, 2).unwrap();
let prices = [100.0, 110.0, 121.0, 100.0, 105.0, 99.0];
let candles: Vec<Candle> = prices.iter().map(|p| close_candle(*p)).collect();
let out = vc.batch(&candles);
let warmup = vc.warmup_period(); // 4
assert_eq!(warmup, 4);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn known_value() {
// window = 2 -> vol = |r_t r_{t1}| / √2; lookback = 2.
// prices: r1 = r2 = ln(1.1), r3 = ln(100/121).
let mut vc = VolatilityCone::new(2, 2).unwrap();
let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0]
.iter()
.map(|p| close_candle(*p))
.collect();
let out = vc.batch(&candles);
let r2 = (121.0_f64 / 110.0).ln();
let r3 = (100.0_f64 / 121.0).ln();
let vol2 = (r2 - r3).abs() / 2.0_f64.sqrt();
let o = out[3].unwrap();
assert_relative_eq!(o.current, vol2, epsilon = 1e-9);
assert_relative_eq!(o.min, 0.0, epsilon = 1e-9); // vol1 = 0 (r1 == r2)
assert_relative_eq!(o.max, vol2, epsilon = 1e-9);
assert_relative_eq!(o.median, vol2 / 2.0, epsilon = 1e-9);
assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-9);
}
#[test]
fn odd_lookback_median_is_middle() {
// lookback = 3 picks the middle of the sorted envelope.
let mut vc = VolatilityCone::new(2, 3).unwrap();
let candles: Vec<Candle> = [100.0, 101.0, 103.0, 100.0, 104.0, 99.0, 106.0]
.iter()
.map(|p| close_candle(*p))
.collect();
let out = vc.batch(&candles);
let o = out.last().unwrap().unwrap();
assert!(o.min <= o.median && o.median <= o.max);
}
#[test]
fn envelope_brackets_current() {
let mut vc = VolatilityCone::new(10, 30).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| close_candle(100.0 + (f64::from(i) * 0.3).sin() * 12.0))
.collect();
for o in vc.batch(&candles).into_iter().flatten() {
assert!(o.min <= o.current && o.current <= o.max);
assert!(o.min <= o.median && o.median <= o.max);
assert!(o.percentile > 0.0 && o.percentile <= 100.0);
}
}
#[test]
fn constant_series_yields_zero_cone() {
let mut vc = VolatilityCone::new(5, 5).unwrap();
let candles: Vec<Candle> = (0..40).map(|_| close_candle(100.0)).collect();
for o in vc.batch(&candles).into_iter().flatten() {
assert_relative_eq!(o.current, 0.0, epsilon = 1e-12);
assert_relative_eq!(o.min, 0.0, epsilon = 1e-12);
assert_relative_eq!(o.max, 0.0, epsilon = 1e-12);
assert_relative_eq!(o.median, 0.0, epsilon = 1e-12);
assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-12);
}
}
#[test]
fn skips_non_positive_close() {
let mut vc = VolatilityCone::new(2, 2).unwrap();
let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0]
.iter()
.map(|p| close_candle(*p))
.collect();
let warmup = vc.batch(&candles);
let baseline = warmup.last().copied().flatten().expect("warmed up");
// A non-positive close is skipped and the previous value is returned.
assert_eq!(vc.update(close_candle(0.0)), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = vc.clone();
let after = vc.update(close_candle(105.0)).expect("ready");
assert_eq!(control.update(close_candle(105.0)).expect("ready"), after);
}
#[test]
fn skips_non_positive_before_first_close() {
let mut vc = VolatilityCone::new(2, 2).unwrap();
assert_eq!(vc.update(close_candle(0.0)), None);
assert_eq!(vc.update(close_candle(100.0)), None);
}
#[test]
fn reset_clears_state() {
let mut vc = VolatilityCone::new(2, 2).unwrap();
let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0, 105.0]
.iter()
.map(|p| close_candle(*p))
.collect();
vc.batch(&candles);
assert!(vc.is_ready());
vc.reset();
assert!(!vc.is_ready());
assert_eq!(vc.value(), None);
assert_eq!(vc.update(close_candle(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..200)
.map(|i| close_candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
.collect();
let batch = VolatilityCone::new(10, 30).unwrap().batch(&candles);
let mut b = VolatilityCone::new(10, 30).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,333 @@
//! Volatility of Volatility — the dispersion of a rolling volatility series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
///
/// Uses Bessel's correction (divisor `n 1`) and clamps a tiny negative
/// floating-point residual to zero before the square root.
fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
let n = count as f64;
let mean = sum / n;
let variance = ((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
variance.sqrt()
}
/// Volatility of Volatility — the standard deviation of a rolling realized-
/// volatility series ("vol-of-vol").
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// vol_t = stddev_sample(r over vol_window) (rolling realized volatility)
/// VoV = stddev_sample(vol over vov_window) (dispersion of that series)
/// ```
///
/// This is a two-stage estimator: the first stage measures the rolling sample
/// volatility of log returns (the same quantity
/// [`HistoricalVolatility`](crate::HistoricalVolatility) annualises), and the
/// second stage measures how much *that* volatility itself moves. A high
/// vol-of-vol means the volatility regime is unstable — turbulent periods
/// alternate with calm ones — which is exactly the convexity that long-gamma and
/// volatility-trading strategies care about. Both stages use the unbiased
/// `n 1` sample standard deviation. Each `update` is O(1).
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last value
/// is returned.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, VolatilityOfVolatility};
///
/// let mut indicator = VolatilityOfVolatility::new(20, 20).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolatilityOfVolatility {
vol_window: usize,
vov_window: usize,
prev_price: Option<f64>,
/// Rolling window of log returns (stage one).
returns: VecDeque<f64>,
ret_sum: f64,
ret_sum_sq: f64,
/// Rolling window of realized-volatility readings (stage two).
vols: VecDeque<f64>,
vol_sum: f64,
vol_sum_sq: f64,
last: Option<f64>,
}
impl VolatilityOfVolatility {
/// Construct a new vol-of-vol indicator.
///
/// `vol_window` is the window for the inner realized-volatility series;
/// `vov_window` is the window over which its dispersion is measured.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if either window is `0`, or
/// [`Error::InvalidPeriod`] if either is `1` (a sample standard deviation
/// needs at least two observations).
pub fn new(vol_window: usize, vov_window: usize) -> Result<Self> {
if vol_window == 0 || vov_window == 0 {
return Err(Error::PeriodZero);
}
if vol_window < 2 || vov_window < 2 {
return Err(Error::InvalidPeriod {
message: "vol-of-vol windows must both be >= 2",
});
}
Ok(Self {
vol_window,
vov_window,
prev_price: None,
returns: VecDeque::with_capacity(vol_window),
ret_sum: 0.0,
ret_sum_sq: 0.0,
vols: VecDeque::with_capacity(vov_window),
vol_sum: 0.0,
vol_sum_sq: 0.0,
last: None,
})
}
/// Configured `(vol_window, vov_window)`.
pub const fn windows(&self) -> (usize, usize) {
(self.vol_window, self.vov_window)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for VolatilityOfVolatility {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the return window.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
// Stage one: rolling sample volatility of log returns.
if self.returns.len() == self.vol_window {
let old = self.returns.pop_front().expect("returns window non-empty");
self.ret_sum -= old;
self.ret_sum_sq -= old * old;
}
self.returns.push_back(r);
self.ret_sum += r;
self.ret_sum_sq += r * r;
if self.returns.len() < self.vol_window {
return None;
}
let vol = sample_stddev(self.ret_sum, self.ret_sum_sq, self.vol_window);
// Stage two: rolling sample dispersion of the volatility series.
if self.vols.len() == self.vov_window {
let old = self.vols.pop_front().expect("vols window non-empty");
self.vol_sum -= old;
self.vol_sum_sq -= old * old;
}
self.vols.push_back(vol);
self.vol_sum += vol;
self.vol_sum_sq += vol * vol;
if self.vols.len() < self.vov_window {
return None;
}
let vov = sample_stddev(self.vol_sum, self.vol_sum_sq, self.vov_window);
self.last = Some(vov);
Some(vov)
}
fn reset(&mut self) {
self.prev_price = None;
self.returns.clear();
self.ret_sum = 0.0;
self.ret_sum_sq = 0.0;
self.vols.clear();
self.vol_sum = 0.0;
self.vol_sum_sq = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// One previous price for the first return, `vol_window` returns for the
// first volatility, then `vov_window` volatilities for the dispersion.
// The two windows overlap on the bar axis, so this is the sum.
self.vol_window + self.vov_window
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolatilityOfVolatility"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use crate::HistoricalVolatility;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_window() {
assert!(matches!(
VolatilityOfVolatility::new(0, 10),
Err(Error::PeriodZero)
));
assert!(matches!(
VolatilityOfVolatility::new(10, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_window_one() {
assert!(matches!(
VolatilityOfVolatility::new(1, 10),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
VolatilityOfVolatility::new(10, 1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let vov = VolatilityOfVolatility::new(20, 10).unwrap();
assert_eq!(vov.windows(), (20, 10));
assert_eq!(vov.warmup_period(), 30);
assert_eq!(vov.name(), "VolatilityOfVolatility");
assert!(!vov.is_ready());
assert_eq!(vov.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
let prices: Vec<f64> = (1..=20)
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 4.0)
.collect();
let out = vov.batch(&prices);
let warmup = vov.warmup_period(); // 6
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn matches_two_stage_reference() {
// Stage one equals HistoricalVolatility(vol_window, 1) / 100 (sample
// stddev of log returns); stage two is the sample stddev of that series.
let (vol_window, vov_window) = (3, 3);
let prices: Vec<f64> = [100.0, 102.0, 101.0, 104.0, 103.5, 106.0, 105.0, 108.0].to_vec();
let mut hv = HistoricalVolatility::new(vol_window, 1).unwrap();
let vol_series: Vec<f64> = hv
.batch(&prices)
.into_iter()
.flatten()
.map(|v| v / 100.0)
.collect();
// Sample stddev of the last `vov_window` volatilities.
let tail = &vol_series[vol_series.len() - vov_window..];
let sum: f64 = tail.iter().sum();
let sum_sq: f64 = tail.iter().map(|v| v * v).sum();
let expected = sample_stddev(sum, sum_sq, vov_window);
let mut vov = VolatilityOfVolatility::new(vol_window, vov_window).unwrap();
let out = vov.batch(&prices);
assert_relative_eq!(out.last().unwrap().unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn constant_series_yields_zero() {
let mut vov = VolatilityOfVolatility::new(5, 5).unwrap();
for v in vov.batch(&[100.0; 60]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut vov = VolatilityOfVolatility::new(10, 10).unwrap();
let prices: Vec<f64> = (1..=300)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in vov.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "vol-of-vol must be non-negative, got {v}");
}
}
#[test]
fn ignores_non_finite_input() {
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
let out = vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(vov.update(f64::NAN), last);
assert_eq!(vov.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
let warmup = vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(vov.update(-5.0), Some(baseline));
assert_eq!(vov.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = vov.clone();
let after = vov.update(41.0).expect("ready");
assert_eq!(control.update(41.0).expect("ready"), after);
}
#[test]
fn reset_clears_state() {
let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(vov.is_ready());
vov.reset();
assert!(!vov.is_ready());
assert_eq!(vov.value(), None);
assert_eq!(vov.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = VolatilityOfVolatility::new(10, 10).unwrap().batch(&prices);
let mut b = VolatilityOfVolatility::new(10, 10).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,285 @@
//! Schwager's Volatility Ratio — today's true range versus its typical level.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Schwager's Volatility Ratio — the current bar's true range divided by the
/// exponential moving average of the *prior* true ranges.
///
/// ```text
/// TR_t = true range of bar t
/// VR_t = TR_t / EMA_n(TR through bar t1)
/// ```
///
/// Jack Schwager's volatility ratio measures how today's range compares to its
/// recent typical level: a reading above `2.0` marks a **wide-ranging day** —
/// today's true range is more than twice the smoothed average — which often
/// precedes or accompanies a reversal. The denominator is the exponential
/// moving average of true range *excluding the current bar*, seeded with the
/// simple average of the first `period` true ranges, so a single large bar
/// stands out instead of inflating its own benchmark.
///
/// True range is `max(high low, |high prev_close|, |low prev_close|)`,
/// identical to the [`Atr`](crate::Atr) building block, but here it is compared
/// to a *standard* EMA (smoothing `2 / (period + 1)`) rather than Wilder
/// smoothing, which keeps the ratio distinct from `TR / ATR`. Each `update` is
/// O(1).
///
/// A flat market drives every true range — and the EMA — to `0`; the ratio is
/// then `0.0` rather than an undefined `0 / 0`. `Candle::new` rejects non-finite
/// fields, so no in-method finiteness guard is needed.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolatilityRatio};
///
/// let mut indicator = VolatilityRatio::new(14).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 - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolatilityRatio {
period: usize,
alpha: f64,
prev_close: Option<f64>,
/// Sum and count of the first `period` true ranges, used to seed the EMA.
seed_sum: f64,
seed_count: usize,
/// EMA of true range through the previous bar; `None` until seeded.
ema: Option<f64>,
last: Option<f64>,
}
impl VolatilityRatio {
/// Construct a new volatility-ratio indicator.
///
/// `period` is the number of true ranges that seed and smooth the
/// denominator EMA.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
alpha: 2.0 / (period as f64 + 1.0),
prev_close: None,
seed_sum: 0.0,
seed_count: 0,
ema: None,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for VolatilityRatio {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// The first bar has no previous close, so no true range can be formed.
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let tr = candle.true_range(Some(prev_close));
self.prev_close = Some(candle.close);
match self.ema {
None => {
// Seeding the EMA with the simple average of the first `period`
// true ranges; emit nothing until it is established.
self.seed_sum += tr;
self.seed_count += 1;
if self.seed_count == self.period {
self.ema = Some(self.seed_sum / self.period as f64);
}
None
}
Some(prev_ema) => {
// Denominator excludes the current bar (it is the EMA through the
// previous bar). A flat benchmark yields 0.0, not 0/0.
let vr = if prev_ema > 0.0 { tr / prev_ema } else { 0.0 };
self.ema = Some(self.alpha * tr + (1.0 - self.alpha) * prev_ema);
self.last = Some(vr);
Some(vr)
}
}
}
fn reset(&mut self) {
self.prev_close = None;
self.seed_sum = 0.0;
self.seed_count = 0;
self.ema = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
// Bar 1 sets the previous close; bars 2..=period+1 seed the EMA; the
// first ratio is emitted on bar period + 2.
self.period + 2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolatilityRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Build a candle with the given high/low/close (open = low, fixed volume).
fn candle(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(VolatilityRatio::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let vr = VolatilityRatio::new(14).unwrap();
assert_eq!(vr.period(), 14);
assert_eq!(vr.warmup_period(), 16);
assert_eq!(vr.name(), "VolatilityRatio");
assert!(!vr.is_ready());
assert_eq!(vr.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut vr = VolatilityRatio::new(3).unwrap();
// Build enough constant-range candles to reach warmup.
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
let out = vr.batch(&candles);
// warmup_period == period + 2 == 5: the first emission is at index 4.
let warmup = vr.warmup_period();
assert_eq!(warmup, 5);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn wide_ranging_day_exceeds_two() {
// Steady true range of 2.0 seeds the EMA, then one bar with a far wider
// range pushes the ratio above 2.0.
let mut vr = VolatilityRatio::new(3).unwrap();
let mut candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base) // TR = 2.0 each
})
.collect();
// A wide bar: range 10 around the last close (~105).
candles.push(candle(110.0, 100.0, 105.0));
let out = vr.batch(&candles);
let last = out.last().unwrap().unwrap();
assert!(last > 2.0, "wide-ranging day should exceed 2.0, got {last}");
}
#[test]
fn steady_range_ratio_is_one() {
// Constant true range -> EMA equals it -> ratio is exactly 1.0.
let mut vr = VolatilityRatio::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base) // TR = 2.0 each
})
.collect();
let out = vr.batch(&candles);
assert_relative_eq!(out.last().unwrap().unwrap(), 1.0, epsilon = 1e-9);
}
#[test]
fn flat_market_yields_zero() {
// Zero-range candles: TR = 0, EMA = 0, ratio guarded to 0.0.
let mut vr = VolatilityRatio::new(3).unwrap();
let candles: Vec<Candle> = (0..10).map(|_| candle(100.0, 100.0, 100.0)).collect();
let out = vr.batch(&candles);
for v in out.into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut vr = VolatilityRatio::new(14).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
candle(base + 2.0, base - 2.0, base + 0.5)
})
.collect();
for v in vr.batch(&candles).into_iter().flatten() {
assert!(v >= 0.0, "volatility ratio must be non-negative, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut vr = VolatilityRatio::new(3).unwrap();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
vr.batch(&candles);
assert!(vr.is_ready());
vr.reset();
assert!(!vr.is_ready());
assert_eq!(vr.value(), None);
assert_eq!(vr.update(candle(101.0, 99.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
candle(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = VolatilityRatio::new(14).unwrap().batch(&candles);
let mut b = VolatilityRatio::new(14).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}