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