Skip to main content

wickra_core/indicators/
volatility_of_volatility.rs

1//! Volatility of Volatility — the dispersion of a rolling volatility series.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
9///
10/// Uses Bessel's correction (divisor `n − 1`) and clamps a tiny negative
11/// floating-point residual to zero before the square root.
12fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
13    let n = count as f64;
14    let mean = sum / n;
15    let variance = ((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
16    variance.sqrt()
17}
18
19/// Volatility of Volatility — the standard deviation of a rolling realized-
20/// volatility series ("vol-of-vol").
21///
22/// ```text
23/// r_t   = ln(price_t / price_{t−1})
24/// vol_t = stddev_sample(r over vol_window)          (rolling realized volatility)
25/// VoV   = stddev_sample(vol over vov_window)         (dispersion of that series)
26/// ```
27///
28/// This is a two-stage estimator: the first stage measures the rolling sample
29/// volatility of log returns (the same quantity
30/// [`HistoricalVolatility`](crate::HistoricalVolatility) annualises), and the
31/// second stage measures how much *that* volatility itself moves. A high
32/// vol-of-vol means the volatility regime is unstable — turbulent periods
33/// alternate with calm ones — which is exactly the convexity that long-gamma and
34/// volatility-trading strategies care about. Both stages use the unbiased
35/// `n − 1` sample standard deviation. Each `update` is O(1).
36///
37/// Non-finite and non-positive prices are ignored (the log return would be
38/// undefined): the tick is dropped, state is left untouched, and the last value
39/// is returned.
40///
41/// # Example
42///
43/// ```
44/// use wickra_core::{Indicator, VolatilityOfVolatility};
45///
46/// let mut indicator = VolatilityOfVolatility::new(20, 20).unwrap();
47/// let mut last = None;
48/// for i in 0..120 {
49///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
50/// }
51/// assert!(last.is_some());
52/// ```
53#[derive(Debug, Clone)]
54pub struct VolatilityOfVolatility {
55    vol_window: usize,
56    vov_window: usize,
57    prev_price: Option<f64>,
58    /// Rolling window of log returns (stage one).
59    returns: VecDeque<f64>,
60    ret_sum: f64,
61    ret_sum_sq: f64,
62    /// Rolling window of realized-volatility readings (stage two).
63    vols: VecDeque<f64>,
64    vol_sum: f64,
65    vol_sum_sq: f64,
66    last: Option<f64>,
67}
68
69impl VolatilityOfVolatility {
70    /// Construct a new vol-of-vol indicator.
71    ///
72    /// `vol_window` is the window for the inner realized-volatility series;
73    /// `vov_window` is the window over which its dispersion is measured.
74    ///
75    /// # Errors
76    /// Returns [`Error::PeriodZero`] if either window is `0`, or
77    /// [`Error::InvalidPeriod`] if either is `1` (a sample standard deviation
78    /// needs at least two observations).
79    pub fn new(vol_window: usize, vov_window: usize) -> Result<Self> {
80        if vol_window == 0 || vov_window == 0 {
81            return Err(Error::PeriodZero);
82        }
83        if vol_window < 2 || vov_window < 2 {
84            return Err(Error::InvalidPeriod {
85                message: "vol-of-vol windows must both be >= 2",
86            });
87        }
88        Ok(Self {
89            vol_window,
90            vov_window,
91            prev_price: None,
92            returns: VecDeque::with_capacity(vol_window),
93            ret_sum: 0.0,
94            ret_sum_sq: 0.0,
95            vols: VecDeque::with_capacity(vov_window),
96            vol_sum: 0.0,
97            vol_sum_sq: 0.0,
98            last: None,
99        })
100    }
101
102    /// Configured `(vol_window, vov_window)`.
103    pub const fn windows(&self) -> (usize, usize) {
104        (self.vol_window, self.vov_window)
105    }
106
107    /// Current value if available.
108    pub const fn value(&self) -> Option<f64> {
109        self.last
110    }
111}
112
113impl Indicator for VolatilityOfVolatility {
114    type Input = f64;
115    type Output = f64;
116
117    fn update(&mut self, input: f64) -> Option<f64> {
118        // Non-finite / non-positive prices are skipped: `ln(input / prev)` is
119        // undefined, so the tick must not enter the return window.
120        if !input.is_finite() || input <= 0.0 {
121            return self.last;
122        }
123        let Some(prev) = self.prev_price else {
124            self.prev_price = Some(input);
125            return None;
126        };
127        self.prev_price = Some(input);
128        // `prev` came from `self.prev_price`, gated by the guard above, so it is
129        // finite and positive — the log return is always well-defined.
130        let r = (input / prev).ln();
131
132        // Stage one: rolling sample volatility of log returns.
133        if self.returns.len() == self.vol_window {
134            let old = self.returns.pop_front().expect("returns window non-empty");
135            self.ret_sum -= old;
136            self.ret_sum_sq -= old * old;
137        }
138        self.returns.push_back(r);
139        self.ret_sum += r;
140        self.ret_sum_sq += r * r;
141        if self.returns.len() < self.vol_window {
142            return None;
143        }
144        let vol = sample_stddev(self.ret_sum, self.ret_sum_sq, self.vol_window);
145
146        // Stage two: rolling sample dispersion of the volatility series.
147        if self.vols.len() == self.vov_window {
148            let old = self.vols.pop_front().expect("vols window non-empty");
149            self.vol_sum -= old;
150            self.vol_sum_sq -= old * old;
151        }
152        self.vols.push_back(vol);
153        self.vol_sum += vol;
154        self.vol_sum_sq += vol * vol;
155        if self.vols.len() < self.vov_window {
156            return None;
157        }
158        let vov = sample_stddev(self.vol_sum, self.vol_sum_sq, self.vov_window);
159        self.last = Some(vov);
160        Some(vov)
161    }
162
163    fn reset(&mut self) {
164        self.prev_price = None;
165        self.returns.clear();
166        self.ret_sum = 0.0;
167        self.ret_sum_sq = 0.0;
168        self.vols.clear();
169        self.vol_sum = 0.0;
170        self.vol_sum_sq = 0.0;
171        self.last = None;
172    }
173
174    fn warmup_period(&self) -> usize {
175        // One previous price for the first return, `vol_window` returns for the
176        // first volatility, then `vov_window` volatilities for the dispersion.
177        // The two windows overlap on the bar axis, so this is the sum.
178        self.vol_window + self.vov_window
179    }
180
181    fn is_ready(&self) -> bool {
182        self.last.is_some()
183    }
184
185    fn name(&self) -> &'static str {
186        "VolatilityOfVolatility"
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::traits::BatchExt;
194    use crate::HistoricalVolatility;
195    use approx::assert_relative_eq;
196
197    #[test]
198    fn rejects_zero_window() {
199        assert!(matches!(
200            VolatilityOfVolatility::new(0, 10),
201            Err(Error::PeriodZero)
202        ));
203        assert!(matches!(
204            VolatilityOfVolatility::new(10, 0),
205            Err(Error::PeriodZero)
206        ));
207    }
208
209    #[test]
210    fn rejects_window_one() {
211        assert!(matches!(
212            VolatilityOfVolatility::new(1, 10),
213            Err(Error::InvalidPeriod { .. })
214        ));
215        assert!(matches!(
216            VolatilityOfVolatility::new(10, 1),
217            Err(Error::InvalidPeriod { .. })
218        ));
219    }
220
221    #[test]
222    fn accessors_and_metadata() {
223        let vov = VolatilityOfVolatility::new(20, 10).unwrap();
224        assert_eq!(vov.windows(), (20, 10));
225        assert_eq!(vov.warmup_period(), 30);
226        assert_eq!(vov.name(), "VolatilityOfVolatility");
227        assert!(!vov.is_ready());
228        assert_eq!(vov.value(), None);
229    }
230
231    #[test]
232    fn first_emission_at_warmup_period() {
233        let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
234        let prices: Vec<f64> = (1..=20)
235            .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 4.0)
236            .collect();
237        let out = vov.batch(&prices);
238        let warmup = vov.warmup_period(); // 6
239        for v in out.iter().take(warmup - 1) {
240            assert!(v.is_none());
241        }
242        assert!(out[warmup - 1].is_some());
243    }
244
245    #[test]
246    fn matches_two_stage_reference() {
247        // Stage one equals HistoricalVolatility(vol_window, 1) / 100 (sample
248        // stddev of log returns); stage two is the sample stddev of that series.
249        let (vol_window, vov_window) = (3, 3);
250        let prices: Vec<f64> = [100.0, 102.0, 101.0, 104.0, 103.5, 106.0, 105.0, 108.0].to_vec();
251
252        let mut hv = HistoricalVolatility::new(vol_window, 1).unwrap();
253        let vol_series: Vec<f64> = hv
254            .batch(&prices)
255            .into_iter()
256            .flatten()
257            .map(|v| v / 100.0)
258            .collect();
259        // Sample stddev of the last `vov_window` volatilities.
260        let tail = &vol_series[vol_series.len() - vov_window..];
261        let sum: f64 = tail.iter().sum();
262        let sum_sq: f64 = tail.iter().map(|v| v * v).sum();
263        let expected = sample_stddev(sum, sum_sq, vov_window);
264
265        let mut vov = VolatilityOfVolatility::new(vol_window, vov_window).unwrap();
266        let out = vov.batch(&prices);
267        assert_relative_eq!(out.last().unwrap().unwrap(), expected, epsilon = 1e-9);
268    }
269
270    #[test]
271    fn constant_series_yields_zero() {
272        let mut vov = VolatilityOfVolatility::new(5, 5).unwrap();
273        for v in vov.batch(&[100.0; 60]).into_iter().flatten() {
274            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
275        }
276    }
277
278    #[test]
279    fn output_is_non_negative() {
280        let mut vov = VolatilityOfVolatility::new(10, 10).unwrap();
281        let prices: Vec<f64> = (1..=300)
282            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
283            .collect();
284        for v in vov.batch(&prices).into_iter().flatten() {
285            assert!(v >= 0.0, "vol-of-vol must be non-negative, got {v}");
286        }
287    }
288
289    #[test]
290    fn ignores_non_finite_input() {
291        let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
292        let out = vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
293        let last = *out.last().unwrap();
294        assert!(last.is_some());
295        assert_eq!(vov.update(f64::NAN), last);
296        assert_eq!(vov.update(f64::INFINITY), last);
297    }
298
299    #[test]
300    fn skips_non_positive_prices() {
301        let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
302        let warmup = vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
303        let baseline = warmup.last().copied().flatten().expect("warmed up");
304        assert_eq!(vov.update(-5.0), Some(baseline));
305        assert_eq!(vov.update(0.0), Some(baseline));
306        // State untouched: a clone advanced by the same real tick agrees.
307        let mut control = vov.clone();
308        let after = vov.update(41.0).expect("ready");
309        assert_eq!(control.update(41.0).expect("ready"), after);
310    }
311
312    #[test]
313    fn reset_clears_state() {
314        let mut vov = VolatilityOfVolatility::new(3, 3).unwrap();
315        vov.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
316        assert!(vov.is_ready());
317        vov.reset();
318        assert!(!vov.is_ready());
319        assert_eq!(vov.value(), None);
320        assert_eq!(vov.update(1.0), None);
321    }
322
323    #[test]
324    fn batch_equals_streaming() {
325        let prices: Vec<f64> = (1..=200)
326            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
327            .collect();
328        let batch = VolatilityOfVolatility::new(10, 10).unwrap().batch(&prices);
329        let mut b = VolatilityOfVolatility::new(10, 10).unwrap();
330        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
331        assert_eq!(batch, streamed);
332    }
333}