Pairs Trading
Calculate the statistics of trading the spread between every pair of assets. The hedge ratio follows from regressing one log price on the other; what remains is the spread, and when the two are cointegrated the spread reverts to its mean. Its z-score says how far it is from that mean in standard deviations, and threshold rules trade on it: short the spread (sell the first asset, buy the hedge ratio of the second) once the z-score rises above the entry threshold, buy it once it falls below minus the threshold, and close the position once the z-score crosses the exit threshold on its way back (below it for a short, above minus it for a long). A z-score that jumps past the band in one period still closes the position, and one that lands beyond the entry threshold on the other side reverses it. The half-life says how many periods the spread takes to close half its gap to the mean.
Gatev, Goetzmann and Rouwenhorst (2006) open a position when the normalized prices of a pair drift two standard deviations (of their formation period) apart and close it when the prices cross, which is an exit threshold of 0; closing at 0.5 is a common variant that takes profits before the spread is fully back at its mean.
The hedge ratio and intercept are estimated on the whole period, so the spread, and with it every z-score and signal, uses information from after its date. A rolling window only makes the mean and standard deviation of the z-score backward-looking; a backtest without look-ahead estimates the hedge ratio on a formation period and trades in the period after it. The Engle-Granger p-value says whether there is a mean-reverting relationship to trade at all (below 0.05).
The formula is as follows:
\[\text{yₜ} = c + h \cdot \text{xₜ} + \text{sₜ},\;\; \text{fitted by OLS on the dates both have},\;\; \text{with yₜ and xₜ the (log, when log\_prices=True) column levels (Adj Close by default): h the Hedge Ratio},\;\; \text{c the Intercept and sₜ the Spread}\] \[\text{Z-Scoreₜ} = (\text{sₜ} - \operatorname{mean}(s)) / \operatorname{std}(s) (\text{ddof} = 1),\;\; \text{over the full sample or a rolling window}\]- Signal: -1 from Z >
entry_z_scoreuntil Z <exit_z_score, +1 from Z < -entry_z_scoreuntil Z > -exit_z_score, 0 otherwise (2.0 and 0.5 by default)
- Cointegration P-Value: the Engle-Granger test of yₜ on xₜ
For more information, see the following papers:
- Gatev, E., Goetzmann, W.N. and Rouwenhorst, K.G. (2006). “Pairs Trading: Performance of a Relative-Value Arbitrage Rule.” The Review of Financial Studies, 19(3), 797-827.
- Engle, R.F. and Granger, C.W.J. (1987). “Co-Integration and Error Correction: Representation, Estimation, and Testing.” Econometrica, 55(2), 251-276.
Also known as: statistical arbitrage, spread trading, z-score strategy, half-life of mean reversion.
Pairs Trading in Python or via the MCP
Install the Finance Toolkit with:
pip install financetoolkit -U
Then call get_pairs_trading as shown below.
from financetoolkit import Toolkit
toolkit = Toolkit(
["KO", "PEP"],
api_key="FINANCIAL_MODELING_PREP_KEY",
start_date="2015-01-01",
end_date="2025-12-31",
)
toolkit.econometrics.get_pairs_trading(window=60)
Or, after setting up the Finance Toolkit MCP, ask:
What were the hedge ratio, half-life, cointegration p-value and current trading signal of a Coca-Cola and PepsiCo pairs trade from 2015 to 2025, with a 60-day rolling z-score?
Which returns:
| Hedge Ratio | Intercept | Half-Life | Cointegration P-Value | Z-Score | Signal | Number of Trades | |
|---|---|---|---|---|---|---|---|
| (‘KO’, ‘PEP’) | 0.8685 | -0.3098 | 290.448 | 0.8345 | 0.684 | 0 | 42 |
Using Your Own Data
get_pairs_trading runs on any data, not only prices: ratios, economic indicators, yield curves or your own dataset. Pass a DataFrame, a Series or a dictionary of them to Econometrics as data, with the dates in the rows or the columns, and set changes to how its changes are calculated: "percentage", "log", "difference" for rates and ratios, or None when the data are changes already. For example, for the monthly unemployment rates of the United States and Germany:
from financetoolkit import Economics, Econometrics
economics = Economics(start_date="2000-01-01")
unemployment = economics.get_unemployment_rate(
countries=["United States", "Germany"], period="monthly"
)
econometrics = Econometrics(data=unemployment, changes="difference")
econometrics.get_pairs_trading()
Parameters
get_pairs_trading accepts the following parameters.
| Parameter | Type | Description | Example |
|---|---|---|---|
period | str | The data frequency (daily, weekly, monthly, quarterly, or yearly). Defaults to “daily”. | "weekly" |
column | str | The price column. Defaults to “Adj Close”. | "Return" |
window | int | None | The rolling window of the z-score. Defaults to None, which uses the full sample. | 20 |
entry_z_score | float | The z-score at which a position is opened. Defaults to 2.0. | 2.0 |
exit_z_score | float | The z-score whose crossing on the way back closes a position (0 closes when the spread is back at its mean). Defaults to 0.5. | 0.5 |
log_prices | bool | Whether to use log prices, so the hedge ratio is an elasticity. Defaults to True. | True |
time_series | bool | Whether to return the Spread, Z-Score and Signal per date instead of the summary. Defaults to False. | False |
include_benchmark | bool | Whether to include “Benchmark” among the assets. Defaults to False. | True |
rounding | int | None | The number of decimals to round the results to. Defaults to None. | 2 |