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_score until Z < exit_z_score, +1 from Z < -entry_z_score until Z > -exit_z_score, 0 otherwise (2.0 and 0.5 by default)
\[\text{Half-Life} = - \ln(2) / \ln(1 + \lambda),\;\; \text{with}\; \lambda \;\text{from}\; \Delta \text{sₜ} = a + \lambda \cdot \text{sₜ₋₁} + \text{epsilonₜ} (\text{infinite when}\; \lambda \;\text{is} (\text{nearly}) \text{zero or positive})\]
  • 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.

ParameterTypeDescriptionExample
periodstrThe data frequency (daily, weekly, monthly, quarterly, or yearly). Defaults to “daily”."weekly"
columnstrThe price column. Defaults to “Adj Close”."Return"
windowint | NoneThe rolling window of the z-score. Defaults to None, which uses the full sample.20
entry_z_scorefloatThe z-score at which a position is opened. Defaults to 2.0.2.0
exit_z_scorefloatThe 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_pricesboolWhether to use log prices, so the hedge ratio is an elasticity. Defaults to True.True
time_seriesboolWhether to return the Spread, Z-Score and Signal per date instead of the summary. Defaults to False.False
include_benchmarkboolWhether to include “Benchmark” among the assets. Defaults to False.True
roundingint | NoneThe number of decimals to round the results to. Defaults to None.2
Share