Skip to main content

fin_primitives/volatility/
mod.rs

1//! Realised volatility estimators: Close-to-Close, Parkinson, Garman-Klass, Rogers-Satchell, Yang-Zhang.
2//! Also provides `volatility::garch` with GARCH(1,1) MLE fitting, conditional variance,
3//! multi-step forecasting, and volatility term structure.
4//!
5//! ## Responsibility
6//! Realised volatility estimators using OHLCV data.
7//!
8//! ## Estimators
9//! | Estimator | Data needed | Efficiency gain vs Close-to-Close |
10//! |-----------|-------------|-----------------------------------|
11//! | `CloseToClose` | Close only | 1× (baseline) |
12//! | `Parkinson` | High, Low | ~5× |
13//! | `GarmanKlass` | Open, High, Low, Close | ~7-8× |
14//! | `RogersSatchell` | Open, High, Low, Close | ~8× (drift-adjusted) |
15//! | `YangZhang` | Open, High, Low, Close + previous close | ~15× (gap-robust) |
16//!
17//! All estimators operate in rolling-window mode: push bars one at a time,
18//! receive `Some(annualised_vol)` once the window is full.
19//!
20//! ## NOT Responsible For
21//! - Forward volatility (implied vol is in `options`)
22
23/// GARCH(1,1) parametric volatility model: MLE fitting, conditional variance,
24/// multi-step forecasting, and volatility term structure.
25pub mod garch;
26
27use crate::error::FinError;
28
29/// A single OHLCV bar input for volatility estimators.
30#[derive(Debug, Clone, Copy)]
31pub struct OhlcBar {
32    /// Opening price of the bar.
33    pub open: f64,
34    /// Highest price during the bar.
35    pub high: f64,
36    /// Lowest price during the bar.
37    pub low: f64,
38    /// Closing price of the bar.
39    pub close: f64,
40}
41
42// ─── helper: rolling window statistics ───────────────────────────────────────
43
44fn sample_variance(values: &[f64]) -> f64 {
45    let n = values.len() as f64;
46    if n < 2.0 {
47        return 0.0;
48    }
49    let mean = values.iter().sum::<f64>() / n;
50    values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0)
51}
52
53fn annualise(variance_per_bar: f64, bars_per_year: f64) -> f64 {
54    (variance_per_bar * bars_per_year).sqrt()
55}
56
57// ─── Close-to-Close ───────────────────────────────────────────────────────────
58
59/// Standard close-to-close realised volatility estimator.
60///
61/// Uses log-returns: r_t = ln(C_t / C_{t-1}).
62/// σ_ann = std(r) × √(bars_per_year).
63#[derive(Debug, Clone)]
64pub struct CloseToClose {
65    window: usize,
66    bars_per_year: f64,
67    closes: Vec<f64>,
68}
69
70impl CloseToClose {
71    /// Create a new close-to-close estimator.
72    ///
73    /// - `window`: number of bars in the rolling window.
74    /// - `bars_per_year`: annualisation factor (252 for daily, 252×6.5×60 for minute bars).
75    ///
76    /// # Errors
77    /// `FinError::InvalidPeriod` if `window == 0`.
78    pub fn new(window: usize, bars_per_year: f64) -> Result<Self, FinError> {
79        if window == 0 {
80            return Err(FinError::InvalidPeriod(window));
81        }
82        Ok(Self { window, bars_per_year, closes: Vec::with_capacity(window + 1) })
83    }
84
85    /// Push the latest close price. Returns annualised volatility once the window is full.
86    pub fn update(&mut self, close: f64) -> Option<f64> {
87        self.closes.push(close);
88        if self.closes.len() > self.window + 1 {
89            self.closes.remove(0);
90        }
91        if self.closes.len() <= self.window {
92            return None;
93        }
94        let returns: Vec<f64> = self
95            .closes
96            .windows(2)
97            .map(|w| (w[1] / w[0]).ln())
98            .collect();
99        let var = sample_variance(&returns);
100        Some(annualise(var, self.bars_per_year))
101    }
102
103    /// Minimum observations before output is emitted.
104    pub fn warmup_period(&self) -> usize {
105        self.window + 1
106    }
107}
108
109// ─── Parkinson ────────────────────────────────────────────────────────────────
110
111/// Parkinson high-low realised volatility estimator.
112///
113/// Uses only the high and low prices; ~5× more efficient than close-to-close
114/// but assumes no overnight gaps and zero drift.
115///
116/// σ²_park = [1/(4n·ln2)] × Σ (ln(H_i/L_i))²
117#[derive(Debug, Clone)]
118pub struct Parkinson {
119    window: usize,
120    bars_per_year: f64,
121    hl_sq: Vec<f64>,
122}
123
124impl Parkinson {
125    /// Create a new Parkinson estimator.
126    ///
127    /// # Errors
128    /// `FinError::InvalidPeriod` if `window == 0`.
129    pub fn new(window: usize, bars_per_year: f64) -> Result<Self, FinError> {
130        if window == 0 {
131            return Err(FinError::InvalidPeriod(window));
132        }
133        Ok(Self { window, bars_per_year, hl_sq: Vec::with_capacity(window) })
134    }
135
136    /// Push an OHLC bar. Returns annualised volatility once the window is full.
137    pub fn update(&mut self, bar: OhlcBar) -> Option<f64> {
138        if bar.high <= 0.0 || bar.low <= 0.0 || bar.high < bar.low {
139            return None;
140        }
141        let hl = (bar.high / bar.low).ln();
142        self.hl_sq.push(hl * hl);
143        if self.hl_sq.len() > self.window {
144            self.hl_sq.remove(0);
145        }
146        if self.hl_sq.len() < self.window {
147            return None;
148        }
149        let n = self.window as f64;
150        let var_per_bar = self.hl_sq.iter().sum::<f64>() / (4.0 * n * 2.0_f64.ln());
151        Some(annualise(var_per_bar, self.bars_per_year))
152    }
153
154    /// Minimum bars before output is emitted.
155    pub fn warmup_period(&self) -> usize {
156        self.window
157    }
158}
159
160// ─── Garman-Klass ─────────────────────────────────────────────────────────────
161
162/// Garman-Klass OHLC realised volatility estimator.
163///
164/// Uses open, high, low, and close; ~7-8× more efficient than close-to-close.
165///
166/// σ²_gk = (1/n) × Σ [ 0.5·(ln H/L)² − (2ln2−1)·(ln C/O)² ]
167#[derive(Debug, Clone)]
168pub struct GarmanKlass {
169    window: usize,
170    bars_per_year: f64,
171    terms: Vec<f64>,
172}
173
174impl GarmanKlass {
175    /// Create a new Garman-Klass estimator.
176    ///
177    /// # Errors
178    /// `FinError::InvalidPeriod` if `window == 0`.
179    pub fn new(window: usize, bars_per_year: f64) -> Result<Self, FinError> {
180        if window == 0 {
181            return Err(FinError::InvalidPeriod(window));
182        }
183        Ok(Self { window, bars_per_year, terms: Vec::with_capacity(window) })
184    }
185
186    /// Push an OHLC bar. Returns annualised volatility once the window is full.
187    pub fn update(&mut self, bar: OhlcBar) -> Option<f64> {
188        if bar.open <= 0.0 || bar.high <= 0.0 || bar.low <= 0.0 || bar.close <= 0.0 {
189            return None;
190        }
191        let hl = (bar.high / bar.low).ln();
192        let co = (bar.close / bar.open).ln();
193        let term = 0.5 * hl * hl - (2.0 * 2.0_f64.ln() - 1.0) * co * co;
194        self.terms.push(term);
195        if self.terms.len() > self.window {
196            self.terms.remove(0);
197        }
198        if self.terms.len() < self.window {
199            return None;
200        }
201        let var_per_bar = self.terms.iter().sum::<f64>() / self.window as f64;
202        let var_per_bar = var_per_bar.max(0.0);
203        Some(annualise(var_per_bar, self.bars_per_year))
204    }
205
206    /// Minimum bars before output is emitted.
207    pub fn warmup_period(&self) -> usize {
208        self.window
209    }
210}
211
212// ─── Rogers-Satchell ──────────────────────────────────────────────────────────
213
214/// Rogers-Satchell OHLC realised volatility estimator.
215///
216/// Handles non-zero drift unlike Parkinson or Garman-Klass.
217///
218/// σ²_rs = (1/n) × Σ [ ln(H/C)·ln(H/O) + ln(L/C)·ln(L/O) ]
219#[derive(Debug, Clone)]
220pub struct RogersSatchell {
221    window: usize,
222    bars_per_year: f64,
223    terms: Vec<f64>,
224}
225
226impl RogersSatchell {
227    /// Create a new Rogers-Satchell estimator.
228    ///
229    /// # Errors
230    /// `FinError::InvalidPeriod` if `window == 0`.
231    pub fn new(window: usize, bars_per_year: f64) -> Result<Self, FinError> {
232        if window == 0 {
233            return Err(FinError::InvalidPeriod(window));
234        }
235        Ok(Self { window, bars_per_year, terms: Vec::with_capacity(window) })
236    }
237
238    /// Push an OHLC bar. Returns annualised volatility once the window is full.
239    pub fn update(&mut self, bar: OhlcBar) -> Option<f64> {
240        if bar.open <= 0.0 || bar.high <= 0.0 || bar.low <= 0.0 || bar.close <= 0.0 {
241            return None;
242        }
243        let hc = (bar.high / bar.close).ln();
244        let ho = (bar.high / bar.open).ln();
245        let lc = (bar.low / bar.close).ln();
246        let lo = (bar.low / bar.open).ln();
247        let term = hc * ho + lc * lo;
248        self.terms.push(term);
249        if self.terms.len() > self.window {
250            self.terms.remove(0);
251        }
252        if self.terms.len() < self.window {
253            return None;
254        }
255        let var_per_bar = (self.terms.iter().sum::<f64>() / self.window as f64).max(0.0);
256        Some(annualise(var_per_bar, self.bars_per_year))
257    }
258
259    /// Minimum bars before output is emitted.
260    pub fn warmup_period(&self) -> usize {
261        self.window
262    }
263}
264
265// ─── Yang-Zhang ───────────────────────────────────────────────────────────────
266
267/// Yang-Zhang OHLC realised volatility estimator.
268///
269/// Handles overnight gaps (open ≠ previous close) and drift.
270/// ~15× more efficient than close-to-close.
271///
272/// σ²_yz = σ²_overnight + k·σ²_open + (1-k)·σ²_rs
273/// where k = 0.34 / (1.34 + (n+1)/(n-1)).
274#[derive(Debug, Clone)]
275pub struct YangZhang {
276    window: usize,
277    bars_per_year: f64,
278    /// (prev_close, open, high, low, close) tuples
279    bars: Vec<OhlcBar>,
280    prev_close: Option<f64>,
281}
282
283impl YangZhang {
284    /// Create a new Yang-Zhang estimator.
285    ///
286    /// # Errors
287    /// `FinError::InvalidPeriod` if `window < 2`.
288    pub fn new(window: usize, bars_per_year: f64) -> Result<Self, FinError> {
289        if window < 2 {
290            return Err(FinError::InvalidInput(
291                "YangZhang window must be at least 2".to_owned(),
292            ));
293        }
294        Ok(Self { window, bars_per_year, bars: Vec::with_capacity(window), prev_close: None })
295    }
296
297    /// Push an OHLC bar. Returns annualised volatility once the window is full.
298    pub fn update(&mut self, bar: OhlcBar) -> Option<f64> {
299        if bar.open <= 0.0 || bar.high <= 0.0 || bar.low <= 0.0 || bar.close <= 0.0 {
300            return None;
301        }
302        self.prev_close = Some(bar.close);
303        self.bars.push(bar);
304        if self.bars.len() > self.window {
305            self.bars.remove(0);
306        }
307        if self.bars.len() < self.window {
308            return None;
309        }
310
311        let n = self.window as f64;
312
313        // Build sequences: need prev close for each bar except the first
314        // We compute overnight (open/prev_close), open (close/open) and RS terms
315        let mut overnight_sq = Vec::with_capacity(self.window - 1);
316        let mut open_sq = Vec::with_capacity(self.window);
317        let mut rs_terms = Vec::with_capacity(self.window);
318
319        for i in 0..self.bars.len() {
320            let b = &self.bars[i];
321            // Rogers-Satchell per bar
322            let hc = (b.high / b.close).ln();
323            let ho = (b.high / b.open).ln();
324            let lc = (b.low / b.close).ln();
325            let lo = (b.low / b.open).ln();
326            rs_terms.push(hc * ho + lc * lo);
327
328            // Open-to-close
329            open_sq.push((b.close / b.open).ln());
330
331            // Overnight gap (requires previous bar)
332            if i > 0 {
333                let prev = &self.bars[i - 1];
334                overnight_sq.push((b.open / prev.close).ln());
335            }
336        }
337
338        let mean_overnight = overnight_sq.iter().sum::<f64>() / overnight_sq.len() as f64;
339        let var_overnight = overnight_sq
340            .iter()
341            .map(|x| (x - mean_overnight).powi(2))
342            .sum::<f64>()
343            / (overnight_sq.len() as f64 - 1.0).max(1.0);
344
345        let mean_open = open_sq.iter().sum::<f64>() / n;
346        let var_open = open_sq
347            .iter()
348            .map(|x| (x - mean_open).powi(2))
349            .sum::<f64>()
350            / (n - 1.0).max(1.0);
351
352        let var_rs = (rs_terms.iter().sum::<f64>() / n).max(0.0);
353
354        let k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0));
355        let var_yz = (var_overnight + k * var_open + (1.0 - k) * var_rs).max(0.0);
356
357        Some(annualise(var_yz, self.bars_per_year))
358    }
359
360    /// Minimum bars before output is emitted.
361    pub fn warmup_period(&self) -> usize {
362        self.window
363    }
364}
365
366// ─── tests ────────────────────────────────────────────────────────────────────
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    fn rising_bar(base: f64) -> OhlcBar {
373        OhlcBar { open: base, high: base * 1.01, low: base * 0.99, close: base * 1.005 }
374    }
375
376    #[test]
377    fn test_close_to_close_warmup() {
378        let mut ctc = CloseToClose::new(5, 252.0).unwrap();
379        for i in 0..5 {
380            assert!(ctc.update(100.0 + i as f64).is_none());
381        }
382        assert!(ctc.update(106.0).is_some());
383    }
384
385    #[test]
386    fn test_close_to_close_positive_vol() {
387        let mut ctc = CloseToClose::new(10, 252.0).unwrap();
388        let mut vol = None;
389        for i in 0..20 {
390            vol = ctc.update(100.0 + (i as f64).sin() * 2.0);
391        }
392        assert!(vol.unwrap() > 0.0);
393    }
394
395    #[test]
396    fn test_parkinson_warmup_and_positive() {
397        let mut pk = Parkinson::new(5, 252.0).unwrap();
398        for i in 0..4 {
399            assert!(pk.update(rising_bar(100.0 + i as f64)).is_none());
400        }
401        let v = pk.update(rising_bar(105.0));
402        assert!(v.is_some());
403        assert!(v.unwrap() > 0.0);
404    }
405
406    #[test]
407    fn test_garman_klass_positive() {
408        let mut gk = GarmanKlass::new(10, 252.0).unwrap();
409        let mut vol = None;
410        for i in 0..10 {
411            vol = gk.update(rising_bar(100.0 + i as f64));
412        }
413        assert!(vol.is_some());
414    }
415
416    #[test]
417    fn test_rogers_satchell_positive() {
418        let mut rs = RogersSatchell::new(10, 252.0).unwrap();
419        let mut vol = None;
420        for i in 0..10 {
421            vol = rs.update(rising_bar(100.0 + i as f64));
422        }
423        assert!(vol.is_some());
424    }
425
426    #[test]
427    fn test_yang_zhang_warmup() {
428        let mut yz = YangZhang::new(5, 252.0).unwrap();
429        for i in 0..4 {
430            assert!(yz.update(rising_bar(100.0 + i as f64)).is_none());
431        }
432        assert!(yz.update(rising_bar(105.0)).is_some());
433    }
434
435    #[test]
436    fn test_yang_zhang_invalid_window() {
437        assert!(YangZhang::new(1, 252.0).is_err());
438    }
439
440    #[test]
441    fn test_invalid_period() {
442        assert!(CloseToClose::new(0, 252.0).is_err());
443        assert!(Parkinson::new(0, 252.0).is_err());
444        assert!(GarmanKlass::new(0, 252.0).is_err());
445        assert!(RogersSatchell::new(0, 252.0).is_err());
446    }
447}