Markets: A Rigorous Course

Exercise · m13a_ou.py

Ornstein–Uhlenbeck estimation, half-life and Bertram trading bands

Model: dX = kappa (theta - X) dt + sigma dW, kappa > 0. Exact discretisation at step dt (M10 eq. 10.18, notes eq. 13A.23): X_{t+dt} = a + b X_t + e, b = exp(-kappa dt), a = theta (1 - b), Var(e) = sigma^2 (1 - b^2) / (2 kappa).

Provided from toolkit.ou (the M10 functions, same conventions as M10): simulate_ou(kappa, theta, sigma, dt, n, x0=None, seed=0) n points, X_0 = x0 (theta if None) fit_ar1(x, ddof=2) -> (a, b, s) OLS of x_{t+1} on [1, x_t], s^2 = SSR / (m - ddof) ou_from_ar1(a, b, s, dt) -> (kappa, theta, sigma) ou_half_life(kappa) = ln 2 / kappa (M10 eq. 10.19)

Run: cd exercises && .venv/bin/python -m pytest modules/M13A-cointegration-stat-arb/tests/test_m13a_ou.py

Tests (8)

  • test_fit_recovers_truth
  • test_fit_rejects_unit_root_coefficient
  • test_cycle_time_is_sum_of_passage_times
  • test_passage_time_solves_ode
  • test_optimal_symmetric_band_first_order_condition_and_maximum
  • test_optimal_band_widens_with_cost
  • test_backtest_fine_grid_matches_theory
  • test_backtest_hand_path

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

not run yet

Not saved: sign up to keep your code
"""M13A · Ornstein–Uhlenbeck estimation, half-life and Bertram trading bands. Read notes §13A.7–13A.8 first.

Model: dX = kappa (theta - X) dt + sigma dW, kappa > 0.
Exact discretisation at step dt (M10 eq. 10.18, notes eq. 13A.23):
    X_{t+dt} = a + b X_t + e,  b = exp(-kappa dt),  a = theta (1 - b),
    Var(e) = sigma^2 (1 - b^2) / (2 kappa).

Provided from toolkit.ou (the M10 functions, same conventions as M10):
    simulate_ou(kappa, theta, sigma, dt, n, x0=None, seed=0)  n points, X_0 = x0 (theta if None)
    fit_ar1(x, ddof=2) -> (a, b, s)    OLS of x_{t+1} on [1, x_t], s^2 = SSR / (m - ddof)
    ou_from_ar1(a, b, s, dt) -> (kappa, theta, sigma)
    ou_half_life(kappa) = ln 2 / kappa  (M10 eq. 10.19)

Run:  cd exercises && .venv/bin/python -m pytest modules/M13A-cointegration-stat-arb/tests/test_m13a_ou.py
"""

from __future__ import annotations

import numpy as np
from scipy.optimize import brentq  # noqa: F401  (useful for the optimal band)
from scipy.special import erfcx, erfi  # noqa: F401
from scipy.integrate import quad  # noqa: F401

from toolkit.ou import fit_ar1, ou_from_ar1, ou_half_life, simulate_ou  # noqa: F401  (provided, M10)


def fit_ou(x: np.ndarray, dt: float):
    """Conditional (exact-discretisation) Gaussian MLE of (kappa, theta, sigma) (Prop. 13A.7, eq. 13A.24).

    Regress x_{t+1} on [1, x_t] by OLS to get a, b and the residual variance
    s2 = SSR / n (MLE divisor, n = number of transitions). Then
        kappa = -ln(b)/dt,  theta = a/(1-b),  sigma = sqrt(2 kappa s2 / (1 - b^2)).
    Requires 0 < b < 1; raise ValueError otherwise. Returns (kappa, theta, sigma).
    This is M10's fit_ar1 + ou_from_ar1 with the MLE divisor: which ddof is that?
    """
    raise NotImplementedError


def mean_passage_time(x0: float, level: float, kappa: float, theta: float, sigma: float) -> float:
    """E[first time X hits `level` | X_0 = x0] for the OU process (Prop. 13A.9, eq. 13A.27).

    With z = (x - theta) sqrt(kappa)/sigma:
      x0 < level:  (sqrt(pi)/kappa) * int_{z0}^{zl} erfcx(-z) dz
      x0 > level:  (sqrt(pi)/kappa) * int_{zl}^{z0} erfcx(+z) dz
    where erfcx(u) = exp(u^2) erfc(u) (scipy.special.erfcx). Use scipy.integrate.quad.
    Return 0.0 if x0 == level.
    """
    raise NotImplementedError


def bertram_cycle_time(a: float, m: float, kappa: float, sigma: float, theta: float = 0.0) -> float:
    """Expected length of one trade cycle a -> m -> a (eq. 13A.28):
        (pi/kappa) [erfi((m-theta) sqrt(kappa)/sigma) - erfi((a-theta) sqrt(kappa)/sigma)].
    """
    raise NotImplementedError


def bertram_rate(a: float, m: float, kappa: float, sigma: float, cost: float, theta: float = 0.0) -> float:
    """Expected profit per unit time of the cycle strategy: (m - a - cost) / cycle time (eq. 13A.26)."""
    raise NotImplementedError


def bertram_optimal_symmetric(kappa: float, sigma: float, cost: float, theta: float = 0.0):
    """Optimal symmetric band a = theta - d, m = theta + d maximising bertram_rate (Prop. 13A.11, eq. 13A.29).

    In u = d sqrt(kappa)/sigma and c_hat = cost sqrt(kappa)/sigma the first-order condition is
        (2u - c_hat) exp(u^2) = sqrt(pi) erfi(u),
    with a unique root u* > c_hat/2. Solve it with brentq (bracket: lower end max(c_hat/2, 1e-9);
    double the upper end until the left side exceeds the right). Returns (a*, m*, rate*).
    """
    raise NotImplementedError


def band_backtest(x: np.ndarray, a: float, m: float, cost: float):
    """Discretely monitored one-sided cycle strategy on a path x (notes §13A.8.4).

    Flat -> go long one unit of the spread at the first t with x_t <= a (fill at x_t);
    long -> exit at the first t (at or after entry) with x_t >= m (fill at x_t). Repeat from the
    exit index. A trade still open at the end is dropped.
    Returns dict with
        'pnl':     array of completed-trade P&Ls, x_exit - x_entry - cost
        'entries': int array of entry indices of the completed trades
        'exits':   int array of exit indices
    """
    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.