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:
@@ -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_{t−1})
|
||||
/// BV = (π / 2) · Σ |r_t| · |r_{t−1}| 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_{t−1})
|
||||
/// σ²_t = λ · σ²_{t−1} + (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_{t−1})
|
||||
/// σ²_t = ω + α · r²_{t−1} + β · σ²_{t−1}
|
||||
/// 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>,
|
||||
/// `(σ²_{t−1}, r²_{t−1})` — 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);
|
||||
}
|
||||
}
|
||||
@@ -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_{t−1})
|
||||
/// 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_{t−1}| / √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_{t−1})
|
||||
/// 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 t−1)
|
||||
/// ```
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user