How to Build a Pairs Trading Strategy with Stock ETFs: A Complete Lean Implementation

You can build a pairs trading strategy with stock ETFs by calculating normalized price distances over a 120-day formation period, selecting the top 5 correlated pairs, and entering trades when spreads deviate beyond 0.5 standard deviations from the historical mean, as implemented in the awesome-systematic-trading repository.

The awesome-systematic-trading repository provides a production-ready framework for statistical arbitrage on exchange-traded funds. Located in static/strategies/pairs-trading-with-country-etfs.py, the implementation leverages the Lean backtesting engine to execute a classic distance-based methodology that you can adapt to any stock ETF universe.

Understanding the Distance-Based Methodology

Universe Definition and Pair Formation

The algorithm initializes by loading a basket of liquid stock ETFs into the self.symbols list. Using it.combinations(self.symbols, 2), the strategy generates all possible unordered pairs to evaluate potential statistical relationships. This combinatorial approach ensures comprehensive coverage of potential arbitrage opportunities within your defined universe.

The Distance Metric Calculation

In the ComputePairDistances method, the algorithm normalizes each price series to a starting value of $1.0. It then computes the sum of squared deviations between the two normalized series according to the formula:


distance = sum((norm_a - norm_b) ** 2)

This scalar distance measures how closely the ETFs move together historically. Pairs with smaller distances exhibit tighter tracking, indicating stronger statistical relationships suitable for mean-reversion strategies.

Selection and Trading Windows

After a 120-day formation window (self.formation_days = 120), the strategy selects the top 5 pairs with the smallest distance values (self.max_traded_pairs = 5). During the subsequent 20-day trading window (self.trading_days = 20), the algorithm monitors these selected pairs for mean-reversion opportunities while ignoring weaker correlations.

Implementing the Strategy in Lean

The following implementation demonstrates the core architecture using QuantConnect's Lean engine. The code handles pair generation, distance calculation, and signal execution within a custom QCAlgorithm subclass.

import itertools as it
from QuantConnect import *
from QuantConnect.Algorithm import *
from QuantConnect.Data.Market import *

class EtfPairsTrading(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2023, 12, 31)
        self.SetCash(100000)
        
        # Define the ETF universe

        self.symbols = [
            self.AddEquity("SPY", Resolution.Daily).Symbol,
            self.AddEquity("IVV", Resolution.Daily).Symbol,
            self.AddEquity("VTI", Resolution.Daily).Symbol,
            self.AddEquity("QQQ", Resolution.Daily).Symbol,
            self.AddEquity("IWM", Resolution.Daily).Symbol,
        ]
        
        # Generate all possible pairs

        self.symbol_pairs = list(it.combinations(self.symbols, 2))
        
        # Strategy parameters

        self.formation_days = 120
        self.trading_days = 20
        self.max_traded_pairs = 5
        self.distance = {}
        self.sorted_pairs = []
        self.traded_pairs = []
        
        # Schedule daily rebalance

        self.Schedule.On(self.DateRules.EveryDay(),
                        self.TimeRules.AfterMarketOpen(self.symbols[0], 30),
                        self.Rebalance)

    def ComputePairDistances(self):
        """Calculate normalized price distances for all pairs."""
        for a, b in self.symbol_pairs:
            hist_a = self.History([a], self.formation_days, Resolution.Daily)['close']
            hist_b = self.History([b], self.formation_days, Resolution.Daily)['close']
            
            # Normalize to start at 1.0

            norm_a = hist_a / hist_a.iloc[0]
            norm_b = hist_b / hist_b.iloc[0]
            
            # Sum of squared deviations

            self.distance[(a, b)] = ((norm_a - norm_b) ** 2).sum()

    def Rebalance(self):
        """Execute pair selection and trading logic."""
        # Update distances at formation period start

        if self.Time.day % self.formation_days == 0:
            self.ComputePairDistances()
            self.sorted_pairs = sorted(self.distance, 
                                      key=self.distance.get)[:self.max_traded_pairs]
        
        # Exit stale positions

        for pair in list(self.traded_pairs):
            if self.IsExitSignal(pair):
                self.Liquidate(pair[0])
                self.Liquidate(pair[1])
                self.traded_pairs.remove(pair)
        
        # Open new positions

        for pair in self.sorted_pairs:
            if pair in self.traded_pairs:
                continue
            if len(self.traded_pairs) >= self.max_traded_pairs:
                break
            
            # Calculate current spread and historical std-dev

            hist_a = self.History([pair[0]], self.formation_days, Resolution.Daily)['close']
            hist_b = self.History([pair[1]], self.formation_days, Resolution.Daily)['close']
            spread = hist_a / hist_a.iloc[0] - hist_b / hist_b.iloc[0]
            std = spread.std()
            
            price_a = self.Securities[pair[0]].Price
            price_b = self.Securities[pair[1]].Price
            current_spread = price_a / hist_a.iloc[-1] - price_b / hist_b.iloc[-1]
            
            # Entry signal: spread > 0.5 * sigma

            if abs(current_spread) > 0.5 * std:
                allocation = self.Portfolio.TotalPortfolioValue / (2 * self.max_traded_pairs)
                self.SetHoldings(pair[0], allocation / price_a)
                self.SetHoldings(pair[1], -allocation / price_b)
                self.traded_pairs.append(pair)

    def IsExitSignal(self, pair):
        """Determine if position should be closed."""
        return self.Time.day % self.trading_days == 0

Entry and Exit Logic

The Rebalance method executes daily checks 30 minutes after market open. A position opens when the current spread exceeds 0.5 × the historical standard deviation of the spread. Positions close when the spread reverts to the mean or the 20-day trading window expires, ensuring the strategy does not hold positions beyond the predefined holding period.

Position Sizing

The portfolio value divides equally among active pairs using self.Portfolio.TotalPortfolioValue / self.max_traded_pairs. Each leg receives half of the pair allocation, creating a market-neutral exposure. You can modify this calculation in the Rebalance method to implement volatility-adjusted or risk-parity sizing schemes.

Adapting the Strategy for Your ETF Universe

To customize the implementation in static/strategies/pairs-trading-with-country-etfs.py for your specific research objectives, modify these key components:

  • Universe Selection: Replace the self.symbols list in Initialize with your target ETFs (e.g., sector ETFs like "XLE", "XLF", "XLK" or international funds like "EFA", "EEM").
  • Formation Period: Adjust self.formation_days to capture recent correlation dynamics. Shorter windows (60 days) respond faster to regime changes, while longer windows (180 days) suit stable, long-term relationships.
  • Distance Metric: Consider replacing the sum-of-squares approach with cointegration tests (Engle-Granger) or Kalman filters for dynamic hedge ratios when correlations drift over time.
  • Entry Threshold: Tune the 0.5 × σ multiplier to balance trade frequency against signal strength based on your specific ETF volatility characteristics.
  • Risk Controls: Implement stop-losses in IsExitSignal beyond the simple time-based exit to limit downside when historical correlations break down.

Summary

  • The pairs-trading-with-country-etfs.py file in the awesome-systematic-trading repository implements a complete statistical arbitrage workflow on the Lean engine.
  • Distance metrics rely on normalized price series and sum-of-squared deviations calculated over a 120-day formation period.
  • The strategy selects the top 5 closest pairs (max_traded_pairs = 5) and trades mean reversion when spreads exceed 0.5 standard deviations.
  • Position sizing uses equal capital allocation across active pairs, with each leg receiving Portfolio.TotalPortfolioValue / (2 * max_traded_pairs).
  • You can adapt the strategy to any ETF universe by modifying the symbol list and tuning the formation/trading window parameters.

Frequently Asked Questions

What is the optimal formation period for ETF pairs trading?

The reference implementation uses 120 days to capture stable historical correlations. However, you should adjust self.formation_days based on your ETFs' volatility regime; 60 days works better for rapidly changing markets, while 180 days suits stable, long-term cointegrated relationships.

How does the distance metric work in pairs trading?

The algorithm normalizes both price series to start at $1.0, then calculates the sum of squared deviations between them over the formation window. Pairs with smaller distance values exhibit tighter historical correlation, making them better candidates for mean-reversion strategies when deviations occur.

Can I use this strategy with sector ETFs instead of country ETFs?

Yes. Simply modify the self.symbols list in the Initialize method to include sector ETFs like "XLF" (Financials) or "XLK" (Technology). The distance calculation remains valid as long as the ETFs share similar liquidity profiles and trading hours.

What risk management features should I add?

Beyond the default time-based exit in IsExitSignal, implement stop-loss thresholds to limit downside when correlations break down. Consider adding maximum drawdown limits or replacing the equal-capital allocation with volatility-adjusted position sizing to align exposure with each pair's risk profile.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →