Markets: A Rigorous Course

Exercise · m13_hamilton.py

Two-state Markov-switching model: Hamilton filter and Kim smoother

Model: y_t | s_t = j ~ N(mu_j, sigma_j^2), j in {0, 1} P[i, j] = Pr(s_t = j | s_{t-1} = i) (rows sum to 1)

Convention: arrays are indexed t = 0..n-1. filtered[t] = Pr(s_t | y_0..y_t) predicted[t] = Pr(s_t | y_0..y_{t-1}) (predicted[0] = initial distribution) smoothed[t] = Pr(s_t | y_0..y_{n-1}) Each is an (n, 2) array whose rows sum to 1.

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

Tests (6)

  • test_ergodic
  • test_filter_matches_statsmodels
  • test_probabilities_are_distributions
  • test_filtered_probs_use_only_past_data
  • test_regime_classification_and_smoothing_gain
  • test_mle_recovers_parameters

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

not run yet

Not saved: sign up to keep your code
"""M13 · Two-state Markov-switching model: Hamilton filter and Kim smoother. Read notes §13.5.

Model:
    y_t | s_t = j  ~  N(mu_j, sigma_j^2),   j in {0, 1}
    P[i, j] = Pr(s_t = j | s_{t-1} = i)      (rows sum to 1)

Convention: arrays are indexed t = 0..n-1.
    filtered[t]  = Pr(s_t | y_0..y_t)
    predicted[t] = Pr(s_t | y_0..y_{t-1})   (predicted[0] = initial distribution)
    smoothed[t]  = Pr(s_t | y_0..y_{n-1})
Each is an (n, 2) array whose rows sum to 1.

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

from __future__ import annotations

import numpy as np


def ergodic_probs(P: np.ndarray) -> np.ndarray:
    """Stationary distribution pi (pi' P = pi', sum pi = 1). For 2 states pi_0 = (1 - p11) / (2 - p00 - p11)."""
    raise NotImplementedError


def simulate_ms(n: int, mus, sigmas, P, seed: int = 0) -> tuple[np.ndarray, np.ndarray]:
    """Simulate (y, s) of length n, s_0 drawn from the ergodic distribution. (Given; uses ergodic_probs.)"""
    rng = np.random.default_rng(seed)
    P = np.asarray(P, dtype=float)
    mus = np.asarray(mus, dtype=float)
    sigmas = np.asarray(sigmas, dtype=float)
    s = np.empty(n, dtype=int)
    s[0] = rng.choice(2, p=ergodic_probs(P))
    u = rng.random(n)
    for t in range(1, n):
        s[t] = int(u[t] > P[s[t - 1], 0])
    y = mus[s] + sigmas[s] * rng.standard_normal(n)
    return y, s


def hamilton_filter(y, mus, sigmas, P, pi0=None) -> tuple[np.ndarray, np.ndarray, float]:
    """Hamilton (1989) filter, notes eqs. 13.23-13.25. Return (filtered, predicted, loglik).

    predicted[0] = pi0 (default: ergodic_probs(P));
    filtered[t]  = predicted[t] * f(y_t | s_t) / sum_j predicted[t, j] f(y_t | s_t = j);
    predicted[t+1] = P' filtered[t];
    loglik = sum_t ln sum_j predicted[t, j] f(y_t | s_t = j), f the normal density.
    filtered[t] must depend on y_0..y_t only (a test rewrites the future and checks).
    """
    raise NotImplementedError


def kim_smoother(filtered: np.ndarray, predicted: np.ndarray, P: np.ndarray) -> np.ndarray:
    """Kim (1994) backward recursion, notes eq. 13.27:

        smoothed[n-1] = filtered[n-1]
        smoothed[t, i] = filtered[t, i] * sum_j P[i, j] smoothed[t+1, j] / predicted[t+1, j]
    """
    raise NotImplementedError


def fit_ms2(y) -> dict:
    """MLE of (mu0, mu1, sigma0, sigma1, p00, p11) by maximising hamilton_filter's loglik.

    Return dict(mus, sigmas, P, loglik) with states ordered so that sigmas[0] <= sigmas[1].
    Reparametrise: sigma = exp(.), p = logistic(.). Start the two vols apart
    (e.g. 0.6 and 1.5 times the sample std) to break the label symmetry.
    """
    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.