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    fn update(&mut self, input: f64) -> Option<f64> {
123        // Non-finite / non-positive prices are skipped: `ln(input / prev)` is
124        // undefined, so the tick must not enter the variance recursion.
125        if !input.is_finite() || input <= 0.0 {
126            return self.last;
127        }
128        let Some(prev) = self.prev_price else {
129            self.prev_price = Some(input);
130            return None;
131        };
132        self.prev_price = Some(input);
133        // `prev` came from `self.prev_price`, gated by the guard above, so it is
134        // finite and positive — the log return is always well-defined.
135        let r = (input / prev).ln();
136        let r_sq = r * r;
137        let var = match self.state {
138            // Seed the recursion with the unconditional variance.
139            None => self.unconditional,
140            Some((prev_var, prev_r_sq)) => {
141                self.omega + self.alpha * prev_r_sq + self.beta * prev_var
142            }
143        };
144        self.state = Some((var, r_sq));
145        // `var` is `omega (> 0) + non-negative terms`, so it is strictly
146        // positive — the square root is always well-defined.
147        let vol = var.sqrt();
148        self.last = Some(vol);
149        Some(vol)
150    }
151
152    fn reset(&mut self) {
153        self.prev_price = None;
154        self.state = None;
155        self.last = None;
156    }
157
158    fn warmup_period(&self) -> usize {
159        // The first log return needs a previous price; the estimate is seeded
160        // with the unconditional variance and emitted on that first return.
161        2
162    }
163
164    fn is_ready(&self) -> bool {
165        self.last.is_some()
166    }
167
168    fn name(&self) -> &'static str {
169        "Garch11"
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::traits::BatchExt;
177    use approx::assert_relative_eq;
178
179    #[test]
180    fn rejects_invalid_params() {
181        assert!(matches!(
182            Garch11::new(0.0, 0.1, 0.8),
183            Err(Error::InvalidParameter { .. })
184        ));
185        assert!(matches!(
186            Garch11::new(-1.0, 0.1, 0.8),
187            Err(Error::InvalidParameter { .. })
188        ));
189        assert!(matches!(
190            Garch11::new(0.001, -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.5, 0.5),
199            Err(Error::InvalidParameter { .. })
200        ));
201        assert!(matches!(
202            Garch11::new(f64::NAN, 0.1, 0.8),
203            Err(Error::InvalidParameter { .. })
204        ));
205        assert!(matches!(
206            Garch11::new(0.001, f64::INFINITY, 0.8),
207            Err(Error::InvalidParameter { .. })
208        ));
209    }
210
211    #[test]
212    fn accessors_and_metadata() {
213        let g = Garch11::new(0.001, 0.1, 0.85).unwrap();
214        assert_eq!(g.params(), (0.001, 0.1, 0.85));
215        assert_relative_eq!(g.unconditional_variance(), 0.001 / 0.05, epsilon = 1e-12);
216        assert_eq!(g.warmup_period(), 2);
217        assert_eq!(g.name(), "Garch11");
218        assert!(!g.is_ready());
219        assert_eq!(g.value(), None);
220    }
221
222    #[test]
223    fn first_emission_is_unconditional() {
224        // The first log return emits the seed = sqrt(unconditional variance),
225        // independent of the return value.
226        let g = Garch11::new(0.002, 0.1, 0.85);
227        let mut g = g.unwrap();
228        assert_eq!(g.update(100.0), None);
229        let out = g.update(110.0).unwrap();
230        assert_relative_eq!(out, (0.002_f64 / 0.05).sqrt(), epsilon = 1e-12);
231    }
232
233    #[test]
234    fn known_value() {
235        // σ²₁ = uncond; σ²₂ = ω + α·r1² + β·uncond.
236        let (omega, alpha, beta) = (0.002, 0.1, 0.85);
237        let mut g = Garch11::new(omega, alpha, beta).unwrap();
238        let out = g.batch(&[100.0, 110.0, 99.0]);
239        let uncond = omega / (1.0 - alpha - beta);
240        let r1 = (110.0_f64 / 100.0).ln();
241        assert_relative_eq!(out[1].unwrap(), uncond.sqrt(), epsilon = 1e-12);
242        let var2 = omega + alpha * r1 * r1 + beta * uncond;
243        assert_relative_eq!(out[2].unwrap(), var2.sqrt(), epsilon = 1e-12);
244    }
245
246    #[test]
247    fn flat_series_converges_to_long_run() {
248        // With zero returns the alpha term vanishes; the variance mean-reverts
249        // to the fixed point ω / (1 − β), NOT to zero (the key GARCH/EWMA
250        // distinction).
251        let (omega, beta) = (0.002, 0.85);
252        let mut g = Garch11::new(omega, 0.10, beta).unwrap();
253        let out = g.batch(&[100.0; 400]);
254        let fixed_point = (omega / (1.0 - beta)).sqrt();
255        assert_relative_eq!(out.last().unwrap().unwrap(), fixed_point, epsilon = 1e-9);
256    }
257
258    #[test]
259    fn output_is_strictly_positive() {
260        let mut g = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
261        let prices: Vec<f64> = (1..=200)
262            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
263            .collect();
264        for v in g.batch(&prices).into_iter().flatten() {
265            assert!(
266                v > 0.0,
267                "GARCH volatility must be strictly positive, got {v}"
268            );
269        }
270    }
271
272    #[test]
273    fn ignores_non_finite_input() {
274        let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
275        let out = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
276        let last = *out.last().unwrap();
277        assert!(last.is_some());
278        assert_eq!(g.update(f64::NAN), last);
279        assert_eq!(g.update(f64::INFINITY), last);
280    }
281
282    #[test]
283    fn skips_non_positive_prices() {
284        let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
285        let warmup = g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
286        let baseline = warmup.last().copied().flatten().expect("warmed up");
287        assert_eq!(g.update(-5.0), Some(baseline));
288        assert_eq!(g.update(0.0), Some(baseline));
289        // State untouched: a clone advanced by the same real tick agrees.
290        let mut control = g.clone();
291        let after = g.update(21.0).expect("ready");
292        assert_eq!(control.update(21.0).expect("ready"), after);
293    }
294
295    #[test]
296    fn skips_non_positive_before_first_price() {
297        let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
298        assert_eq!(g.update(0.0), None);
299        assert_eq!(g.update(f64::NAN), None);
300        assert_eq!(g.update(100.0), None);
301        assert!(g.update(110.0).is_some());
302    }
303
304    #[test]
305    fn reset_clears_state() {
306        let mut g = Garch11::new(0.001, 0.1, 0.85).unwrap();
307        g.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
308        assert!(g.is_ready());
309        g.reset();
310        assert!(!g.is_ready());
311        assert_eq!(g.value(), None);
312        assert_eq!(g.update(1.0), None);
313    }
314
315    #[test]
316    fn batch_equals_streaming() {
317        let prices: Vec<f64> = (1..=120)
318            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
319            .collect();
320        let batch = Garch11::new(0.000_002, 0.1, 0.88).unwrap().batch(&prices);
321        let mut b = Garch11::new(0.000_002, 0.1, 0.88).unwrap();
322        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
323        assert_eq!(batch, streamed);
324    }
325}