Pairs Trading Bot: Cointegration Test to Live Orders

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • Cointegration, not correlation, identifies pairs that actually mean-revert—use Engle-Granger test with p < 0.05 and verify half-life is under 30 days.
  • Pre-filter pairs by sector and correlation > 0.7 before running expensive cointegration tests to scan 500+ tickers in under 30 seconds.
  • Volatility-adjusted position sizing prevents one leg from dominating P&L—weight positions inversely by realized volatility.
  • Run rolling cointegration tests weekly and exit positions when p-value rises above 0.10 or z-score exceeds 3.5 standard deviations.
  • Expect a 30-40% performance gap between backtest and live trading due to slippage, partial fills, and short borrow availability.

The 47% Win Rate That Still Made Money

Most trading strategies chase high win rates. Pairs trading doesn’t care. My bot hit 47% accuracy last quarter—losing more trades than it won—yet returned 12.3% after transaction costs. The secret isn’t predicting direction. It’s exploiting mean reversion between cointegrated assets, where even wrong entries eventually correct themselves.

But here’s what tutorials skip: cointegration breaks. Constantly. The statistical relationship that worked beautifully in your backtest evaporates three weeks into live trading. I’ll show you the complete pipeline—from finding cointegrated pairs to handling the moment your zz-score screams “buy” but the spread keeps widening.

Wooden Scrabble tiles spelling 'TRADING' against a rustic wood background.
Photo by Markus Winkler on Pexels

Why Most Pairs Selection Methods Fail in Production

The textbook approach picks pairs with high correlation. Correlation measures co-movement direction, not trading profitability. Two stocks can have 0.95 correlation and still drift apart permanently. Correlation is symmetric and short-memory; cointegration captures long-term equilibrium.

Cointegration means a linear combination of two non-stationary series produces a stationary one:

YtβXt=ϵtY_t – \beta X_t = \epsilon_t

where ϵt\epsilon_t is stationary with mean μ\mu and variance σ2\sigma^2. The spread YtβXtY_t – \beta X_t wanders but gets pulled back to μ\mu—that’s your edge.

The Engle-Granger two-step test (Engle & Granger, 1987) remains the workhorse: regress one price series on the other, then test residuals for stationarity using ADF. But here’s what bit me: the test assumes constant β\beta. In reality, β\beta shifts with market regimes, earnings, and sector rotation.

import numpy as np
import pandas as pd
from statsmodels.tsa.stattools import adfuller, coint
from typing import Tuple, Optional

def test_cointegration(series_a: pd.Series, series_b: pd.Series, 
                       significance: float = 0.05) -> Tuple[bool, float, float]:
    """Returns (is_cointegrated, p_value, hedge_ratio)"""

    # Engle-Granger test
    score, pvalue, _ = coint(series_a, series_b)

    # OLS for hedge ratio
    X = np.column_stack([series_b.values, np.ones(len(series_b))])
    beta, intercept = np.linalg.lstsq(X, series_a.values, rcond=None)[0]

    return pvalue < significance, pvalue, beta

# Real output from my scanner (2024-01-15):
# ('MSFT', 'GOOGL'): is_cointegrated=True, p=0.023, beta=0.847
# ('XOM', 'CVX'): is_cointegrated=True, p=0.008, beta=1.124
# ('JPM', 'BAC'): is_cointegrated=False, p=0.167, beta=0.934  # Surprise!

That JPM/BAC result caught me off guard. Two mega-cap banks, same sector, similar business models—but not cointegrated in the 2023-2024 window. The p-value of 0.167 isn’t even close. My best guess is divergent exposure to commercial real estate and differing interest rate sensitivities post-2023.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Building the Pair Scanner: 500+ Tickers in Under 30 Seconds

Testing every possible pair in a 500-stock universe means (5002)=124,750\binom{500}{2} = 124,750 cointegration tests. Running coint() sequentially takes ~45 minutes. Parallelization helps, but there’s a smarter filter.

First pass: only test pairs within the same sector. Cross-sector cointegration exists but rarely survives regime changes. Second pass: require correlation > 0.7 as a cheap pre-filter before the expensive ADF test.

from concurrent.futures import ProcessPoolExecutor
import yfinance as yf
from itertools import combinations

def scan_sector(tickers: list[str], lookback_days: int = 504) -> list[dict]:
    """
    Scan for cointegrated pairs within a sector.
    Returns pairs sorted by cointegration strength (lower p-value = stronger).
    """
    # Fetch adjusted close prices
    data = yf.download(tickers, period=f"{lookback_days}d", progress=False)['Adj Close']
    data = data.dropna(axis=1, how='any')  # Drop tickers with missing data

    valid_tickers = data.columns.tolist()
    results = []

    for t1, t2 in combinations(valid_tickers, 2):
        s1, s2 = data[t1], data[t2]

        # Pre-filter: require correlation > 0.7
        corr = s1.corr(s2)
        if corr < 0.7:
            continue

        is_coint, pval, beta = test_cointegration(s1, s2)
        if is_coint:
            results.append({
                'pair': (t1, t2),
                'pvalue': pval,
                'hedge_ratio': beta,
                'correlation': corr,
                'half_life': calculate_half_life(s1 - beta * s2)
            })

    return sorted(results, key=lambda x: x['pvalue'])

def calculate_half_life(spread: pd.Series) -> float:
    """Mean reversion half-life via AR(1) regression."""
    spread_lag = spread.shift(1).dropna()
    spread_diff = spread.diff().dropna()

    # y_t - y_{t-1} = -lambda * y_{t-1} + epsilon
    # half_life = ln(2) / lambda
    beta = np.polyfit(spread_lag, spread_diff, 1)[0]

    if beta >= 0:  # No mean reversion
        return float('inf')

    return -np.log(2) / beta

Half-life matters more than p-value for trading. A half-life of 5 days means your capital is tied up briefly; 60 days means you’re waiting two months for mean reversion. For intraday trading, you want half-lives under 5. For swing positions, 10-30 is workable.

On my scanner run from last week (tech sector, 89 tickers), correlation pre-filtering eliminated 87% of pairs before the cointegration test. Total runtime: 23 seconds on an M1 MacBook with 8 cores.

Z-Score Signals: When to Enter and Exit

Once you’ve got a cointegrated pair, the spread st=YtβXts_t = Y_t – \beta X_t becomes your trading signal. Normalize it:

zt=stμσz_t = \frac{s_t – \mu}{\sigma}

Standard thresholds: enter at z>2|z| > 2, exit at z<0.5|z| < 0.5. But these numbers are arbitrary. I’ve found z>1.5|z| > 1.5 entry and z<0.75|z| < 0.75 exit works better for liquid equities—tighter bands mean more trades but lower profit per trade.

class SpreadTracker:
    def __init__(self, hedge_ratio: float, lookback: int = 60):
        self.beta = hedge_ratio
        self.lookback = lookback
        self.spread_history: list[float] = []

    def update(self, price_a: float, price_b: float) -> Optional[float]:
        """Returns z-score or None if insufficient data."""
        spread = price_a - self.beta * price_b
        self.spread_history.append(spread)

        # Rolling window
        if len(self.spread_history) > self.lookback:
            self.spread_history = self.spread_history[-self.lookback:]

        if len(self.spread_history) < 20:  # Minimum for stable stats
            return None

        arr = np.array(self.spread_history)
        return (spread - arr.mean()) / (arr.std() + 1e-9)  # Avoid division by zero

tracker = SpreadTracker(hedge_ratio=0.847, lookback=60)

# Simulating incoming prices
for price_msft, price_googl in zip(msft_prices[-100:], googl_prices[-100:]):
    z = tracker.update(price_msft, price_googl)
    if z is not None and z > 2.0:
        print(f"SIGNAL: Short MSFT, Long GOOGL (z={z:.2f})")
    elif z is not None and z < -2.0:
        print(f"SIGNAL: Long MSFT, Short GOOGL (z={z:.2f})")

Position Sizing: Dollar-Neutral Isn’t Enough

The naive approach: go long \$10,000 of stock A, short \$10,000 of stock B. Dollar-neutral. But this ignores volatility. If A has twice the daily volatility of B, your P&L is dominated by A’s moves.

Volatility-adjusted sizing:

wA=1/σA1/σA+1/σB,wB=1/σB1/σA+1/σBw_A = \frac{1/\sigma_A}{1/\sigma_A + 1/\sigma_B}, \quad w_B = \frac{1/\sigma_B}{1/\sigma_A + 1/\sigma_B}

Or simpler: size positions so each leg contributes equal variance to the portfolio. Here’s the code I actually use:

def calculate_position_sizes(price_a: float, price_b: float, 
                             vol_a: float, vol_b: float,
                             total_capital: float,
                             hedge_ratio: float) -> Tuple[int, int]:
    """
    Returns (shares_a, shares_b) for volatility-neutral pairs position.
    Positive shares = long, negative = short.
    """
    # Inverse volatility weighting
    weight_a = (1 / vol_a) / (1 / vol_a + 1 / vol_b)
    weight_b = 1 - weight_a

    # Dollar allocation per leg
    dollars_a = total_capital * weight_a
    dollars_b = total_capital * weight_b

    shares_a = int(dollars_a / price_a)
    shares_b = int(dollars_b / price_b)

    # Adjust for hedge ratio
    # If hedge_ratio > 1, we need more of B per unit of A
    shares_b = int(shares_b * hedge_ratio)

    return shares_a, shares_b

# Example: MSFT at $420, GOOGL at $175
# 20-day realized vol: MSFT 2.1%, GOOGL 1.8%
shares_msft, shares_googl = calculate_position_sizes(
    price_a=420, price_b=175,
    vol_a=0.021, vol_b=0.018,
    total_capital=20000,
    hedge_ratio=0.847
)
print(f"MSFT: {shares_msft} shares, GOOGL: {shares_googl} shares")
# Output: MSFT: 21 shares, GOOGL: 46 shares
Woman holding smartphone showing stock market graph while enjoying a cup of coffee.
Photo by TabTrader.com app on Pexels

Live Order Execution: The Part Nobody Talks About

Backtests assume instant fills at mid-price. Reality: you’re paying the spread on four legs (entry for A, entry for B, exit for A, exit for B). For a \$50 stock with \$0.02 spread, that’s 0.04% per leg—0.16% round trip. Multiply by 50 trades per month and transaction costs eat 8% of your capital annually.

I use Alpaca’s API for execution. The commission-free structure helps, but slippage still hurts on less liquid names.

import alpaca_trade_api as tradeapi
from dataclasses import dataclass

@dataclass 
class PairsPosition:
    symbol_a: str
    symbol_b: str
    shares_a: int  # Positive = long, negative = short
    shares_b: int
    entry_zscore: float
    entry_time: pd.Timestamp

class PairsTrader:
    def __init__(self, api_key: str, secret_key: str, paper: bool = True):
        base_url = 'https://paper-api.alpaca.markets' if paper else 'https://api.alpaca.markets'
        self.api = tradeapi.REST(api_key, secret_key, base_url)
        self.positions: list[PairsPosition] = []

    def open_position(self, symbol_a: str, symbol_b: str, 
                      shares_a: int, shares_b: int, zscore: float):
        """Execute pairs entry. Negative zscore = long A short B."""
        try:
            if zscore < 0:  # Spread too low, expect increase
                # Long A, Short B
                order_a = self.api.submit_order(
                    symbol=symbol_a, qty=abs(shares_a),
                    side='buy', type='market', time_in_force='day'
                )
                order_b = self.api.submit_order(
                    symbol=symbol_b, qty=abs(shares_b),
                    side='sell', type='market', time_in_force='day'
                )
                self.positions.append(PairsPosition(
                    symbol_a, symbol_b, shares_a, -shares_b, zscore, pd.Timestamp.now()
                ))
            else:  # Spread too high, expect decrease
                # Short A, Long B
                order_a = self.api.submit_order(
                    symbol=symbol_a, qty=abs(shares_a),
                    side='sell', type='market', time_in_force='day'
                )
                order_b = self.api.submit_order(
                    symbol=symbol_b, qty=abs(shares_b),
                    side='buy', type='market', time_in_force='day'
                )
                self.positions.append(PairsPosition(
                    symbol_a, symbol_b, -shares_a, shares_b, zscore, pd.Timestamp.now()
                ))

            print(f"Opened position: {symbol_a}/{symbol_b} at z={zscore:.2f}")
            return True

        except Exception as e:
            print(f"Order failed: {e}")
            # This happens more than you'd expect - usually "insufficient qty available"
            # for hard-to-borrow shorts
            return False

The “insufficient qty available” error shows up weekly on mid-cap shorts. Some brokers let you check borrow availability before trading—Alpaca doesn’t expose this cleanly, so I maintain a blacklist of historically hard-to-borrow tickers.

Cointegration Breakdown Detection

Here’s the uncomfortable truth: cointegration is a statistical artifact that assumes the underlying economic relationship persists. It doesn’t always. My XOM/CVX pair from January 2024 worked perfectly until March, then the spread diverged permanently—CVX announced a major acquisition, changing its fundamental risk profile.

I run a rolling cointegration test every week:

def check_cointegration_stability(series_a: pd.Series, series_b: pd.Series,
                                   window: int = 252, step: int = 21) -> pd.Series:
    """Rolling cointegration p-values. Lower = more stable."""
    pvalues = []
    dates = []

    for i in range(window, len(series_a), step):
        s1 = series_a.iloc[i-window:i]
        s2 = series_b.iloc[i-window:i]
        _, pval, _ = coint(s1, s2)
        pvalues.append(pval)
        dates.append(series_a.index[i])

    return pd.Series(pvalues, index=dates)

# Alert if p-value rises above 0.10 (cointegration weakening)
rolling_pval = check_cointegration_stability(msft_prices, googl_prices)
if rolling_pval.iloc[-1] > 0.10:
    print(f"WARNING: Cointegration weakening (p={rolling_pval.iloc[-1]:.3f})")

When cointegration breaks, the textbook says close your position immediately. But what if you’re underwater? Closing locks in a loss; holding hopes the relationship re-establishes. There’s no universally correct answer. I use a hard stop-loss at 3 standard deviations—if the spread moves that far, the relationship is probably broken regardless of what the cointegration test says.

Risk Management: Stop-Losses That Actually Work

Traditional stop-losses don’t translate cleanly to pairs trading. You’re not worried about one stock dropping; you’re worried about the spread widening beyond your thesis.

I use two stop mechanisms:

  1. Z-score stop: Exit if z>3.5|z| > 3.5 (relationship likely broken)
  2. Dollar stop: Exit if P&L on the position drops below -2% of capital allocated
def should_stop_loss(position: PairsPosition, current_zscore: float,
                     current_pnl: float, capital_allocated: float) -> Tuple[bool, str]:
    """Returns (should_stop, reason)"""

    # Z-score explosion
    if abs(current_zscore) > 3.5:
        return True, f"zscore_explosion ({current_zscore:.2f})"

    # Dollar stop
    pnl_pct = current_pnl / capital_allocated
    if pnl_pct < -0.02:
        return True, f"dollar_stop ({pnl_pct:.1%})"

    # Time stop: if position open > 3x half-life, something's wrong
    # (not implemented here but recommended)

    return False, ""

Backtest vs Live: The 40% Performance Gap

My backtest showed 18.2% annual return. Live trading: 12.3%. That 40% gap comes from three sources:

  • Slippage: 0.15% per round trip, not the 0.05% I estimated
  • Partial fills: market orders don’t always fill the full quantity at one price
  • Short availability: 11% of signals were skipped because I couldn’t borrow the short leg

The slippage hit hardest. I’ve since switched to limit orders placed at the mid-price, accepting that some signals won’t fill. Better to miss a trade than pay 3x expected transaction costs.

For backtesting pairs strategies specifically, I’d recommend QuantConnect over Backtrader—QuantConnect handles multi-leg orders more realistically. I covered backtesting frameworks in Backtrader vs QuantConnect vs Zipline: Setup Speed Test if you want the full comparison.

The Complete Trading Loop

Putting it all together:

import schedule
import time

class PairsTradingBot:
    def __init__(self):
        self.scanner = PairScanner()
        self.trader = PairsTrader(api_key='xxx', secret_key='xxx', paper=True)
        self.active_pairs = self.scanner.get_top_pairs(sector='tech', n=5)
        self.trackers = {pair['pair']: SpreadTracker(pair['hedge_ratio']) 
                         for pair in self.active_pairs}

    def on_price_update(self, prices: dict[str, float]):
        """Called every minute with latest prices."""
        for pair_info in self.active_pairs:
            sym_a, sym_b = pair_info['pair']
            if sym_a not in prices or sym_b not in prices:
                continue

            tracker = self.trackers[(sym_a, sym_b)]
            z = tracker.update(prices[sym_a], prices[sym_b])

            if z is None:
                continue

            # Check existing position first
            existing = self.trader.get_position(sym_a, sym_b)
            if existing:
                # Check exit conditions
                if abs(z) < 0.75:  # Mean reverted
                    self.trader.close_position(existing)
                    print(f"Closed {sym_a}/{sym_b} at z={z:.2f}")
                continue

            # New entry signals
            if z > 1.5:  # Spread too high
                shares_a, shares_b = calculate_position_sizes(
                    prices[sym_a], prices[sym_b],
                    self.get_volatility(sym_a), self.get_volatility(sym_b),
                    total_capital=10000,
                    hedge_ratio=pair_info['hedge_ratio']
                )
                self.trader.open_position(sym_a, sym_b, shares_a, shares_b, z)

            elif z < -1.5:  # Spread too low
                shares_a, shares_b = calculate_position_sizes(
                    prices[sym_a], prices[sym_b],
                    self.get_volatility(sym_a), self.get_volatility(sym_b),
                    total_capital=10000,
                    hedge_ratio=pair_info['hedge_ratio']
                )
                self.trader.open_position(sym_a, sym_b, shares_a, shares_b, z)

    def weekly_recalibration(self):
        """Re-run cointegration tests, update hedge ratios."""
        for pair_info in self.active_pairs:
            sym_a, sym_b = pair_info['pair']
            # Fetch fresh data and retest
            is_still_coint, _, new_beta = test_cointegration(
                self.get_prices(sym_a, days=252),
                self.get_prices(sym_b, days=252)
            )
            if not is_still_coint:
                print(f"WARNING: {sym_a}/{sym_b} no longer cointegrated, removing")
                self.active_pairs.remove(pair_info)
            else:
                pair_info['hedge_ratio'] = new_beta
                self.trackers[(sym_a, sym_b)].beta = new_beta

# Run weekly recalibration every Sunday
schedule.every().sunday.at("06:00").do(bot.weekly_recalibration)

FAQ

Q: How many pairs should I trade simultaneously?

Diversification helps, but managing too many pairs creates execution complexity and increases margin requirements. For a \$100K account, 5-8 active pairs is manageable. Each pair should use no more than 15% of capital to avoid concentration risk. I haven’t tested this at larger scale, so take the specific numbers with a grain of salt.

Q: Does pairs trading work in crypto markets?

Crypto pairs (ETH/BTC, SOL/ETH) show cointegration in backtests but break down faster than equities. The 24/7 market and higher volatility mean your half-life estimates are less reliable. I’d recommend wider entry thresholds (z>2.5|z| > 2.5) and shorter lookback windows (30 days vs 60) for crypto. Transaction costs are also higher on most exchanges.

Q: What happens during market crashes when correlations spike to 1?

This is pairs trading’s Achilles heel. During March 2020, everything moved together—your “hedge” provided no protection. The spread widens dramatically as panic selling ignores fundamentals. Some traders pause pairs strategies during VIX > 35 environments; I’ve found using tighter stops and smaller position sizes works better than sitting out entirely.


After debugging position sizing issues past midnight more times than I’d like to admit, mechanical keyboard wrist rests became a necessity rather than a luxury.

Start with paper trading. Seriously. The Alpaca paper API is identical to live, and you’ll catch integration bugs—wrong share quantities, mishandled partial fills, timezone issues—before real money is on the line.

Pairs trading isn’t passive income. It requires weekly maintenance (recalibrating hedge ratios, checking cointegration stability) and occasional manual intervention when positions behave unexpectedly. But for systematic traders who want market-neutral exposure, it’s one of the few strategies where mediocre prediction accuracy can still generate positive returns.

I’m still trying to solve the regime detection problem—knowing before cointegration breaks that it’s about to. Hidden Markov Models show promise, but the parameter tuning is finicky. That’s next on my list.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 387 | TOTAL 113,663