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

> Build a pairs trading strategy with stock ETFs. Learn to select correlated pairs and trade spreads deviating from the historical mean using a lean implementation.

- Repository: [Papers With Backtest/awesome-systematic-trading](https://github.com/paperswithbacktest/awesome-systematic-trading)
- Tags: how-to-guide
- Published: 2026-08-01

---

**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`](https://github.com/paperswithbacktest/awesome-systematic-trading/blob/main/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.

```python
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`](https://github.com/paperswithbacktest/awesome-systematic-trading/blob/main/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`](https://github.com/paperswithbacktest/awesome-systematic-trading/blob/main/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.