Python Code for Portfolio Optimization Chapter 15 – Pairs Trading Portfolios
Daniel P. Palomar (2025). Portfolio Optimization: Theory and Application. Cambridge University Press.
Last update: November 1, 2026
Contributors:
- Daniel Palomar
- DeepSeek V4.1 Flash (DeepSeek, via OpenCode)
Libraries¶
The following libraries are used in the examples:
# Core numerical computing
import numpy as np
import pandas as pd
import warnings
warnings.filterwarnings('ignore')
# Cointegration and time series analysis
import statsmodels.api as sm
from statsmodels.tsa.stattools import coint
from statsmodels.tsa.vector_ar.vecm import coint_johansen
# Market data download (only used for the SPY-IVV-VOO triplet)
import yfinance as yf
# Plotting
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="darkgrid")
# color palette (the same used in the R companion)
BLUE, RED, BLACK = "#2980b9", "#c0392b", "black"
$\def\bm#1{\boldsymbol{#1}}$ $\def\textm#1{\textsf{#1}}$ $\def\T{{\mkern-2mu\raise-1mu\mathsf{T}}}$ $\def\R{\mathbb{R}}$ $\def\E{\rm I\kern-.2em E}$ $\def\w{\bm{w}}$ $\def\bmu{\bm{\mu}}$ $\def\bSigma{\bm{\Sigma}}$
Helper functions¶
The chapter needs a small toolkit that is shared by all the experiments: a cointegration test, a rolling (causal) $z$-score à la Bollinger Bands, the sizing rules that turn a $z$-score into a trading signal, the P&L accounting, and the two estimators of the hedge ratio $\gamma$ (rolling least squares and the Kalman filter). All the functions below take and return plain arrays or pandas objects, so that they can be reused unchanged in every experiment.
The prices used in the experiments are stored in prices-pairs-trading.csv (adjusted closing
prices downloaded from Yahoo Finance), so the results do not depend on when the notebook is run:
that is what the R companion does with prices-pairs-trading.RData.
Cointegration test¶
Python's statsmodels provides the Engle--Granger procedure in coint(), which regresses the
first series on the second, tests the residuals for a unit root, and reports the
Phillips--Ouliaris statistic with the MacKinnon critical values (the analogue of R's egcm(),
which prints a whole battery of unit-root tests of the residuals). The test is applied to the
levels of the prices, as usual:
def engle_granger_test(y1, y2, autolag="aic"):
"""
Engle--Granger cointegration test.
Estimates the static regression y1 = mu + gamma*y2 + eps by OLS and tests
the residuals eps for a unit root.
Parameters
----------
y1 : array-like
Length-T series (e.g., log-prices) of the first asset.
y2 : array-like
Length-T series of the second asset.
autolag : str or None, default "aic"
Lag selection of the ADF regression on the residuals.
Returns
-------
dict
With the entries ``mu`` and ``gamma`` (the OLS estimates), ``residuals``,
``statistic`` and ``pvalue`` of the test, and ``crit`` (critical values).
"""
a = np.asarray(y1, dtype=float)
b = np.asarray(y2, dtype=float)
ols = sm.OLS(a, sm.add_constant(b)).fit()
mu, gamma = ols.params
stat, pvalue, crit = coint(a, b, trend="c", autolag=autolag)
return {
"mu": mu,
"gamma": gamma,
"residuals": a - mu - gamma * b,
"statistic": stat,
"pvalue": pvalue,
"crit": dict(zip(["1%", "5%", "10%"], crit)),
}
def report_engle_granger(res):
"""Print a compact summary of an Engle--Granger test."""
print(f"Estimated relation: y1 = {res['gamma']:.4f} * y2 {res['mu']:+.4f}")
print(f"Statistic on the residuals: {res['statistic']:.3f} "
f"p-value: {res['pvalue']:.4f}")
print("Critical values: " + ", ".join(f"{k}: {v:.3f}" for k, v in res["crit"].items()))
if res["pvalue"] < 0.05:
print("Conclusion: reject the null of no cointegration at the 5% level "
"(the series appear to be cointegrated).")
else:
print("Conclusion: cannot reject the null of no cointegration at the 5% level "
"(the series do not appear to be cointegrated).")
$z$-score and sizing signals¶
The $z$-score cannot be computed with the sample mean and standard deviation of the whole series,
as that is not causal. Instead, it is computed on a rolling window,
$$
z^\textm{score}_t = \frac{z_t - \hat{m}_t}{\hat{\sigma}_t},
$$
where $\hat{m}_t$ and $\hat{\sigma}_t$ are the rolling mean and standard deviation of the spread
over the last lookback points (this is exactly the normalization behind Bollinger Bands). The
R companion uses TTR::BBands(), which computes the population standard deviation of the
window; pandas' std() uses by default the sample standard deviation (ddof=1), hence the
ddof=0 below:
def bollinger_zscore(spread, lookback=20):
"""
Rolling z-score of the spread (Bollinger Bands normalization).
Parameters
----------
spread : array-like
Length-T spread series.
lookback : int, default 20
Length of the rolling window.
Returns
-------
np.ndarray
Length-T z-score (NaN for the first ``lookback - 1`` points).
"""
s = pd.Series(np.asarray(spread, dtype=float))
mavg = s.rolling(lookback).mean()
sdev = s.rolling(lookback).std(ddof=0) # population SD, as TTR::BBands
return ((s - mavg) / sdev).to_numpy()
def generate_thresholded_signal(z_score, s0=1.0, start_signal_at=0):
"""
Thresholded (all-in/all-out) sizing signal.
The signal is +1 when the z-score drops below -s0 (buy the spread), -1 when
it rises above +s0 (short-sell the spread), and the position is kept while
the z-score has not reverted to zero (after which it is closed).
Parameters
----------
z_score : array-like
Length-T z-score of the spread.
s0 : float, default 1.0
Entry threshold.
start_signal_at : int, default 0
Index from which the signal starts being generated.
Returns
-------
np.ndarray
Length-T signal with values in {-1, 0, +1}.
"""
z = np.asarray(z_score, dtype=float)
T = len(z)
signal = np.zeros(T)
for t in range(start_signal_at, T):
if z[t] < -s0: # buy
signal[t] = 1
elif z[t] > s0: # short-sell
signal[t] = -1
elif t > start_signal_at: # maintain or close the position
if signal[t - 1] == 1 and z[t] < 0:
signal[t] = signal[t - 1]
elif signal[t - 1] == -1 and z[t] > 0:
signal[t] = signal[t - 1]
return signal
def generate_linear_signal(z_score, s0=1.0):
"""
Linear (contrarian) sizing signal s_t = -[z_t^{score} / s0]_{-1}^{+1}.
Parameters
----------
z_score : array-like
Length-T z-score of the spread.
s0 : float, default 1.0
Threshold at which the position is fully leveraged.
Returns
-------
np.ndarray
Length-T signal with values in [-1, +1].
"""
return np.clip(-np.asarray(z_score, dtype=float) / s0, -1.0, 1.0)
def generate_bb_linear_signal(spread, s0=1.0, lookback=20, start_signal_at=None):
"""
Linear sizing signal computed from the rolling (Bollinger) z-score.
Parameters
----------
spread : array-like
Length-T spread series.
s0 : float, default 1.0
Threshold at which the position is fully leveraged.
lookback : int, default 20
Length of the rolling window of the z-score.
start_signal_at : int or None, default None
Index from which the signal starts being generated (defaults to
``lookback``).
Returns
-------
dict
With the entries ``signal`` and ``z_score``.
"""
z = bollinger_zscore(spread, lookback)
if start_signal_at is None:
start_signal_at = lookback
signal = generate_linear_signal(z, s0)
signal[:start_signal_at] = 0
signal[np.isnan(signal)] = 0
return {"signal": signal, "z_score": z}
def generate_bb_thresholded_signal(spread, entry_zscore=1.0, exit_zscore=0.0,
lookback=20, start_signal_at=None):
"""
Thresholded sizing signal computed from the rolling (Bollinger) z-score.
Parameters
----------
spread : array-like
Length-T spread series.
entry_zscore : float, default 1.0
Entry threshold.
exit_zscore : float, default 0.0
Exit threshold (position closed when the z-score reverts to it).
lookback : int, default 20
Length of the rolling window of the z-score.
start_signal_at : int or None, default None
Index from which the signal starts being generated (defaults to
``lookback``).
Returns
-------
dict
With the entries ``signal`` and ``z_score``.
"""
z = bollinger_zscore(spread, lookback)
T = len(z)
signal = np.zeros(T)
if start_signal_at is None:
start_signal_at = lookback
start_signal_at = max(start_signal_at, lookback)
for t in range(start_signal_at, T):
if z[t] < -entry_zscore: # buy
signal[t] = 1
elif z[t] > entry_zscore: # short-sell
signal[t] = -1
elif t > start_signal_at: # maintain or close the position
if signal[t - 1] == 1 and z[t] < -exit_zscore:
signal[t] = signal[t - 1]
elif signal[t - 1] == -1 and z[t] > exit_zscore:
signal[t] = signal[t - 1]
return {"signal": signal, "z_score": z}
P&L accounting¶
If the hedge ratio is constant, the P&L of a signal $s_t$ on a spread $z_t$ can be accumulated as
$$
\textm{P\&L}_t = \sum_{\tau=1}^{t} s_{\tau-1} \left(z_\tau - z_{\tau-1}\right),
$$
where the one-period delay of the signal reflects that the position is entered at the previous
close. When the hedge ratio is time-varying (rolling least squares, Kalman filtering), the
spread is itself a function of time and its difference also contains the change of the weights,
$\w_t^\T\bm{y}_t-\w_{t-1}^\T\bm{y}_t$, which is not a trading profit. In that case the P&L must
be accumulated with the weights held during each period,
$$
\textm{P\&L}_t = \sum_{\tau=1}^{t} s_{\tau-1}\; \w^\T \left(\bm{y}_\tau - \bm{y}_{\tau-1}\right).
$$
If the weights of day $t$ are estimated with the price of day $t$ (as in rolling least squares),
the weights held over day $t$ are those of day $t-1$ (lag_w=True); if they are known at the close
of day $t-1$ (as in the one-step-ahead Kalman estimate), no lag is needed:
def compute_cum_pnl_spread_trading(spread, signal, compounded=False):
"""
Cumulative P&L of a signal traded on a spread with constant weights.
Parameters
----------
spread : array-like or pd.Series
Length-T spread series.
signal : array-like
Length-T sizing signal.
compounded : bool, default False
If True, compound the returns; otherwise accumulate them.
Returns
-------
pd.Series
Length-T cumulative P&L (indexed as ``spread`` when it is a Series).
"""
idx = spread.index if isinstance(spread, pd.Series) else None
z = np.asarray(spread, dtype=float)
s = np.asarray(signal, dtype=float)
T = len(z)
signal_delayed = np.concatenate(([0.0], s[:-1]))
spread_ret = np.concatenate(([0.0], np.diff(z)))
portf_ret = signal_delayed * spread_ret
cum = np.cumprod(1 + portf_ret) - 1 if compounded else np.cumsum(portf_ret)
return pd.Series(cum, index=idx)
def compute_cum_pnl_weights(y, w, signal, lag_w=False, compounded=False):
"""
Cumulative P&L of a signal traded with time-varying weights.
Parameters
----------
y : array-like or pd.DataFrame
T x N prices (log-prices) of the assets.
w : array-like
T x N weights (with leverage one) of the spread at each period.
signal : array-like
Length-T sizing signal.
lag_w : bool, default False
If True, the weights held during period t are those of period t-1.
compounded : bool, default False
If True, compound the returns; otherwise accumulate them.
Returns
-------
pd.Series
Length-T cumulative P&L (indexed as ``y`` when it has an index).
"""
idx = y.index if isinstance(y, pd.DataFrame) else None
Y = np.asarray(y, dtype=float)
W = np.asarray(w, dtype=float)
s = np.asarray(signal, dtype=float)
T = Y.shape[0]
if lag_w:
W = np.vstack([W[:1], W[:-1]])
signal_delayed = np.concatenate(([0.0], s[:-1]))
assets_ret = np.vstack([np.zeros((1, Y.shape[1])), np.diff(Y, axis=0)])
portf_ret = signal_delayed * np.sum(W * assets_ret, axis=1)
cum = np.cumprod(1 + portf_ret) - 1 if compounded else np.cumsum(portf_ret)
return pd.Series(cum, index=idx)
Hedge ratio: rolling least squares and Kalman filter¶
The (possibly time-varying) hedge ratio can be estimated either by refitting the regression $y_{1t} \approx \mu_t + \gamma_t\, y_{2t}$ on a rolling window (rolling least squares), or by modeling the two parameters as random-walk states in a state space model and applying the Kalman filter. The latter is implemented below with a plain recursion: calling $\bm{\alpha}_t=(\mu_t, \gamma_t)$, $$ \begin{aligned} y_{1t} &= \begin{bmatrix} 1 & y_{2t}\end{bmatrix}\bm{\alpha}_t + \epsilon_t,\\ \bm{\alpha}_{t+1} &= \bm{\alpha}_t + \bm{\eta}_t, \end{aligned} $$ with $\epsilon_t\sim\mathcal{N}(0,\sigma_\epsilon^2)$ and $\bm{\eta}_t\sim\mathcal{N}(\bm{0}, \bm{Q})$, where $\bm{Q}=\textm{diag}\left(\alpha\sigma_\epsilon^2,\; \alpha\sigma_\epsilon^2/\textm{Var}(y_2)\right)$ and $\alpha$ controls how fast the past is forgotten. The filter is initialized with the least-squares estimates on the training window. The function returns the one-step-ahead predictions $\mu_{t|t-1}$ and $\gamma_{t|t-1}$, which are the quantities available when trading on day $t$:
def fit_rolling_ls(y1, y2, lookback=2 * 252, every=50):
"""
Rolling least-squares estimates of the hedge ratio and the intercept.
Parameters
----------
y1, y2 : pd.Series
Log-prices of the two assets.
lookback : int, default 2*252
Length of the rolling window.
every : int, default 50
Number of periods between re-estimations.
Returns
-------
pd.DataFrame
With the columns ``gamma`` and ``mu``, indexed as ``y1``.
"""
idx = y1.index if isinstance(y1, pd.Series) else None
a = np.asarray(y1, dtype=float)
b = np.asarray(y2, dtype=float)
T = len(a)
t_update = list(range(lookback - 1, T, every))
if t_update[-1] != T - 1:
t_update.append(T - 1)
gamma = np.full(T, np.nan)
mu = np.full(T, np.nan)
for t in t_update:
window = slice(t - lookback + 1, t + 1)
ols = sm.OLS(a[window], sm.add_constant(b[window])).fit()
mu[t], gamma[t] = ols.params
# carry the last estimate forward and the first one backward
est = pd.DataFrame({"mu": mu, "gamma": gamma}).ffill().bfill()
est.index = idx
return est
def kalman_hedge_ratio(y1, y2, T_trn, alpha=5e-5):
"""
Kalman filter for a time-varying hedge ratio (random-walk states).
State space model
y1[t] = mu[t] + gamma[t]*y2[t] + eps[t], eps[t] ~ N(0, var_eps)
(mu, gamma)[t+1] = (mu, gamma)[t] + eta[t],
with Q = diag(alpha*var_eps, alpha*var_eps/var_y2). The parameters are
initialized with the least-squares estimates on the first ``T_trn`` points.
Parameters
----------
y1, y2 : pd.Series
Log-prices of the two assets.
T_trn : int
Length of the training window used for the initialization.
alpha : float, default 5e-5
Forgetting factor of the random-walk states.
Returns
-------
pd.DataFrame
With the columns ``mu`` and ``gamma`` (the one-step-ahead predictions
mu[t|t-1] and gamma[t|t-1]), indexed as ``y1``.
"""
idx = y1.index if isinstance(y1, pd.Series) else None
a = np.asarray(y1, dtype=float)
b = np.asarray(y2, dtype=float)
T = len(a)
# initial least squares on the training window
ac = a[:T_trn] - a[:T_trn].mean()
bc = b[:T_trn] - b[:T_trn].mean()
gamma_hat = float(bc @ ac) / float(bc @ bc)
mu_hat = float(np.mean(a[:T_trn] - gamma_hat * b[:T_trn]))
# Kalman parameters
eps_hat = a[:T_trn] - mu_hat - gamma_hat * b[:T_trn]
var_eps = float(np.var(eps_hat, ddof=1))
var_y2 = float(np.var(b[:T_trn], ddof=1))
x = np.array([mu_hat, gamma_hat]) # state a priori
V = np.diag([var_eps / T_trn, var_eps / var_y2 / T_trn]) # covariance a priori
Q = np.diag([alpha * var_eps, alpha * var_eps / var_y2]) # state noise
R = var_eps # observation noise
mu = np.empty(T)
gamma = np.empty(T)
for t in range(T):
mu[t], gamma[t] = x # one-step-ahead prediction
Z = np.array([1.0, b[t]]) # observation matrix
F = Z @ V @ Z + R # prediction variance of y1[t]
K = V @ Z / F # Kalman gain
x = x + K * (a[t] - Z @ x) # update with y1[t]
V = V - np.outer(K, Z @ V) + Q # transition (identity) and state noise
est = pd.DataFrame({"mu": mu, "gamma": gamma})
est.index = idx
return est
def spread_and_weights(y1, y2, gamma, mu):
"""
Normalized spread with leverage one and the corresponding weights.
Computes z[t] = (y1[t] - gamma[t]*y2[t] - mu[t])/(1 + gamma[t]) and the
weights w[t] = (1, -gamma[t])/(1 + gamma[t]), so that z[t] = w[t]^T y[t].
Parameters
----------
y1, y2 : array-like or pd.Series
Log-prices of the two assets.
gamma, mu : array-like
Hedge ratio and intercept (possibly time-varying).
Returns
-------
spread : pd.Series
Length-T normalized spread.
w : np.ndarray
T x 2 weights of the spread.
"""
idx = y1.index if isinstance(y1, pd.Series) else None
a = np.asarray(y1, dtype=float)
b = np.asarray(y2, dtype=float)
T = len(a)
g = np.full(T, float(gamma)) if np.ndim(gamma) == 0 else np.asarray(gamma, dtype=float)
m = np.full(T, float(mu)) if np.ndim(mu) == 0 else np.asarray(mu, dtype=float)
spread = pd.Series((a - g * b - m) / (1 + g), index=idx)
w = np.column_stack([np.ones_like(g), -g]) / (1 + g)[:, None]
return spread, w
def plot_pair_trading(spread, z_score, signal, cumret, entry_zscore=1.0,
T_trn=None, figsize=(12, 11)):
"""
Four stacked panels: spread, z-score, signal, and cumulative P&L.
Parameters
----------
spread : array-like or pd.Series
Spread series (its index is used as the x-axis when available).
z_score : array-like
z-score of the spread.
signal : array-like
Sizing signal.
cumret : pd.Series
Cumulative P&L.
entry_zscore : float, default 1.0
Entry threshold drawn in the z-score panel.
T_trn : int or None, default None
If given, marks the end of the training window in the spread panel.
figsize : tuple, default (12, 11)
Size of the figure.
"""
x = spread.index if isinstance(spread, pd.Series) else np.arange(len(spread))
spread = np.asarray(spread, dtype=float)
_, axes = plt.subplots(4, 1, figsize=figsize, sharex=True)
axes[0].plot(x, spread, color=BLUE, lw=0.8)
axes[0].set_title("Spread")
if T_trn is not None:
axes[0].axvline(x[T_trn], color=BLACK, ls="--", lw=1)
ytop = np.nanmax(spread)
axes[0].text(x[T_trn], ytop, "training ", ha="right", va="top", fontsize=9)
axes[0].text(x[T_trn], ytop, " out-of-sample", ha="left", va="top", fontsize=9)
axes[1].plot(x, np.asarray(z_score), color=BLUE, lw=0.8)
axes[1].axhline(0, color=BLACK, lw=1)
axes[1].axhline(-entry_zscore, color=BLACK, ls="--", lw=1)
axes[1].axhline(entry_zscore, color=BLACK, ls="--", lw=1)
axes[1].set_title("Z-score")
axes[2].plot(x, np.asarray(signal), color=BLACK, lw=0.8)
axes[2].set_title("Signal")
axes[3].plot(cumret.index, cumret.values, color=BLUE, lw=0.8)
axes[3].set_title("Cumulative return")
for ax in axes:
ax.set_xlabel(None)
plt.tight_layout()
plt.show()
def plot_comparison(series_a, series_b, label_a, label_b, title, ylim=None,
linestyles=("solid", "solid"), figsize=(12, 5)):
"""
Two time series in the same panel (rolling least squares versus Kalman).
Parameters
----------
series_a, series_b : pd.Series
The two series to compare.
label_a, label_b : str
Legend labels.
title : str
Title of the panel.
ylim : tuple or None, default None
Limits of the vertical axis.
linestyles : tuple of str, default ("solid", "solid")
Line styles of the two series.
figsize : tuple, default (12, 5)
Size of the figure.
"""
_, ax = plt.subplots(figsize=figsize)
ax.plot(series_a.index, series_a.values, color=BLUE, lw=0.8,
ls=linestyles[0], label=label_a)
ax.plot(series_b.index, series_b.values, color=RED, lw=0.8,
ls=linestyles[1], label=label_b)
ax.set_title(title)
ax.set_xlabel(None)
if ylim is not None:
ax.set_ylim(ylim)
ax.legend(loc="best", frameon=True, fontsize=9)
plt.tight_layout()
plt.show()
Introduction¶
Pairs trading is a relative-value strategy: rather than betting on the direction of a single asset, it bets on the distance between two assets, buying the one that has fallen and short-selling the one that has risen, in the expectation that the distance will revert. The statistical concept behind this idea is cointegration, and the practical difficulty is that the relationship between the two assets (the hedge ratio) is not known and changes over time.
The notebook follows the chapter: we first review stationarity and the difference between correlation and cointegration, then run cointegration tests, then design and backtest spread trading strategies on synthetic data and on real pairs (EWA--EWC and KO--PEP), and finally replace rolling least squares with a Kalman filter to track the hedge ratio.
Stationarity¶
Stationarity refers to the property that the statistics of a time series remain fixed over time. In that sense, a stationary time series can be considered mean-reverting. The next two examples contrast a random walk (nonstationary, unit root) with a stationary autoregressive sequence:
# generate synthetic data
T = 200
rng = np.random.default_rng(421)
y = np.zeros(T)
for t in range(1, T):
y[t] = y[t - 1] + rng.normal()
_, ax = plt.subplots(figsize=(12, 4))
ax.plot(np.arange(1, T + 1), y, color=BLUE, lw=1)
ax.set_title("Random walk")
ax.set_xlabel(None)
plt.tight_layout()
plt.show()
The same noise sequence, but passed through an autoregressive recursion with coefficient $0.2$, is stationary instead:
# generate synthetic data
T = 200
rng = np.random.default_rng(421)
y = np.zeros(T)
for t in range(1, T):
y[t] = 0.2 * y[t - 1] + rng.normal()
_, ax = plt.subplots(figsize=(12, 4))
ax.plot(np.arange(1, T + 1), y, color=BLUE, lw=1)
ax.set_title("AR(1) sequence")
ax.set_xlabel(None)
plt.tight_layout()
plt.show()
Correlation versus cointegration¶
Cointegration refers to a property by which two (or more) assets, while not being mean-reverting individually, may be mean-reverting with respect to each other. This commonly happens when the series themselves contain stochastic trends (i.e., they are nonstationary) but nevertheless they move closely together over time in a way that their difference remains stable (i.e., stationary). Thus, the concept of cointegration mimics the existence of a long-run equilibrium to which an economic system converges over time.
Correlation is a basic concept in probability that refers to how correlated two random variables are. We can use this measure for stationary time series but definitely not with nonstationary time series. In fact, when we refer to correlation between two financial assets, we are actually employing this concept to the returns of the assets and not the price values.
Thus, both correlation and cointegration attempt to measure the same concept of co-movement of time series, but they do it in a very different way, namely:
Correlation is high when the two time series co-move (they move simultaneously in the same direction) and zero when they move independently.
Cointegration is high when the two time series move together and remain close to each other, and inexistent when they do not stay together.
The first example shows two cointegrated series whose returns are almost uncorrelated:
# generate synthetic data
rng = np.random.default_rng(421)
T = 200
w = rng.normal(0, 0.1, size=T)
x = np.cumsum(w)
y1 = x + rng.normal(0, 0.2, size=T)
y2 = x + rng.normal(0, 0.2, size=T)
spread = y1 - y2
print(f"Correlation of the differences: {np.corrcoef(np.diff(y1), np.diff(y2))[0, 1]:.3f}")
_, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4), gridspec_kw={"width_ratios": [2, 1]})
ax1.plot(np.arange(1, T + 1), y1, label="y1")
ax1.plot(np.arange(1, T + 1), y2, label="y2")
ax1.plot(np.arange(1, T + 1), spread, label="spread")
ax1.set_title("Time series")
ax1.set_xlabel(None)
ax1.legend(frameon=True, fontsize=9)
ax2.plot(np.diff(y1), np.diff(y2), "o", ms=4, color=BLUE, alpha=0.7)
ax2.set_xlim(-0.8, 0.8)
ax2.set_ylim(-0.8, 0.8)
ax2.set_title("Scatter plot of differences")
plt.tight_layout()
plt.show()
Correlation of the differences: 0.083
The opposite situation is also possible: two series with highly correlated differences whose distance drifts away, so that they are not cointegrated at all. Here a small deterministic drift is added to the first series:
# generate synthetic data
rng = np.random.default_rng(421)
T = 200
w = rng.normal(0, 0.3, size=T)
x = np.cumsum(w)
y1 = x + rng.normal(0, 0.05, size=T) + 0.01 * np.arange(T)
y2 = x + rng.normal(0, 0.05, size=T)
spread = y1 - y2
print(f"Correlation of the differences: {np.corrcoef(np.diff(y1), np.diff(y2))[0, 1]:.3f}")
_, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4), gridspec_kw={"width_ratios": [2, 1]})
ax1.plot(np.arange(1, T + 1), y1, label="y1")
ax1.plot(np.arange(1, T + 1), y2, label="y2")
ax1.plot(np.arange(1, T + 1), spread, label="spread")
ax1.set_title("Time series")
ax1.set_xlabel(None)
ax1.legend(frameon=True, fontsize=9)
ax2.plot(np.diff(y1), np.diff(y2), "o", ms=4, color=BLUE, alpha=0.7)
ax2.set_xlim(-0.8, 0.8)
ax2.set_ylim(-0.8, 0.8)
ax2.set_title("Scatter plot of differences")
plt.tight_layout()
plt.show()
Correlation of the differences: 0.948
Cointegration analysis¶
Cointegration tests check whether or not a linear combination of the two time series follows a stationary autoregressive model and will be mean-reverting. A time series with a unit root is nonstationary and behaves like a random walk. On the other hand, in the absence of unit roots, a time series tends to revert to its long-term mean. Thus, cointegration tests are typically implemented via unit-root stationarity tests.
Mathematically, we want to determine whether there exists a value of $\gamma$ such that the spread $$ z_{t} = y_{1t} - \gamma \, y_{2t} $$ is stationary. Note that, in practice, the mean of the spread $\mu$ (equilibrium value) is not necessarily zero and $\gamma$ does not have to be one.
Cointegrated synthetic data¶
The two synthetic series of the first example above are cointegrated by construction, and the test detects it:
# generate synthetic data
rng = np.random.default_rng(421)
T = 200
w = rng.normal(0, 0.1, size=T)
x = np.cumsum(w)
y1 = x + rng.normal(0, 0.2, size=T)
y2 = x + rng.normal(0, 0.2, size=T)
res = engle_granger_test(y1, y2)
report_engle_granger(res)
# plot residuals
_, ax = plt.subplots(figsize=(12, 4))
ax.plot(np.arange(1, T + 1), res["residuals"], color=BLUE, lw=1)
ax.set_title("Residual time series")
ax.set_xlabel(None)
plt.tight_layout()
plt.show()
Estimated relation: y1 = 0.9415 * y2 -0.0541 Statistic on the residuals: -5.831 p-value: 0.0000 Critical values: 1%: -3.952, 5%: -3.367, 10%: -3.066 Conclusion: reject the null of no cointegration at the 5% level (the series appear to be cointegrated).
Non-cointegrated synthetic data¶
For the second synthetic example (correlated differences, drifting spread) the null hypothesis of no cointegration cannot be rejected:
# generate synthetic data
rng = np.random.default_rng(421)
T = 200
w = rng.normal(0, 0.3, size=T)
x = np.cumsum(w)
y1 = x + rng.normal(0, 0.05, size=T)
y2 = x + rng.normal(0, 0.05, size=T)
y1 = y1 + 0.01 * np.arange(T)
res = engle_granger_test(y1, y2)
report_engle_granger(res)
# plot residuals
_, ax = plt.subplots(figsize=(12, 4))
ax.plot(np.arange(1, T + 1), res["residuals"], color=BLUE, lw=1)
ax.set_title("Residual time series")
ax.set_xlabel(None)
plt.tight_layout()
plt.show()
Estimated relation: y1 = 0.7655 * y2 +0.4702 Statistic on the residuals: -2.205 p-value: 0.4216 Critical values: 1%: -3.952, 5%: -3.367, 10%: -3.066 Conclusion: cannot reject the null of no cointegration at the 5% level (the series do not appear to be cointegrated).
Market data EWA--EWC¶
We now apply the test to the prices of two country ETFs, EWA (Australia) and EWC (Canada), during 2016--2019:
# load the stored adjusted prices (see the note in the Helper functions section)
prices = pd.read_csv("prices-pairs-trading.csv", index_col="date", parse_dates=True)
# to download fresh data instead:
# prices = yf.download(["EWA", "EWC"], start="2013-01-01", end="2022-12-31",
# auto_adjust=True)["Close"]
pair = prices.loc["2016":"2019", ["EWA", "EWC"]]
res = engle_granger_test(pair["EWA"], pair["EWC"])
report_engle_granger(res)
# plot residuals
_, ax = plt.subplots(figsize=(12, 4))
ax.plot(pair.index, res["residuals"], color=BLUE, lw=0.8)
ax.set_title("Residual time series")
ax.set_xlabel(None)
plt.tight_layout()
plt.show()
Estimated relation: y1 = 0.7320 * y2 -0.9229 Statistic on the residuals: -4.815 p-value: 0.0004 Critical values: 1%: -3.907, 5%: -3.342, 10%: -3.049 Conclusion: reject the null of no cointegration at the 5% level (the series appear to be cointegrated).
Market data KO--PEP¶
The same test on Coca-Cola (KO) and Pepsi (PEP) during 2017--2019 gives the opposite conclusion:
pair = prices.loc["2017":"2019", ["KO", "PEP"]]
res = engle_granger_test(pair["KO"], pair["PEP"])
report_engle_granger(res)
# plot residuals
_, ax = plt.subplots(figsize=(12, 4))
ax.plot(pair.index, res["residuals"], color=BLUE, lw=0.8)
ax.set_title("Residual time series")
ax.set_xlabel(None)
plt.tight_layout()
plt.show()
Estimated relation: y1 = 0.3611 * y2 +3.1826 Statistic on the residuals: -2.562 p-value: 0.2525 Critical values: 1%: -3.911, 5%: -3.344, 10%: -3.050 Conclusion: cannot reject the null of no cointegration at the 5% level (the series do not appear to be cointegrated).
Market data SPY--IVV--VOO¶
With more than two assets the Engle--Granger test is not enough, and one uses Johansen's test, which
looks for the number of cointegrating relations (the rank of the cointegration matrix) and returns
the corresponding vectors. We apply it to three S&P 500 ETFs (SPY, IVV, and VOO), whose prices are
downloaded here with yfinance (they are not part of the stored file):
triplet = yf.download(["SPY", "IVV", "VOO"], start="2017-01-01", end="2019-12-31",
auto_adjust=True, progress=False)["Close"]
triplet = triplet[["SPY", "IVV", "VOO"]].dropna()
# Johansen's trace test
joh = coint_johansen(triplet.values, det_order=0, k_ar_diff=1)
print(pd.DataFrame(
{"trace statistic": joh.lr1, "95% critical value": joh.cvt[:, 1]},
index=["r = 0", "r <= 1", "r <= 2"],
).round(3))
# the two cointegration vectors give two residuals
residual1 = pd.Series(triplet.values @ joh.evec[:, 0], index=triplet.index)
residual2 = pd.Series(triplet.values @ joh.evec[:, 1], index=triplet.index)
_, axes = plt.subplots(2, 1, figsize=(12, 7), sharex=True)
axes[0].plot(residual1.index, residual1.values, color=BLUE, lw=0.8)
axes[0].set_title("First residual time series")
axes[1].plot(residual2.index, residual2.values, color=BLUE, lw=0.8)
axes[1].set_title("Second residual time series")
for ax in axes:
ax.set_xlabel(None)
plt.tight_layout()
plt.show()
trace statistic 95% critical value r = 0 342.917 29.796 r <= 1 104.615 15.494 r <= 2 0.446 3.842
The trace statistic rejects $r=0$ and $r\le1$ but not $r\le2$ (compare each statistic with its critical value), so there are two cointegrating relations, as expected for three ETFs that track the same index: any two independent stationary combinations of the three prices span the same space. The test returns the corresponding cointegration vectors, which are used below to build the two residuals.
Trading the spread¶
To define a strategy to trade the spread, it suffices to determine a rule for the sizing signal $s_t$. For this purpose, it is convenient to use a normalized version of the spread, called standard score or $z$-score: $$ z^\textm{score}_t = \frac{z_t - \E[z_t]}{\sqrt{\textm{Var}(z_t)}}, $$ which has zero mean and unit variance. This $z$-score cannot be used in a real implementation since it is not causal and suffers from look-ahead bias (when estimating the mean and variance). A naive approach would be to use some training data to determine the mean and standard deviation, and then apply that to the future out-of-sample data. A more sophisticated way is to make the calculation adaptive by implementing it in a rolling fashion, e.g., via the so-called Bollinger Bands.
Bollinger Bands are a technical trading tool created by John Bollinger in the early 1980s. They arose from the need for adaptive trading bands and the observation that volatility was dynamic. They are computed on a rolling window basis over some lookback window. In particular, the rolling mean and rolling standard deviation are first computed, from which the upper and lower bands are easily obtained (typically, the mean plus/minus one or two standard deviations). In the context of the $z$-score, the spread can be adaptively normalized with the rolling mean and rolling standard deviation.
We now describe two of the simplest possible strategies for trading a spread, namely, the linear strategy and the thresholded strategy.
The linear strategy is very simple to describe based on the contrarian idea of buying low and selling high. As a first attempt, we could define the sizing signal simply as the negative $z$-score, $s_t = -z^\textm{score}_t$, to gradually scale-in and scale-out or, even better, including a scaling factor as $s_t = -z^\textm{score}_t / s_0$, where $s_0$ denotes the threshold at which the signal is fully leveraged. In practice, to limit the leverage to 1, we can project this value to lie on the interval $[-1,1]$: $$ s_t = -\left[\frac{z^\textm{score}_t}{s_0}\right]_{-1}^{+1}, $$ where $[\cdot]_a^b$ clips the argument to $a$ if below that value and to $b$ if above that value.
The thresholded strategy follows similarly the contrarian nature of buying low and selling high, but rather than linear it takes an all-in or all-out sizing based on thresholds. The simplest implementation is based on comparing the $z$-score to a threshold $s_0$: buy when the $z$-score is below $-s_0$ and short-sell when it is above $s_0$, while unwinding the position after reverting to the equilibrium value of 0. In terms of the sizing signal: $$ s_t = \begin{cases} \begin{aligned} +1 &\qquad \textm{if } z^\textm{score}_t < - s_0\\ 0 &\qquad \textm{after }z^\textm{score}_t\textm{ reverts to }0\\ -1 &\qquad \textm{if } z^\textm{score}_t > + s_0. \end{aligned} \end{cases} $$
The functions generate_linear_signal() and generate_thresholded_signal() defined above
implement these two rules on a $z$-score passed by the user, and generate_bb_linear_signal() and
generate_bb_thresholded_signal() combine them with the rolling $z$-score of a spread. We
illustrate the two rules on a synthetic spread that follows an AR(1) model, with parameters taken
from real data (KO).
Linear strategy on a synthetic spread¶
The $z$-score is computed here with the sample mean and standard deviation of the whole series, which is fine for an illustration with synthetic data; the next experiments use the rolling version:
# generate synthetic spread (log-prices) as an AR(1)
T = 200
rho = 0.7
z_mu = 0.02 # from real data KO
z_sd = 0.10 # from real data KO
phi = (1 - rho) * z_mu
sd_eps = np.sqrt(1 - rho**2) * z_sd
rng = np.random.default_rng(42)
z = np.empty(T)
z[0] = phi + rng.normal(0, sd_eps)
for t in range(1, T):
z[t] = phi + rho * z[t - 1] + rng.normal(0, sd_eps)
# compute z-score
z_score = (z - z.mean()) / z.std()
# generate signal and compute P&L
s0 = 1
signal = generate_linear_signal(z_score, s0)
portf_cumret = compute_cum_pnl_spread_trading(z, signal)
plot_pair_trading(z, z_score, signal, portf_cumret, entry_zscore=s0, figsize=(12, 9))
Thresholded strategy on a synthetic spread¶
With the same spread, the thresholded rule trades less often and stays invested only until the $z$-score crosses zero:
# generate signal and compute P&L
s0 = 1
signal = generate_thresholded_signal(z_score, s0)
portf_cumret = compute_cum_pnl_spread_trading(z, signal)
plot_pair_trading(z, z_score, signal, portf_cumret, entry_zscore=s0, figsize=(12, 9))
Market data EWA--EWC¶
We now trade the pair EWA--EWC with market data during 2013--2022. The first experiment uses a
6-month rolling $z$-score and a 2-year fixed least squares estimate of the hedge ratio:
the hedge ratio is estimated once on the first two years and then kept constant, so the spread has
constant weights and the P&L can be accumulated with compute_cum_pnl_spread_trading().
y1 = np.log(prices["EWA"])
y2 = np.log(prices["EWC"])
T_trn = round(2 * 252)
# hedge ratio via fixed least squares on the training window
gamma = np.polyfit(y2.values[:T_trn], y1.values[:T_trn], 1)[0]
# spread with leverage one, centered with its training mean
mu = (y1 - gamma * y2).iloc[:T_trn].mean()
spread, w = spread_and_weights(y1, y2, gamma, mu)
# generate sizing signal via thresholded strategy
entry_zscore = 1
res = generate_bb_thresholded_signal(spread, entry_zscore=entry_zscore,
lookback=6 * 21, start_signal_at=T_trn)
z_score, signal = res["z_score"], res["signal"]
# compute P&L
portf_cumret = compute_cum_pnl_spread_trading(spread, signal)
plot_pair_trading(spread, z_score, signal, portf_cumret,
entry_zscore=entry_zscore, T_trn=T_trn)
print(f"Final cumulative return: {portf_cumret.iloc[-1]:.2f}")
Final cumulative return: 0.48
We can observe that the spread does not show a strong persistent cointegration relationship over the whole period; in practice, the hedge ratio should be adapted over time. The $z$-score is able to produce a more constant mean-reverting version due to the rolling window adaptation. In any case, any practical implementation of pairs trading would re-compute the hedge ratio over time, as it is assumed in the rest of the experiments via a rolling least squares.
Rolling least squares¶
Rolling least squares refits the regression on the last two years at every point in time. The
weights of day $t$ use the price of day $t$, so the P&L must be computed with the weights held
during each period (lag_w=True), i.e., with the weights of the previous day:
# hedge ratio via rolling least squares
rolling_ls = fit_rolling_ls(y1, y2, lookback=T_trn, every=1)
# spread with leverage one
spread, w = spread_and_weights(y1, y2, rolling_ls["gamma"], rolling_ls["mu"])
# generate sizing signal via thresholded strategy
res = generate_bb_thresholded_signal(spread, entry_zscore=entry_zscore,
lookback=6 * 21, start_signal_at=T_trn)
z_score, signal = res["z_score"], res["signal"]
# compute P&L (with the weights held during each period)
portf_cumret = compute_cum_pnl_weights(pd.DataFrame({"y1": y1, "y2": y2}), w, signal, lag_w=True)
plot_pair_trading(spread, z_score, signal, portf_cumret,
entry_zscore=entry_zscore, T_trn=T_trn)
print(f"Final cumulative return: {portf_cumret.iloc[-1]:.2f}")
Final cumulative return: 0.51
We can appreciate that the spread has been improved compared to the previous fixed least squares and looks more mean-reverting. Still, the $z$-score is able to further improve it and produce a much better mean-reverting version. The cumulative return shows a modest improvement thanks to the rolling least squares approach (a final value of about 0.51 compared with 0.48 for the fixed least squares, and a smaller drawdown); the Kalman filter, explored next, is an alternative way to track the hedge ratio over time.
Market data KO--PEP¶
We continue with an attempt to execute pairs trading on the stocks Coca-Cola (KO) and Pepsi (PEP), which do not seem to be cointegrated according to the previous experiments. To be realistic, the hedge ratio $\gamma$ is estimated via a rolling least squares with a lookback period of 2 years (this may help in adapting to the changing cointegration relationship). The $z$-score is computed on a rolling window basis with a lookback period of 6 months (as well as 1 month for a faster adaptation).
6-month rolling $z$-score¶
Pairs trading on KO--PEP with a 6-month rolling $z$-score and 2-year rolling least squares for the estimation of the hedge ratio $\gamma$:
y1 = np.log(prices["KO"])
y2 = np.log(prices["PEP"])
T_trn = 2 * 252
# hedge ratio via rolling least squares
rolling_ls = fit_rolling_ls(y1, y2, lookback=T_trn, every=1)
gamma = rolling_ls["gamma"].to_numpy()
mu = rolling_ls["mu"].to_numpy()
# spread with leverage one
spread, w = spread_and_weights(y1, y2, gamma, mu)
# generate sizing signal via thresholded strategy
entry_zscore = 1
res = generate_bb_thresholded_signal(spread, entry_zscore=entry_zscore,
lookback=6 * 21, start_signal_at=T_trn)
z_score, signal = res["z_score"], res["signal"]
# compute P&L (with the weights held during each period)
portf_cumret = compute_cum_pnl_weights(pd.DataFrame({"y1": y1, "y2": y2}), w, signal, lag_w=True)
plot_pair_trading(spread, z_score, signal, portf_cumret,
entry_zscore=entry_zscore, T_trn=T_trn)
print(f"Final cumulative return: {portf_cumret.iloc[-1]:.2f}")
Final cumulative return: 0.13
We can observe that the spread loses the mean-reversion property despite the rolling nature of the hedge ratio calculation. The $z$-score is able to produce a more constant mean-reverting version, but still one can observe jumps indicating loss of cointegration. The cumulative return indicates that trading is not very successful.
1-month rolling $z$-score¶
A faster adaptation of the $z$-score (1-month window) follows the same spread but trades more often:
# generate sizing signal via thresholded strategy
res = generate_bb_thresholded_signal(spread, entry_zscore=entry_zscore,
lookback=1 * 21, start_signal_at=T_trn)
z_score, signal = res["z_score"], res["signal"]
# compute P&L (with the weights held during each period)
portf_cumret = compute_cum_pnl_weights(pd.DataFrame({"y1": y1, "y2": y2}), w, signal, lag_w=True)
plot_pair_trading(spread, z_score, signal, portf_cumret,
entry_zscore=entry_zscore, T_trn=T_trn)
print(f"Final cumulative return: {portf_cumret.iloc[-1]:.2f}")
Final cumulative return: 0.14
The $z$-score looks much better with a strong mean-reversion. This translates into a higher frequency trading (compare the frequency in the signals), but still the cumulative return does not seem to indicate a good profitability.
Kalman for pairs trading¶
In our context of spread modeling, we want to model $y_{1t} \approx \mu_t + \gamma_t \, y_{2t}$, where now $\mu_t$ and $\gamma_t$ change slowly over time. This can be done conveniently via a state space modeling by identifying the hidden state as $\bm{\alpha}_t = \left(\mu_t, \gamma_t\right)$, leading to $$ \begin{aligned} y_{1t} &= \begin{bmatrix} 1 & y_{2t} \end{bmatrix} \begin{bmatrix} \mu_t\\ \gamma_t \end{bmatrix} + \epsilon_t\\ \begin{bmatrix} \mu_{t+1}\\ \gamma_{t+1} \end{bmatrix} &= \begin{bmatrix} 1 & 0\\ 0 & 1 \end{bmatrix} \begin{bmatrix} \mu_t\\ \gamma_t \end{bmatrix} + \begin{bmatrix} \eta_{1t}\\ \eta_{2t} \end{bmatrix} , \end{aligned} $$ where all the noise components are independent and distributed as $\epsilon_t \sim \mathcal{N}(0,\sigma_\epsilon^2)$, $\eta_{1t} \sim \mathcal{N}(0,\sigma_{\mu}^2)$, and $\eta_{2t} \sim \mathcal{N}(0,\sigma_{\gamma}^2)$, the state transition matrix is the identity $\bm{T}=\bm{I}$, the observation matrix is $\bm{Z}_t=\begin{bmatrix} 1 & y_{2t} \end{bmatrix}$, and the initial states are $\mu_{1} \sim \mathcal{N}\left(\bar{\mu},\sigma_{\mu,1}^2\right)$ and $\gamma_{1} \sim \mathcal{N}\left(\bar{\gamma},\sigma_{\gamma,1}^2\right)$.
The normalized spread, with leverage one (see Chapter 15, Prices vs. Log-Prices), can then be obtained as $$ z_{t} = \frac{1}{1 + \gamma_{t|t-1}} \left(y_{1t} - \gamma_{t|t-1} \, y_{2t} - \mu_{t|t-1}\right), $$ where $\mu_{t|t-1}$ and $\gamma_{t|t-1}$ are the hidden states estimated by the Kalman algorithm.
We now repeat the pairs trading experiments with market data during 2013--2022. As before, the
$z$-score is computed on a rolling window basis with a lookback period of 6 months and pairs trading
is implemented via the thresholded strategy with a threshold of $s_0=1$. The difference is that we
now employ two different methods to track the hedge ratio over time: $i)$ rolling least squares with
lookback period of two years and $ii)$ the Kalman filter based on the basic model above with
$\alpha=5\times10^{-5}$. This value of $\alpha$ has been fixed by trial and error (without
transaction costs) and could be further optimized. With random-walk states, the Kalman filter can be
seen as a recursive least squares with a forgetting mechanism, where $\alpha$ controls how fast the
past data is forgotten: a larger $\alpha$ implies a faster tracking and a smaller variance of the
spread. In principle, all the parameters in the state space model can also be learned to better fit
the data via maximum likelihood estimation methods (e.g., statsmodels.tsa.statespace), although a
better fit does not necessarily translate into a better trading performance. The Kalman model with
momentum (hedge ratio and its velocity as hidden states) is not used because it gave no appreciable
improvement over the basic model.
Market data EWA--EWC¶
Tracking of the hedge ratio for pairs trading on EWA--EWC with the two methods:
y1 = np.log(prices["EWA"])
y2 = np.log(prices["EWC"])
T_trn = round(2 * 252)
# hedge ratio via rolling least squares
ewc_rls = fit_rolling_ls(y1, y2, lookback=T_trn, every=1)
# hedge ratio via Kalman filter
ewc_kal = kalman_hedge_ratio(y1, y2, T_trn=T_trn, alpha=5e-5)
plot_comparison(ewc_rls["gamma"], ewc_kal["gamma"], "Rolling LS", "Kalman",
"Hedge ratio", ylim=(0.5, 1.2))
We can observe that the rolling least squares is very noisy with the value wildly varying between 0.6 and 1.2 (of course a longer lookback period could be used, but then it would not adapt fast enough in case the true hedge ratio changed). The Kalman filter, on the other hand, is much more stable with the value between 0.55 and 0.65.
The spreads implied by the two hedge ratios:
# compute spreads (with leverage one)
ewc_rls_spread, ewc_rls_w = spread_and_weights(y1, y2, ewc_rls["gamma"], ewc_rls["mu"])
ewc_kal_spread, ewc_kal_w = spread_and_weights(y1, y2, ewc_kal["gamma"], ewc_kal["mu"])
plot_comparison(ewc_rls_spread, ewc_kal_spread, "Rolling LS", "Kalman",
"Spread", ylim=(-0.05, 0.05))
It is quite apparent that the spread from the Kalman filter is much more stationary and mean-reverting than the one from the rolling least squares. One important point to notice is that the variance of the spread obtained from the Kalman filter depends on the choice of $\alpha$: if the spread variance becomes too small, then the profit may totally disappear after taking into account transaction costs, so care has to be taken with this choice.
Cumulative return of pairs trading on EWA--EWC:
# generate sizing signal via thresholded strategy
entry_zscore = 1
res = generate_bb_thresholded_signal(ewc_rls_spread, entry_zscore=entry_zscore,
lookback=6 * 21, start_signal_at=T_trn)
ewc_rls_signal = res["signal"]
res = generate_bb_thresholded_signal(ewc_kal_spread, entry_zscore=entry_zscore,
lookback=6 * 21, start_signal_at=T_trn)
ewc_kal_signal = res["signal"]
# compute P&L (with the weights held during each period)
y_ewc = pd.DataFrame({"y1": y1, "y2": y2})
ewc_rls_cum = compute_cum_pnl_weights(y_ewc, ewc_rls_w, ewc_rls_signal, lag_w=True)
ewc_kal_cum = compute_cum_pnl_weights(y_ewc, ewc_kal_w, ewc_kal_signal, lag_w=False)
plot_comparison(ewc_rls_cum, ewc_kal_cum, "Rolling LS", "Kalman", "Cumulative return",
linestyles=("solid", "dashed"))
print(f"Final cumulative return, rolling LS: {ewc_rls_cum.iloc[-1]:.2f}")
print(f"Final cumulative return, Kalman: {ewc_kal_cum.iloc[-1]:.2f}")
Final cumulative return, rolling LS: 0.51 Final cumulative return, Kalman: 0.78
Both strategies are profitable (transaction costs ignored, P&L computed with the weights held during each period), ending at about 0.51 (rolling least squares) and 0.78 (Kalman). The two curves are, however, close until early 2020, when a large jump takes place (the COVID-19 market turmoil) that the Kalman filter captures much better; most of the difference in the final value comes from that single episode. The Kalman filter tracks the hedge ratio more smoothly and gives a spread with a smaller variance, but it also trades more: its turnover is about 4.5 times that of rolling least squares (0.23 versus 0.05 per day, counting changes of both the signal and the hedge ratio), so transaction costs hurt it more. This is quantified at the end of the chapter. Again, it is important to reiterate that special care has to be taken in practice with the choice of $\alpha$ so that the spread variance is large enough to provide a profit after transaction costs.
Market data KO--PEP¶
Tracking of the hedge ratio for pairs trading on KO--PEP:
y1 = np.log(prices["KO"])
y2 = np.log(prices["PEP"])
T_trn = round(2 * 252)
# hedge ratio via rolling least squares
pep_rls = fit_rolling_ls(y1, y2, lookback=T_trn, every=1)
# hedge ratio via Kalman filter
pep_kal = kalman_hedge_ratio(y1, y2, T_trn=T_trn, alpha=5e-5)
plot_comparison(pep_rls["gamma"], pep_kal["gamma"], "Rolling LS", "Kalman",
"Hedge ratio", ylim=(0.2, 1.0))
Again, we can observe that rolling least squares is noisy and not very consistent, whereas the Kalman filter is stable while still reacting to the big change that happens in the early 2020 (perhaps due to the COVID-19 pandemic).
Spreads for pairs trading on KO--PEP:
# compute spreads (with leverage one)
pep_rls_spread, pep_rls_w = spread_and_weights(y1, y2, pep_rls["gamma"], pep_rls["mu"])
pep_kal_spread, pep_kal_w = spread_and_weights(y1, y2, pep_kal["gamma"], pep_kal["mu"])
plot_comparison(pep_rls_spread, pep_kal_spread, "Rolling LS", "Kalman",
"Spread", ylim=(-0.06, 0.06))
We can clearly appreciate a significant difference between the two methods. Observe the early 2020: rolling least squares loses the tracking and cointegration is clearly lost, whereas the Kalman filter is able to track after a momentary loss reflected in the shock on the spread.
Cumulative return of pairs trading on KO--PEP:
# generate sizing signal via thresholded strategy
res = generate_bb_thresholded_signal(pep_rls_spread, entry_zscore=entry_zscore,
lookback=6 * 21, start_signal_at=T_trn)
pep_rls_signal = res["signal"]
res = generate_bb_thresholded_signal(pep_kal_spread, entry_zscore=entry_zscore,
lookback=6 * 21, start_signal_at=T_trn)
pep_kal_signal = res["signal"]
# compute P&L (with the weights held during each period)
y_pep = pd.DataFrame({"y1": y1, "y2": y2})
pep_rls_cum = compute_cum_pnl_weights(y_pep, pep_rls_w, pep_rls_signal, lag_w=True)
pep_kal_cum = compute_cum_pnl_weights(y_pep, pep_kal_w, pep_kal_signal, lag_w=False)
plot_comparison(pep_rls_cum, pep_kal_cum, "Rolling LS", "Kalman", "Cumulative return",
linestyles=("solid", "dashed"))
print(f"Final cumulative return, rolling LS: {pep_rls_cum.iloc[-1]:.2f}")
print(f"Final cumulative return, Kalman: {pep_kal_cum.iloc[-1]:.2f}")
Final cumulative return, rolling LS: 0.13 Final cumulative return, Kalman: 0.04
In this case neither method produces a convincing profit: the final values are about 0.13 (rolling least squares) and 0.04 (Kalman), both with sizable drawdowns. Moreover, one cannot forget that transaction costs have not been considered and the Kalman filter trades about five times as much as rolling least squares (see the next section on turnover). This is consistent with KO and PEP not being cointegrated: better tracking of the hedge ratio cannot create a mean-reverting spread where there is none. The conclusion is that Kalman filtering is a useful tool to track a cointegration relationship when it exists, but it cannot create one.
Turnover and transaction costs¶
The comparison above ignored transaction costs, which is misleading because the two methods trade very different amounts: the rolling least squares updates the hedge ratio smoothly, while the Kalman filter adjusts it day by day (and the $z$-score of the more mean-reverting Kalman spread also changes sign more often). A simple accounting of the costs adds a charge proportional to the traded amount to the daily P&L:
- the position held over day $t$ is the sizing signal $s_{t-1}$ times the weights of the spread used that day ($\w_{t-1}$ for rolling least squares, $\w_t$ for the Kalman filter, consistently with the P&L computed above);
- the turnover of day $t$ is the sum over the two assets of the absolute change of the position, $\sum_i \left| p_{t,i} - p_{t-1,i} \right|$;
- a cost of $c$ basis points on the traded amount subtracts $c \times \text{turnover}_t$ from the daily P&L.
The following two helpers implement this accounting:
def turnover_series(signal, w, lag_w=False):
"""
Daily turnover of a pairs-trading strategy.
Parameters
----------
signal : array-like
Length-T sizing signal.
w : array-like
T x N weights of the spread.
lag_w : bool, default False
If True, the weights held during period t are those of period t-1.
Returns
-------
np.ndarray
Length-T turnover, sum over the assets of the absolute changes of the
positions held.
"""
s = np.asarray(signal, dtype=float)
W = np.asarray(w, dtype=float)
if lag_w:
W = np.vstack([W[:1], W[:-1]])
positions = np.concatenate(([0.0], s[:-1]))[:, None] * W
dpos = np.abs(np.diff(positions, axis=0)).sum(axis=1)
return np.concatenate(([0.0], dpos))
def cumulative_pnl_net_returns(y, w, signal, cost_bps=0.0, lag_w=False):
"""
Cumulative P&L net of transaction costs (in basis points of the traded amount).
Parameters
----------
y : array-like
T x N prices (log-prices) of the assets.
w : array-like
T x N weights of the spread.
signal : array-like
Length-T sizing signal.
cost_bps : float, default 0.0
Transaction cost per unit of traded amount, in basis points.
lag_w : bool, default False
If True, the weights held during period t are those of period t-1.
Returns
-------
pd.Series
Length-T cumulative P&L net of costs.
"""
cum = compute_cum_pnl_weights(y, w, signal, lag_w=lag_w)
turnover = turnover_series(signal, w, lag_w=lag_w)
return cum - cost_bps / 1e4 * np.cumsum(turnover)
We compute the average daily turnover of the four strategies and the final cumulative return net of costs for a cost of 0, 2, 5, and 10 basis points:
# average over the out-of-sample period only (the first two years are the training window)
turnover = pd.Series({
"EWA-EWC, rolling LS": turnover_series(ewc_rls_signal, ewc_rls_w, lag_w=True)[T_trn:].mean(),
"EWA-EWC, Kalman": turnover_series(ewc_kal_signal, ewc_kal_w, lag_w=False)[T_trn:].mean(),
"KO-PEP, rolling LS": turnover_series(pep_rls_signal, pep_rls_w, lag_w=True)[T_trn:].mean(),
"KO-PEP, Kalman": turnover_series(pep_kal_signal, pep_kal_w, lag_w=False)[T_trn:].mean(),
}, name="average daily turnover (out-of-sample)")
turnover.round(3)
EWA-EWC, rolling LS 0.050 EWA-EWC, Kalman 0.225 KO-PEP, rolling LS 0.033 KO-PEP, Kalman 0.163 Name: average daily turnover (out-of-sample), dtype: float64
cost_bps = [0, 2, 5, 10]
final = pd.DataFrame({
"EWA-EWC, rolling LS": [cumulative_pnl_net_returns(y_ewc, ewc_rls_w, ewc_rls_signal,
cost_bps=c, lag_w=True).iloc[-1]
for c in cost_bps],
"EWA-EWC, Kalman": [cumulative_pnl_net_returns(y_ewc, ewc_kal_w, ewc_kal_signal,
cost_bps=c, lag_w=False).iloc[-1]
for c in cost_bps],
"KO-PEP, rolling LS": [cumulative_pnl_net_returns(y_pep, pep_rls_w, pep_rls_signal,
cost_bps=c, lag_w=True).iloc[-1]
for c in cost_bps],
"KO-PEP, Kalman": [cumulative_pnl_net_returns(y_pep, pep_kal_w, pep_kal_signal,
cost_bps=c, lag_w=False).iloc[-1]
for c in cost_bps],
}, index=[f"{c} bps" for c in cost_bps])
final.round(3)
| EWA-EWC, rolling LS | EWA-EWC, Kalman | KO-PEP, rolling LS | KO-PEP, Kalman | |
|---|---|---|---|---|
| 0 bps | 0.508 | 0.782 | 0.127 | 0.040 |
| 2 bps | 0.488 | 0.691 | 0.114 | -0.026 |
| 5 bps | 0.458 | 0.555 | 0.095 | -0.124 |
| 10 bps | 0.408 | 0.329 | 0.062 | -0.288 |
The table confirms the qualitative discussion of the chapter. On EWA--EWC the Kalman filter trades about 4.5 times more than rolling least squares, so the advantage of its smoother spread survives only low transaction costs: at 5 basis points the final values are 0.56 (Kalman) versus 0.46 (rolling least squares) and at 10 basis points the ranking is already reversed (0.33 versus 0.41). On KO--PEP, where the pair is not cointegrated, a cost of just 2 basis points takes the Kalman result to $-0.03$ and the rolling least squares one to 0.11. The practical lesson is twofold: the hedge ratio tracker has to be chosen together with the trading frequency, and a filter that produces a very smooth spread also produces more trading, which matters as soon as costs are realistic.
Summary¶
The notebook reproduces the experiments of Chapter 15 with Python:
Stationarity and cointegration. Correlation between returns and cointegration between prices measure different things: two series can be cointegrated with almost uncorrelated returns, and highly correlated while drifting apart.
statsmodels'coint()provides the Engle--Granger test used in the R companion (egcm), andcoint_johansen()covers the multi-asset case.Trading the spread. The z-score normalization has to be causal, which leads to the rolling (Bollinger Bands) version. The linear and thresholded sizing rules are simple, and the P&L has to be accumulated with the weights held during each period when the hedge ratio is time-varying; using the difference of the spread directly would count the rebalancing term as profit.
Tracking the hedge ratio. Rolling least squares adapts but is noisy; the Kalman filter with random-walk states is smoother and gives a spread with a much smaller variance (on EWA--EWC the final cumulative return is 0.78 versus 0.51 without costs), at the price of a much higher turnover (0.23 versus 0.05 per day), which reverses the ranking once costs reach about 10 basis points.
When there is no cointegration. On KO--PEP the Kalman filter tracks the hedge ratio better but both strategies end with small profits (0.04 and 0.13) that disappear with a few basis points of costs: a better tracker cannot create a mean-reverting spread where the economic relation does not provide one.