Pairs Trading Implementation with Normalized Price Series: A QuantConnect Lean Guide
The awesome-systematic-trading repository implements pairs trading by normalizing price series to $1 during formation to identify co-integrated pairs via Euclidean distance, then executing mean-reversion trades using z-score thresholds within the QuantConnect Lean framework.
Pairs trading implementation with normalized price series is a statistical arbitrage technique that exploits temporary mispricing between correlated assets while maintaining market neutrality. The awesome-systematic-trading repository provides a complete Python implementation of this strategy in static/strategies/pairs-trading-with-country-etfs.py, designed to run on the QuantConnect Lean algorithmic trading platform. This guide examines the two-phase methodology—formation (pair selection) and trading (signal execution)—along with the specific code implementations that enable robust backtesting and live deployment.
Formation Phase: Selecting Co-Integrated Pairs with Normalized Prices
The algorithm begins by analyzing a predefined universe of symbols (such as country ETFs or stocks) to identify the most tightly correlated pairs. This formation period relies on normalized price series to ensure fair distance calculations regardless of absolute price levels.
Normalizing Price Series to $1
During the formation window (default 120 days), each instrument's price history is normalized to start at $1. This eliminates scale differences between assets trading at different absolute prices, enabling meaningful comparison of their relative movements. The implementation divides every historical price by the first day's closing price:
# Pull historical data for formation period
hist_a = self.History([a], self.formation_days, Resolution.Daily)["close"]
hist_b = self.History([b], self.formation_days, Resolution.Daily)["close"]
# Normalize to $1 starting value
norm_a = hist_a / hist_a.iloc[0]
norm_b = hist_b / hist_b.iloc[0]
This normalization ensures that a $200 stock and a $20 stock can be compared fairly based on their percentage movements rather than nominal dollar changes.
Computing the Distance Metric
For every possible pair combination generated via itertools.combinations, the algorithm calculates a Euclidean-style distance metric as the sum of squared deviations between the two normalized series:
# Sum of squared deviations as distance metric
distances[(a, b)] = np.sum((norm_a - norm_b) ** 2)
This metric quantifies the total deviation between the two price trajectories over the formation period, with smaller values indicating tighter co-integration.
Ranking and Selecting Top Pairs
The algorithm sorts all candidate pairs by their distance scores and retains only the most co-integrated relationships (default 5 pairs):
# Generate all unordered symbol pairs
self.symbol_pairs = list(it.combinations(self.symbols, 2))
# Select top pairs with minimum distance
self.sorted_pairs = sorted(distances.items(),
key=lambda kv: kv[1])[:self.max_traded_pairs]
self.sorted_pairs = [pair for pair, _ in self.sorted_pairs]
These selected pairs become the exclusive trading universe for the subsequent period, ensuring the algorithm only trades the most statistically robust relationships.
Trading Phase: Signal Generation and Execution
Once pairs are selected, the algorithm transitions to real-time monitoring and execution, entering positions when statistical thresholds indicate significant deviation from historical norms.
Real-Time Spread Monitoring
During the trading phase, current market prices are dynamically normalized using reference prices stored from the formation period. The algorithm validates data availability using data.ContainsKey() before accessing prices to prevent runtime errors:
for pair in self.sorted_pairs:
a, b = pair
if not (data.ContainsKey(a) and data.ContainsKey(b)):
continue
# Current normalized prices (starting from $1)
price_a = data[a].Close / self.reference_prices[a]
price_b = data[b].Close / self.reference_prices[b]
spread = price_a - price_b
Z-Score Entry and Exit Thresholds
The algorithm maintains a rolling history of spreads and computes a z-score relative to the historical standard deviation (using a 30-day window):
hist_spread.append(spread)
if len(hist_spread) > 30:
sigma = np.std(hist_spread[-30:])
z = spread / sigma
# Entry when deviation exceeds 0.5 standard deviations
if abs(z) > self.entry_threshold and pair not in self.traded_pairs:
# Execute trade logic here
# Exit when spread reverts to mean (z < 0)
if pair in self.traded_pairs and abs(z) < self.exit_threshold:
self.Liquidate(a)
self.Liquidate(b)
self.traded_pairs.remove(pair)
The entry threshold of 0.5σ indicates significant deviation, while the exit threshold of 0.0σ signals mean reversion and profit realization.
Dollar-Neutral Position Sizing
Positions are sized to maintain dollar neutrality, allocating equal capital to long and short legs to eliminate market directional risk:
alloc = self.Portfolio.TotalPortfolioValue / self.max_traded_pairs
if z > 0:
# Long undervalued, short overvalued
self.SetHoldings(a, alloc / self.Securities[a].Price)
self.SetHoldings(b, -alloc / self.Securities[b].Price)
else:
# Short undervalued, long overvalued (opposite)
self.SetHoldings(a, -alloc / self.Securities[a].Price)
self.SetHoldings(b, alloc / self.Securities[b].Price)
self.traded_pairs.append(pair)
This ensures that profit depends solely on the convergence of the spread between the two assets, not on broader market movements.
Key Implementation Details in QuantConnect Lean
The strategy leverages specific QuantConnect Lean API methods and conventions:
- Historical Data Access:
self.History([symbol], self.formation_days, Resolution.Daily)retrieves formation period price data - Order Execution:
self.SetHoldings(symbol, target_percentage)immediately adjusts positions to target portfolio percentages, whileself.Liquidate(symbol)closes specific positions - Security References:
self.Securities[symbol].Priceprovides current market prices for position sizing calculations - State Management: The algorithm tracks active pairs in
self.traded_pairsto prevent duplicate entries and ensure proper exit handling - Periodic Rebalancing: Pair distances are recalculated at defined intervals (e.g., monthly) by checking
self.Timeconditions, allowing the universe to adapt to changing market correlations
The file static/strategies/pairs-trading-with-stocks (directory) contains additional implementations following this same normalized price methodology for equity pairs.
Summary
- Price Normalization: Dividing series by their initial value (
price / price.iloc[0]) enables fair distance-based pair selection across different price scales - Distance Calculation: Sum of squared deviations between normalized series (
np.sum((norm_a - norm_b) ** 2)) identifies the most co-integrated pairs during the 120-day formation period - Z-Score Signals: Entry at 0.5σ (
entry_threshold = 0.5) and exit at 0σ (exit_threshold = 0.0) creates a disciplined mean-reversion logic - Dollar Neutrality: Equal capital allocation to long/short legs using
SetHoldingsensures market-neutral exposure as implemented inpairs-trading-with-country-etfs.py - Dynamic Pair Selection: Monthly re-evaluation of
self.sorted_pairsensures the algorithm maintains exposure only to currently co-integrated relationships
Frequently Asked Questions
What is the purpose of normalizing price series to $1 in pairs trading?
Normalizing price series to $1 by dividing each price by its formation period starting value eliminates absolute price level differences between assets. This allows the algorithm to compare relative price movements fairly, ensuring that distance calculations reflect co-integration strength rather than nominal price differences, which is essential when comparing assets like ETFs trading at $50 versus $150 per share.
How does the algorithm determine which pairs to trade?
The algorithm generates all possible symbol combinations using itertools.combinations, then computes the sum of squared deviations between normalized price series for each candidate pair. It ranks these Euclidean distances and selects the top 5 pairs (configurable via self.max_traded_pairs) with the smallest distances, indicating the tightest historical correlation and highest probability of mean reversion.
What triggers entry and exit signals in this normalized price series implementation?
Entry signals trigger when the absolute z-score of the normalized price spread exceeds 0.5 standard deviations (abs(z) > self.entry_threshold), suggesting significant statistical deviation from the historical mean. Exit signals occur when the absolute z-score falls below 0.0 (abs(z) < self.exit_threshold), indicating the spread has reverted to its mean value and positions should be closed to realize profits.
How does the strategy manage risk and position sizing?
Risk management combines dollar-neutral sizing (equal capital allocation to long and short legs via Portfolio.TotalPortfolioValue / max_traded_pairs), position limits (restricting active trades to the top 5 pairs), and automatic liquidation upon mean reversion using Liquidate(). The algorithm also periodically clears and re-evaluates pairs to ensure selections remain statistically valid under current market conditions.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →