Markets: A Rigorous Course

Exercise · m10_ratio_meanrev.py

Is the gold/silver ratio mean-reverting?

Model the log ratio x_t = ln(Gold_t / Silver_t) as an Ornstein-Uhlenbeck process dx = kappa (theta - x) dt + sigma dW. Sampled every dt it is an exact AR(1): x_{k+1} = a + phi x_k + e_k, phi = exp(-kappa dt), a = theta (1 - phi), Var(e) = sigma^2 (1 - phi^2) / (2 kappa). Half-life in sampling periods: h = -ln 2 / ln phi (Proposition 10.11); in years h*dt = ln2/kappa.

Check with cd exercises && .venv/bin/python -m pytest modules/M10-commodities/tests/test_m10_ratio_meanrev.py

Tests (5)

  • test_half_life_formula
  • test_noise_free_ou_is_exact_ar1
  • test_recovers_known_kappa_from_simulated_log_ratio
  • test_adf_distinguishes_ou_from_random_walk
  • test_kendall_correction_reduces_bias

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

not run yet

Not saved: sign up to keep your code
"""M10 exercise: is the gold/silver ratio mean-reverting? (notes §10.7).

Model the log ratio x_t = ln(Gold_t / Silver_t) as an Ornstein-Uhlenbeck process
    dx = kappa (theta - x) dt + sigma dW.
Sampled every dt it is an exact AR(1):
    x_{k+1} = a + phi x_k + e_k,  phi = exp(-kappa dt),  a = theta (1 - phi),
    Var(e) = sigma^2 (1 - phi^2) / (2 kappa).
Half-life in sampling periods: h = -ln 2 / ln phi (Proposition 10.11); in years h*dt = ln2/kappa.

Check with
    cd exercises && .venv/bin/python -m pytest modules/M10-commodities/tests/test_m10_ratio_meanrev.py
"""

from __future__ import annotations

import warnings  # noqa: F401  (statsmodels may emit a FutureWarning from adfuller)

import numpy as np
from statsmodels.tsa.stattools import adfuller  # noqa: F401


def simulate_ou(kappa: float, theta: float, sigma: float, dt: float, n: int, x0: float | None = None, seed: int = 0) -> np.ndarray:
    """Exact-discretisation OU path of length n.

    x[0] = x0 (theta if None); x[k] = theta + (x[k-1]-theta)*phi + sd*eps[k-1], with
    phi = exp(-kappa dt), sd = sigma*sqrt((1-phi^2)/(2 kappa)),
    eps = np.random.default_rng(seed).standard_normal(n - 1).
    Raise ValueError unless kappa > 0, sigma >= 0, dt > 0.
    """
    raise NotImplementedError


def fit_ar1(x: np.ndarray) -> tuple[float, float, float]:
    """OLS of x[1:] on (1, x[:-1]). Return (a, phi, s), s = residual std with n-2 degrees of freedom."""
    raise NotImplementedError


def half_life(phi: float) -> float:
    """h = -ln 2 / ln phi for 0 < phi < 1, in sampling periods. Return inf if phi >= 1.

    Raise ValueError if phi <= 0 (oscillating or degenerate decay has no half-life).
    """
    raise NotImplementedError


def ou_from_ar1(a: float, phi: float, s: float, dt: float) -> tuple[float, float, float]:
    """Invert the exact AR(1) map. Return (kappa, theta, sigma):
    kappa = -ln(phi)/dt, theta = a/(1-phi), sigma = s*sqrt(2 kappa/(1-phi^2)).
    Raise ValueError unless 0 < phi < 1.
    """
    raise NotImplementedError


def kendall_corrected_phi(phi_hat: float, n: int) -> float:
    """First-order bias correction: E[phi_hat] ~ phi - (1 + 3 phi)/n  =>  phi ~ (n phi_hat + 1)/(n - 3).

    n is the number of regression observations.
    """
    raise NotImplementedError


def adf(x: np.ndarray, regression: str = "c", maxlag: int | None = None) -> tuple[float, float]:
    """Augmented Dickey-Fuller test via statsmodels.adfuller. Return (statistic, p-value). H0: unit root.

    If maxlag is None use autolag='AIC'; otherwise use exactly maxlag lags (autolag=None).
    """
    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.