Markets: A Rigorous Course

Exercise · m13_garch.py

GARCH(1,1) by maximum likelihood, from scratch

Model: r_t = eps_t, eps_t = sigma_t z_t, z_t iid N(0, 1) sigma_t^2 = omega + alpha eps_{t-1}^2 + beta sigma_{t-1}^2 with omega > 0, alpha >= 0, beta >= 0, alpha + beta < 1.

Initialisation ("backcast"): set the unobserved pre-sample eps_0^2 and sigma_0^2 both equal to a number b, so sigma_1^2 = omega + (alpha + beta) b. This is the convention of the arch package; the tests pass arch's own backcast and expect your log-likelihood to agree with arch's to ~1e-8 relative.

Run: cd exercises && .venv/bin/python -m pytest modules/M13-forecasting/tests/test_m13_garch.py

Tests (6)

  • test_variance_recursion_by_hand
  • test_loglik_matches_arch_at_same_params
  • test_mle_recovers_truth_and_matches_arch
  • test_unconditional_variance_and_half_life
  • test_multistep_forecast_closed_form_vs_monte_carlo
  • test_quasi_mle_on_fat_tailed_course_data

Theory: M13 notes. Run with Ctrl/⌘+Enter.

not run yet

Not saved: sign up to keep your code
"""M13 · GARCH(1,1) by maximum likelihood, from scratch. Read notes §13.4 first.

Model:
    r_t = eps_t,  eps_t = sigma_t z_t,  z_t iid N(0, 1)
    sigma_t^2 = omega + alpha eps_{t-1}^2 + beta sigma_{t-1}^2
with omega > 0, alpha >= 0, beta >= 0, alpha + beta < 1.

Initialisation ("backcast"): set the unobserved pre-sample eps_0^2 and sigma_0^2
both equal to a number b, so sigma_1^2 = omega + (alpha + beta) b. This is the
convention of the `arch` package; the tests pass arch's own backcast and expect
your log-likelihood to agree with arch's to ~1e-8 relative.

Run:  cd exercises && .venv/bin/python -m pytest modules/M13-forecasting/tests/test_m13_garch.py
"""

from __future__ import annotations

import numpy as np


def simulate_garch11(omega: float, alpha: float, beta: float, n: int, seed: int = 0,
                     burn: int = 500) -> np.ndarray:
    """Simulate n returns from a zero-mean Gaussian GARCH(1,1), discarding `burn` start-up values. (Given.)"""
    rng = np.random.default_rng(seed)
    z = rng.standard_normal(n + burn)
    r = np.empty(n + burn)
    s2 = omega / (1.0 - alpha - beta)
    for t in range(n + burn):
        if t > 0:
            s2 = omega + alpha * r[t - 1] ** 2 + beta * s2
        r[t] = np.sqrt(s2) * z[t]
    return r[burn:]


def default_backcast(r: np.ndarray) -> float:
    """Exponentially weighted mean of the first min(75, n) squared returns, weights 0.94^j, normalised. (Given.)"""
    r = np.asarray(r, dtype=float)
    tau = min(75, r.size)
    w = 0.94 ** np.arange(tau)
    w = w / w.sum()
    return float(np.sum(w * r[:tau] ** 2))


def garch11_variance(r: np.ndarray, omega: float, alpha: float, beta: float,
                     backcast: float | None = None) -> np.ndarray:
    """Conditional variances sigma_t^2 for t = 1..n (notes eq. 13.16).

    Inputs: returns r (length n), parameters, backcast b (default: default_backcast(r)).
    Output: array of length n with sigma_1^2 = omega + (alpha + beta) b and
            sigma_t^2 = omega + alpha r_{t-1}^2 + beta sigma_{t-1}^2 for t >= 2.
    Hint: a Python loop is fine; scipy.signal.lfilter is faster.
    """
    raise NotImplementedError


def garch11_loglik(params, r: np.ndarray, backcast: float | None = None) -> float:
    """Full Gaussian log-likelihood (notes eq. 13.20), including the constant:

        L(theta) = -1/2 sum_{t=1}^n [ ln(2 pi) + ln sigma_t^2 + r_t^2 / sigma_t^2 ]

    params = (omega, alpha, beta).
    """
    raise NotImplementedError


def fit_garch11(r: np.ndarray, backcast: float | None = None) -> dict:
    """Maximum-likelihood estimates. Return dict(omega, alpha, beta, loglik).

    Enforce omega > 0, alpha >= 0, beta >= 0, alpha + beta < 1, either with
    bounds/constraints or (recommended) an unconstrained reparametrisation such as
    omega = s exp(t0), alpha + beta = logistic(t1), alpha / (alpha + beta) = logistic(t2).
    Use scipy.optimize.minimize from more than one starting point.
    """
    raise NotImplementedError


def unconditional_variance(omega: float, alpha: float, beta: float) -> float:
    """sigma_bar^2 = omega / (1 - alpha - beta) (Proposition 13.10). Raise ValueError if alpha + beta >= 1."""
    raise NotImplementedError


def garch_half_life(alpha: float, beta: float) -> float:
    """Half-life of a variance shock: h solving (alpha + beta)^h = 1/2 (eq. 13.18).
    (Not the OU half-life ln 2 / kappa of M10/M13A, toolkit.ou.ou_half_life.)"""
    raise NotImplementedError


def garch11_forecast(omega: float, alpha: float, beta: float, sigma2_next: float, h: int) -> np.ndarray:
    """Multi-step forecasts E_T[sigma_{T+k}^2], k = 1..h (eq. 13.17):

        sigma_bar^2 + (alpha + beta)^{k-1} (sigma_{T+1}^2 - sigma_bar^2).
    """
    raise NotImplementedError


def mc_forecast(omega: float, alpha: float, beta: float, sigma2_next: float, h: int,
                n_paths: int = 200_000, seed: int = 0) -> np.ndarray:
    """Monte Carlo estimate of E_T[eps_{T+k}^2], k = 1..h.

    Start every path at sigma_{T+1}^2 = sigma2_next; at each step draw
    eps^2 = sigma^2 z^2, record its cross-path mean, then update
    sigma^2 <- omega + alpha eps^2 + beta sigma^2. Vectorise over paths.
    """
    raise NotImplementedError

No runs yet

The stub's functions raise NotImplementedError until you write them. Fill in one, press Run tests, and watch its tests turn green.

Reference solution

Available after your first run, with an account approved for code runs.

Create a free account

Reading is open to everyone. An account keeps your progress, answers, notes and code drafts, and gives you the study plan, the journals and the simulator. Running code needs an account approved for it.