Main Page > Articles > Pairs Cointegration > Building a Granger Causality-Based Pairs Trading Strategy in Python

Building a Granger Causality-Based Pairs Trading Strategy in Python

From TradingHabits, the trading encyclopedia · 7 min read · February 28, 2026
The Black Book of Day Trading Strategies
Free Book

The Black Book of Day Trading Strategies

1,000 complete strategies · 31 chapters · Full trade plans

Pairs trading is a classic market-neutral strategy that relies on the concept of cointegration. The core idea is to find two assets whose prices have a long-run equilibrium relationship. When the spread between their prices diverges from its historical mean, a trader can short the outperforming asset and long the underperforming one, betting that the spread will revert to the mean. While cointegration is a necessary condition, it does not specify the dynamic, lead-lag relationship within the pair. This is where Granger causality can be introduced as a effective enhancement, allowing traders to identify which asset leads the pair and structure their trades with greater precision.

By default, a standard cointegrated pair gives no information on which leg of the spread is more likely to drive the reversion. Incorporating Granger causality allows us to test whether the past values of one asset have predictive power over the future values of the other. If we find a unidirectional causal link, from asset X to asset Y, we can refine our trading rules. Instead of simply trading the spread, we can use movements in the "leader" asset (X) as the primary signal for entering a trade in the "laggard" asset (Y), potentially improving entry timing and reducing false signals.

A Step-by-Step Python Implementation

This guide will walk through the process of building and testing a Granger causality-enhanced pairs trading strategy using Python. We will use the yfinance library to download price data, statsmodels for statistical tests, and pandas for data manipulation.

1. Data Acquisition and Preparation

First, we select a pair of potentially cointegrated assets. A classic example is the relationship between two large companies in the same sector, such as Coca-Cola (KO) and PepsiCo (PEP). We download their daily adjusted closing prices for a substantial period.

python
import yfinance as yf
import pandas as pd
import statsmodels.api as sm
from statsmodels.tsa.stattools import adfuller, coint, grangercausalitytests

# Download data
data = yf.download("KO PEP", start="2018-01-01", end="2023-01-01")['Adj Close']
data.columns = ['KO', 'PEP']

2. Testing for Stationarity

Most time series models, including Granger causality tests, require the data to be stationary. A stationary series has a constant mean, variance, and autocorrelation over time. We use the Augmented Dickey-Fuller (ADF) test to check for stationarity. If the p-value is above 0.05, we fail to reject the null hypothesis that the series has a unit root and is non-stationary.

python
def check_stationarity(series):
    result = adfuller(series)
    print(f'ADF Statistic: {result[0]}')
    print(f'p-value: {result[1]}')

check_stationarity(data['KO'])
check_stationarity(data['PEP'])

Price series are rarely stationary. We must transform them, typically by taking the first difference of the log prices to get log returns, which are more likely to be stationary.

python
log_returns = np.log(data).diff().dropna()
check_stationarity(log_returns['KO'])
check_stationarity(log_returns['PEP'])

3. Testing for Cointegration

If the individual price series are non-stationary but their log returns are stationary (i.e., they are I(1)), we can test for cointegration. The Engle-Granger two-step method is a common approach. If the p-value from the coint function is below 0.05, we can reject the null hypothesis of no cointegration.

python
score, pvalue, _ = coint(data['KO'], data['PEP'])
print(f'Cointegration test p-value: {pvalue}')

A low p-value suggests a stable, long-term relationship exists between KO and PEP prices.

4. Testing for Granger Causality

Now we test for the directional, predictive relationship using the stationary log returns. The grangercausalitytests function in statsmodels runs the test for a range of specified lags. We are looking for a scenario where we can reject the null hypothesis in one direction (e.g., KO Granger-causes PEP) but not the other.

python
granger_results_pep_ko = grangercausalitytests(log_returns[['PEP', 'KO']], maxlag=5, verbose=False)
granger_results_ko_pep = grangercausalitytests(log_returns[['KO', 'PEP']], maxlag=5, verbose=False)

# Check p-values for a specific lag, e.g., 3
p_pep_causes_ko = granger_results_pep_ko[3][0]['ssr_ftest'][1]
p_ko_causes_pep = granger_results_ko_pep[3][0]['ssr_ftest'][1]

print(f'P-value for PEP Granger-causing KO: {p_pep_causes_ko}')
print(f'P-value for KO Granger-causing PEP: {p_ko_causes_pep}')

Let's assume the results show that KO Granger-causes PEP (p-value < 0.05) but PEP does not Granger-cause KO (p-value > 0.05). This identifies KO as the leading asset in the pair.

5. Developing Trading Rules

With KO identified as the leader, we can formulate a more nuanced trading strategy. First, we calculate the spread using the cointegration relationship, typically by running a linear regression of one price on the other.

python
model = sm.OLS(data['PEP'], sm.add_constant(data['KO']))
results = model.fit()
beta = results.params[1]
spread = data['PEP'] - beta * data['KO']

Next, we calculate the z-score of the spread to identify deviations from the mean.

python
zscore = (spread - spread.mean()) / spread.std()

Our trading rules can now incorporate the leader-laggard dynamic:

  • Entry Signal: If the z-score crosses a threshold (e.g., > 2.0) AND KO has recently shown strong positive momentum (the leader is moving), we short the spread (short PEP, long KO).
  • Exit Signal: We exit the trade when the z-score reverts to a lower threshold (e.g., < 0.5).

This is a significant refinement over a basic pairs strategy, which would trigger a trade based solely on the z-score, regardless of which asset is driving the divergence.

Backtesting and Further Considerations

A proper backtest of this strategy would involve simulating these rules over a historical period, accounting for transaction costs, and measuring performance metrics like Sharpe ratio, maximum drawdown, and total return. The Python backtrader or zipline libraries are well-suited for this.

This approach is not without its challenges. The causal relationships identified can be unstable and may break down over time, requiring periodic re-evaluation of the model. The choice of lag length in the Granger causality test is also important and can affect the results. Despite these caveats, integrating Granger causality into a pairs trading framework provides a clear, logical method for moving beyond simple cointegration and developing more sophisticated, directionally-aware market-neutral strategies.

Categories: Pairs Trading