Skip to main content

wickra_core/indicators/
garch11.rs

1//! GARCH(1,1) — conditional volatility with a long-run-variance anchor.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6/// GARCH(1,1) conditional volatility — the square root of the
7/// generalized-autoregressive-conditional-heteroskedasticity variance recursion.
8///
9/// ```text
10/// r_t  = ln(price_t / price_{t−1})
11/// σ²_t = ω + α · r²_{t−1} + β · σ²_{t−1}
12/// out  = √σ²_t
13/// ```
14///
15/// GARCH(1,1) (Bollerslev 1986) generalizes the
16/// [`EwmaVolatility`](crate::EwmaVolatility) recursion by adding a constant `ω`,
17/// which pins the process to a finite long-run (unconditional) variance
18/// `ω / (1 − α − β)`. The `α` term gives weight to the latest squared return
19/// (the "ARCH" shock) and `β` to the previous variance (the "GARCH"
20/// persistence). When `ω = 0` and `α + β = 1` the model degenerates to EWMA; a
21/// proper GARCH keeps `ω > 0` and `α + β < 1` so volatility mean-reverts rather
22/// than drifting.
23///
24/// The recursion is seeded with the unconditional variance (`σ²₁ = ω / (1 − α −
25/// β)`) and emits from the first log return onward. Unlike EWMA — which decays to
26/// zero on a flat series — a flat series here mean-reverts toward `ω / (1 − β)`
27/// (the `α`-term vanishes but the `ω` floor and the `β` carry remain), so the
28/// output is always strictly positive. Each `update` is O(1).
29///
30/// Non-finite and non-positive prices are ignored (the log return would be
31/// undefined): the tick is dropped, state is left untouched, and the last value
32/// is returned.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Garch11, Indicator};
38///
39/// // Typical equity daily estimate.
40/// let mut indicator = Garch11::new(0.000_002, 0.10, 0.88).unwrap();
41/// let mut last = None;
42/// for i in 0..80 {
43///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
44/// }
45/// assert!(last.is_some());
46/// ```
47#[derive(Debug, Clone)]
48pub struct Garch11 {
49    omega: f64,
50    alpha: f64,
51    beta: f64,
52    unconditional: f64,
53    prev_price: Option<f64>,
54    /// `(σ²_{t−1}, r²_{t−1})` — previous variance and previous squared return.
55    state: Option<(f64, f64)>,
56    last: Option<f64>,
57}
58
59impl Garch11 {
60    /// Construct a new GARCH(1,1) indicator from its three parameters.
61    ///
62    /// `omega` (`ω`) is the constant variance floor, `alpha` (`α`) the weight on
63    /// the latest squared return, and `beta` (`β`) the persistence of the
64    /// previous variance.
65    ///
66    /// # Errors
67    /// Returns [`Error::InvalidParameter`] unless every parameter is finite,
68    /// `omega > 0`, `alpha >= 0`, `beta >= 0`, and `alpha + beta < 1` (the
69    /// covariance-stationarity condition that gives a finite long-run variance).
70    pub fn new(omega: f64, alpha: f64, beta: f64) -> Result<Self> {
71        if !omega.is_finite() || !alpha.is_finite() || !beta.is_finite() {
72            return Err(Error::InvalidParameter {
73                message: "GARCH(1,1) parameters must be finite",
74            });
75        }
76        if omega <= 0.0 {
77            return Err(Error::InvalidParameter {
78                message: "GARCH(1,1) omega must be > 0",
79            });
80        }
81        if alpha < 0.0 || beta < 0.0 {
82            return Err(Error::InvalidParameter {
83                message: "GARCH(1,1) alpha and beta must be >= 0",
84            });
85        }
86        if alpha + beta >= 1.0 {
87            return Err(Error::InvalidParameter {
88                message: "GARCH(1,1) requires alpha + beta < 1 (covariance stationarity)",
89            });
90        }
91        Ok(Self {
92            omega,
93            alpha,
94            beta,
95            unconditional: omega / (1.0 - alpha - beta),
96            prev_price: None,
97            state: None,
98            last: None,
99        })
100    }
101
102    /// Configured `(omega, alpha, beta)`.
103    pub const fn params(&self) -> (f64, f64, f64) {
104        (self.omega, self.alpha, self.beta)
105    }
106
107    /// Long-run (unconditional) variance `ω / (1 − α − β)`.
108    pub const fn unconditional_variance(&self) -> f64 {
109        self.unconditional
110    }
111
112    /// Current value if available.
113    pub const fn value(&self) -> Option<f64> {
114        self.last
115    }
116}
117
118impl Indicator for Garch11 {
119    type Input = f64;
120    type Output = f64;
121
122    #[inline]
123    fn update(&mut self, input: f64) -> Option<f64> {
124        // Non-finite / non-positive prices are skipped: `ln(input / prev)` is
125        // undefined, so the tick must not enter the variance recursion.
126        if !input.is_finite() || input <= 0.0 {
127            return None;
128        }
129        let Some(prev) = self.prev_price else {
130            self.prev_price = Some(input);
131            return None;
132        };
133        self.prev_price = Some(input);
134        // `prev` came from `self.prev_price`, gated by the guard above, so it is
135        // finite and positive — the log return is always well-defined.
136        let r = (input / prev).ln();
137        let r_sq = r * r;
138        let var = match self.state {
139            // Seed the recursion with the unconditional variance.
140            None => self.unconditional,
141            Some((prev_var, prev_r_sq)) => {
142                self.omega + self.alpha * prev_r_sq + self.beta * prev_var
143            }
144        };
145        self.state = Some((var, r_sq));
146        // `var` is `omega (> 0) + non-negative terms`, so it is strictly
147        // positive — the square root is always well-defined.
148        let vol = var.sqrt();
149        self.last = Some(vol);
150        Some(vol)
151    }
152
153    fn reset(&mut self) {
154        self.prev_price = None;
155        self.state = None;
156        self.last = None;
157    }
158
159    #[inline]
160    fn warmup_period(&self) -> usize {
161        // The first log return needs a previous price; the estimate is seeded
162        // with the unconditional variance and emitted on that first return.
163        2
164    }
165
166    #[inline]
167    fn is_ready(&self) -> bool {
168        self.last.is_some()
169    }
170
171    #[inline]
172    fn name(&self) -> &'static str {
173        "Garch11"
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::traits::BatchExt;
181    use approx::assert_relative_eq;
182
183    #[test]
184    fn rejects_invalid_params() {
185        assert!(matches!(
186            Garch11::new(0.0, 0.1, 0.8),
187            Err(Error::InvalidParameter { .. })
188        ));
189        assert!(matches!(
190            Garch11::new(-1.0, 0.1, 0.8),
191            Err(Error::InvalidParameter { .. })
192        ));
193        assert!(matches!(
194            Garch11::new(0.001, -0.1, 0.8),
195            Err(Error::InvalidParameter { .. })
196        ));
197        assert!(matches!(
198            Garch11::new(0.001, 0.1, -0.8),
199            Err(Error::InvalidParameter { .. })
200        ));
201        assert!(matches!(
202            Garch11::new(0.001, 0.5, 0.5),
203            Err(Error::InvalidParameter { .. })
204        ));
205        assert!(matches!(
206            Garch11::new(f64::NAN, 0.1, 0.8),
207            Err(Error::InvalidParameter { .. })
208        ));
209        assert!(matches!(
210            Garch11::new(0.001, f64::INFINITY, 0.8),
211            Err(Error::InvalidParameter { .. })
212        ));
213    }
214
215    #[test]
216    fn accessors_and_metadata() {
217        let g = Garch11::new(0.001, 0.1, 0.85).unwrap();
218        assert_eq!(g.params(), (0.001, 0.1, 0.85));
219        assert_relative_eq!(g.unconditional_variance(), 0.001 / 0.05, epsilon = 1e-12);
220        assert_eq!(g.warmup_period(), 2);
221        assert_eq!(g.name(), "Garch11");
222        assert!(!g.is_ready());
223        assert_eq!(g.value(), None);
224    }
225
226    #[test]
227    fn first_emission_is_unconditional() {
228        // The first log return emits the seed = sqrt(unconditional variance),
229        // independent of the return value.
230        let g = Garch11::new(0.002, 0.1, 0.85);
231        let mut g = g.unwrap();
232        assert_eq!(g.update(100.0), None);
233        let out = g.update(110.0).unwrap();
234        assert_relative_eq!(out, (0.002_f64 / 0.05).sqrt(), epsilon = 1e-12);
235    }
236
237    #[test]
238    fn known_value() {
239        // σ²₁ = uncond; σ²₂ = ω + α·r1² + β·uncond.
240        let (omega, alpha, beta) = (0.002, 0.1, 0.85);
241        let mut g = Garch11::new(omega, alpha, beta).unwrap();
242        let out = g.batch(&[100.0, 110.0, 99.0]);
243        let uncond = omega / (1.0 - alpha - beta);
244        let r1 = (110.0_f64 / 100.0).ln();
245        assert_relative_eq!(out[1].unwrap(), uncond.sqrt(), epsilon = 1e-12);
246        let var2 = omega + alpha * r1 * r1 + beta * uncond;
247        assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
248    }
249
250    #[test]
251    fn flat_series_converges_to_long_run() {
252        // With zero returns the alpha term vanishes; the variance mean-reverts
253        // to the fixed point ω / (1 − β), NOT to zero (the key GARCH/EWMA
254        // distinction).
255        let (omega, beta) = (0.002, 0.85);
256        let mut g = Garch11::new(omega, 0.10, beta).unwrap();
257        let out = g.batch(&[100.0; 400]);
258        let fixed_point = (omega / (1.0 - beta)).sqrt();
259        assert_relative_eq!(out.last().unwrap().unwrap(), fixed_point, epsilon = 1e-9);
260    }
261
262    #[test]
263    fn output_is_strictly_positive() {
264        let mut g = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
265        let prices: Vec<f64> = (1..=200)
266            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
267            .collect();
268        for v in g.batch(&prices).into_iter().flatten() {
269            assert!(
270                v > 0.0,
271                "GARCH volatility must be strictly positive, got {v}"
272            );
273        }
274    }
275
276    #[test]
277    fn ignores_non_finite_input() {
278        let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
279        let out = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
280        let last = *out.last().unwrap();
281        assert!(last.is_some());
282        assert_eq!(g.update(f64::NAN), None);
283        assert_eq!(g.update(f64::INFINITY), None);
284    }
285
286    #[test]
287    fn skips_non_positive_prices() {
288        let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
289        let warmup = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
290        warmup.last().copied().flatten().expect("warmed up");
291        assert_eq!(g.update(-5.0), None);
292        assert_eq!(g.update(0.0), None);
293        // State untouched: a clone advanced by the same real tick agrees.
294        let mut control = g.clone();
295        let after = g.update(21.0).expect("ready");
296        assert_eq!(control.update(21.0).expect("ready"), after);
297    }
298
299    #[test]
300    fn skips_non_positive_before_first_price() {
301        let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
302        assert_eq!(g.update(0.0), None);
303        assert_eq!(g.update(f64::NAN), None);
304        assert_eq!(g.update(100.0), None);
305        assert!(g.update(110.0).is_some());
306    }
307
308    #[test]
309    fn reset_clears_state() {
310        let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
311        g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
312        assert!(g.is_ready());
313        g.reset();
314        assert!(!g.is_ready());
315        assert_eq!(g.value(), None);
316        assert_eq!(g.update(1.0), None);
317    }
318
319    #[test]
320    fn batch_equals_streaming() {
321        let prices: Vec<f64> = (1..=120)
322            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
323            .collect();
324        let batch = Garch11::new(0.000_002, 0.1, 0.88).unwrap().batch(&prices);
325        let mut b = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
326        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
327        assert_eq!(batch, streamed);
328    }
329}