wickra_core/indicators/
volatility_of_volatility.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8fn 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#[derive(Debug, Clone)]
54pub struct VolatilityOfVolatility {
55 vol_window: usize,
56 vov_window: usize,
57 prev_price: Option<f64>,
58 returns: VecDeque<f64>,
60 ret_sum: f64,
61 ret_sum_sq: f64,
62 vols: VecDeque<f64>,
64 vol_sum: f64,
65 vol_sum_sq: f64,
66 last: Option<f64>,
67}
68
69impl VolatilityOfVolatility {
70 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 pub const fn windows(&self) -> (usize, usize) {
104 (self.vol_window, self.vov_window)
105 }
106
107 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 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 let r = (input / prev).ln();
131
132 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 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 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(); 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 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 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 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}