Exercise · m10_roll_yield.py
Roll yield and the decomposition of futures excess returns
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.