Markets: A Rigorous Course

Exercise · m10_roll_yield.py

Roll yield and the decomposition of futures excess returns

Conventions. Continuous compounding, time in years. The cost of carry is c = r + u - y (M07): interest r, storage u, convenience yield y, all per year. A futures curve with constant carry c is F_t(T) = S_t * exp(c * (T - t)).

A fully collateralised long futures position earns total return = excess return (futures P&L / notional) + collateral return (r). Proposition 10.5: holding a contract from t to its expiry T, ln F_T(T) - ln F_t(T) = ln(S_T / S_t) - ln(F_t(T) / S_t), i.e. excess log return = spot log return + roll yield, roll yield = -(log basis at entry).

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

Tests (5)

  • test_curve_and_slope
  • test_decomposition_identity_and_expiry
  • test_contango_loss_with_flat_spot
  • test_rowwise_identity_on_synthetic_market
  • test_monte_carlo_mean_excess_equals_spot_drift_minus_carry

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

not run yet

Not saved: sign up to keep your code
"""M10 exercise: roll yield and the decomposition of futures excess returns (notes §10.3).

Conventions. Continuous compounding, time in years. The cost of carry is
c = r + u - y (M07): interest r, storage u, convenience yield y, all per year.
A futures curve with constant carry c is F_t(T) = S_t * exp(c * (T - t)).

A fully collateralised long futures position earns
    total return = excess return (futures P&L / notional) + collateral return (r).
Proposition 10.5: holding a contract from t to its expiry T,
    ln F_T(T) - ln F_t(T) = ln(S_T / S_t) - ln(F_t(T) / S_t),
i.e. excess log return = spot log return + roll yield, roll yield = -(log basis at entry).

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

from __future__ import annotations

import numpy as np
import pandas as pd


def futures_curve(spot: float, carry: float, maturities: np.ndarray) -> np.ndarray:
    """F(tau) = S * exp(carry * tau) for each time-to-maturity tau (years). Returns an array."""
    raise NotImplementedError


def curve_roll_yield(f_near: float, f_far: float, tau_near: float, tau_far: float) -> float:
    """Annualised roll yield implied by two curve points: -ln(F_far/F_near) / (tau_far - tau_near).

    Negative in contango (F_far > F_near), positive in backwardation.
    Raise ValueError if tau_far <= tau_near.
    """
    raise NotImplementedError


def decompose_excess_return(f_entry: float, f_exit: float, s_entry: float, s_exit: float) -> dict[str, float]:
    """Exact identity (notes eq. 10.8): ln(F1/F0) = ln(S1/S0) + [ln(F1/S1) - ln(F0/S0)].

    Return {'excess': ln(F1/F0), 'spot': ln(S1/S0), 'roll': ln(F1/S1) - ln(F0/S0)}.
    Held to expiry (F1 = S1) the roll term equals -ln(F0/S0).
    """
    raise NotImplementedError


def rolled_long_returns(spot: pd.Series, carry: pd.Series, dt: float) -> pd.DataFrame:
    """Per-period log returns of a long futures position rolled every period.

    At each date t_k (k = 0..n-2) buy the contract expiring at t_{k+1} = t_k + dt,
    at price F_k = S_k * exp(carry_k * dt); hold it to expiry, where it converges
    to S_{k+1}; roll. Return a DataFrame indexed by spot.index[1:] with columns
      'spot'   = ln(S_{k+1}/S_k)
      'roll'   = -carry_k * dt
      'excess' = ln(S_{k+1}/F_k)      (equals spot + roll)
    Raise ValueError if spot and carry differ in length.
    """
    raise NotImplementedError


def synthetic_curve_market(
    n: int = 240,
    dt: float = 1.0 / 12.0,
    s0: float = 100.0,
    mu: float = 0.0,
    sigma: float = 0.30,
    carry_mean: float = 0.10,
    carry_kappa: float = 2.0,
    carry_sigma: float = 0.10,
    seed: int = 0,
) -> tuple[pd.Series, pd.Series]:
    """PROVIDED (do not change). Monthly GBM spot and an OU carry process c_t (positive = contango)."""
    rng = np.random.default_rng(seed)
    z = rng.standard_normal((2, n - 1))
    logs = np.log(s0) + np.concatenate([[0.0], np.cumsum((mu - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * z[0])])
    c = np.empty(n)
    c[0] = carry_mean
    a = np.exp(-carry_kappa * dt)
    sd = carry_sigma * np.sqrt((1 - a * a) / (2 * carry_kappa))
    for k in range(1, n):
        c[k] = carry_mean + (c[k - 1] - carry_mean) * a + sd * z[1, k - 1]
    idx = pd.date_range("2006-01-31", periods=n, freq="ME")
    return pd.Series(np.exp(logs), index=idx, name="spot"), pd.Series(c, index=idx, name="carry")

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.