Case Study - Building a Quantitative Strategy from Scratch › Stage 2: Data Exploration and Pair Selection
Quantitative FinanceAll chapters Case Study - Building a Quantitative Strategy from Scratch › Stage 2: Data Exploration and Pair SelectionOne section · Sections in this chapter3 / 12 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
With the idea in hand, we move to data. In practice, you would source historical equity prices from a data vendor and scan a universe of stocks for cointegrated pairs. For this case study, we will generate synthetic data that faithfully reproduces the statistical properties of a cointegrated equity pair. This ensures full reproducibility and lets us control the data-generating process, which is valuable for understanding how the strategy behaves under known conditions.
Generating Synthetic Cointegrated Prices
We construct two price series that share a common stochastic trend (a random walk) plus idiosyncratic noise. This is the classic structure that produces cointegration. The idea is that both stocks are driven by the same underlying factor, such as the price of oil for two energy companies, but each also has its own firm-specific noise that causes temporary deviations. A mean-reverting spread component is layered on top, governed by an autoregressive process with parameter . Because is less than one, the spread is stationary and tends to pull back toward zero, but because it is close to one, the reversion is slow enough to create tradeable multi-week excursions. This mirrors what we observe in real equity pairs, where the spread can remain far from equilibrium for weeks before converging.
!uv pip install numpy pandas matplotlib scipy statsmodels scikit-learn
import numpy as np
import pandas as pd
import sys
from pathlib import Path
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
from scipy import stats
for parent in [Path.cwd(), *Path.cwd().parents]:
style_dir = (
parent
/ "apps"
/ "web"
/ "content"
/ "notebooks"
/ "1_machine-learning-from-scratch"
)
if (style_dir / "mls_plot_style.py").exists():
if str(style_dir) not in sys.path:
sys.path.insert(0, str(style_dir))
break
else:
raise FileNotFoundError("Could not locate mls_plot_style.py")
from mls_plot_style import PALETTE, label_bars, polish_axes, use_mls_style
np.random.seed(42)
# Simulation parameters
n_days = 1500 # roughly 6 years of trading days
dt = 1.0
# Common stochastic trend (shared random walk)
common_innovations = np.random.normal(0, 1.0, n_days)
common_trend = 100 + np.cumsum(common_innovations * 0.5)
# Idiosyncratic noise for each stock
noise_a = np.cumsum(np.random.normal(0, 0.3, n_days))
noise_b = np.cumsum(np.random.normal(0, 0.3, n_days))
# Mean-reverting spread component
spread_noise = np.zeros(n_days)
phi = 0.95 # autoregressive parameter (close to 1 = slow reversion)
for t in range(1, n_days):
spread_noise[t] = phi * spread_noise[t-1] + np.random.normal(0, 0.8)
# Construct prices
# Stock B = beta * Stock A + stationary spread
beta_true = 1.2
price_a = common_trend + noise_a + np.random.normal(0, 0.5, n_days)
price_b = beta_true * common_trend + noise_b + spread_noise + np.random.normal(0, 0.5, n_days)
# Ensure prices are positive and realistic
price_a = price_a - price_a.min() + 30
price_b = price_b - price_b.min() + 40
# Build DataFrame with date index
dates = pd.bdate_range(start='2018-01-02', periods=n_days)
df = pd.DataFrame({
'Stock_A': price_a,
'Stock_B': price_b
}, index=dates)
df.index.name = 'Date'Let's visualize the raw price series to get a feel for the data.

The two series clearly trend together but with visible divergences, exactly the pattern we want to exploit.
Train-Test Split
Before any analysis, we split the data into an in-sample (training) period for model estimation and an out-of-sample (test) period for unbiased evaluation. As we emphasized in the backtesting chapter, failing to separate these periods is the single most common source of overfitting in quantitative research.
# Use first 1000 days for in-sample, remaining for out-of-sample
split_idx = 1000
df_train = df.iloc[:split_idx].copy()
df_test = df.iloc[split_idx:].copy()In-sample period: 2018-01-02 to 2021-11-01 (1000 days) Out-of-sample period: 2021-11-02 to 2023-10-02 (500 days)
The chronological split preserves the time-series structure, so we estimate parameters on historical data and validate performance on subsequent unseen data.
Testing for Cointegration
We now test whether our pair is cointegrated using the Engle-Granger two-step procedure, which we covered conceptually in the time series models chapter. The core logic behind this procedure is elegant: if two price series are truly cointegrated, then a properly chosen linear combination of them should eliminate the non-stationary component entirely, leaving behind a stationary residual. The test works by first finding that linear combination through ordinary least squares regression and then checking whether the resulting residuals behave like a stationary process. If they do, we have evidence that the two prices are tied together by a long-run equilibrium, which is precisely the condition we need for mean reversion trading.
The procedure involves two main steps:
-
Estimate the cointegrating regression: Regress Stock B on Stock A to find the hedge ratio and intercept :
where:
- : prices of Stock B and Stock A at time
- : hedge ratio (slope coefficient), representing the number of units of A to hold for each unit of B
- : intercept term, capturing any constant pricing difference
- : residual (spread), which should be stationary if the pair is cointegrated
The hedge ratio deserves careful attention because it is the critical component of the entire strategy. Economically, tells us how many dollars of Stock A we need to sell short (or buy) to neutralize the common factor exposure in one dollar of Stock B. If , for example, then Stock B is 20% more sensitive to the shared stochastic trend than Stock A, so we need 1.2 shares of A to hedge each share of B. Getting right ensures that our spread isolates the mean-reverting component. Getting it wrong means our spread will inherit a residual trend, turning a mean reversion trade into an unintended directional bet. The intercept captures any constant offset in the price relationship, such as a persistent premium of one stock over the other. This offset shifts the equilibrium level of the spread but does not affect its mean-reverting dynamics.
-
Test the residuals for stationarity: Apply the Augmented Dickey-Fuller (ADF) test to the regression residuals. If we reject the null of a unit root, the spread is stationary and the pair is cointegrated.
The ADF test is the standard tool for determining whether a time series contains a unit root, that is, whether it behaves like a random walk. The null hypothesis of the ADF test states that the series has a unit root and is therefore non-stationary. If we can reject this null at a conventional significance level (typically 5%), we conclude that the residuals are stationary, which in the context of the Engle-Granger procedure means the original price series are cointegrated. In a cointegration test, the critical values for the ADF test differ from the standard Dickey-Fuller tables because the residuals are generated from a first-stage regression rather than observed directly. The
adfullerfunction fromstatsmodelshandles these details automatically, but understanding the logic helps avoid misinterpretation.
from sklearn.linear_model import LinearRegression
from statsmodels.tsa.stattools import adfuller
# Step 1: Estimate hedge ratio via OLS on training data
X_train = df_train["Stock_A"].values.reshape(-1, 1)
y_train = df_train["Stock_B"].values
reg = LinearRegression()
reg.fit(X_train, y_train)
beta_hat = reg.coef_[0]
intercept_hat = reg.intercept_
# Step 2: Compute the spread (residuals)
spread_train = y_train - (beta_hat * X_train.flatten() + intercept_hat)
# Step 3: ADF test on the spread
adf_result = adfuller(spread_train, maxlag=20, autolag="AIC")Estimated hedge ratio (beta): 0.6610
Estimated intercept: 25.5410
ADF Test on Spread:
Test statistic: -3.4931
p-value: 0.008166
Critical values: {'1%': np.float64(-3.4369259442540416), '5%': np.float64(-2.8644432969122833), '10%': np.float64(-2.5683158550174094)}
Conclusion: Cointegrated (reject unit root)The ADF test rejects the null hypothesis of a unit root at conventional significance levels, confirming that the spread is stationary. This validates our hypothesis: the pair is cointegrated, and the spread is mean-reverting. In practical terms, this means that when the spread deviates from its long-run average, there is a statistically reliable tendency for it to return, and this tendency is what our strategy will exploit.
Let's visualize the spread to see its mean-reverting behavior.

The spread oscillates around zero and repeatedly returns after excursions, including several moves beyond the reference bands. Those crossings create candidate entry points; stationarity comes from the ADF result, not from every observation remaining inside the bands.