Strasmore Research
Deep Dives Matt ConnorBy Matt Connor

Pairs Trading and Cointegration Explained

Cointegration is what pairs trading actually needs, and correlation is not the same test. Build the hedge ratio, spread and z score by hand on real closes.

Pairs trading and cointegration go together: the trade rests on a gap between two stock prices that keeps coming back to a stable level, and cointegration is the statistical property that says such a gap exists at all. Correlation measures something else. Two stocks can post a high correlation and still drift apart forever, and two stocks whose daily moves barely line up can hold a steady gap for years. This page builds the hedge ratio, the spread and the z score by hand, measures all three on real closing prices, and then spends equal space on how the construction fails.

What is cointegration in pairs trading?

Correlation is the tightness of two stocks' daily percentage moves. It runs from -1 to +1, where +1 means they rose and fell together on every session in the sample. It says nothing about the distance between the two prices.

Cointegration is a statement about that distance. Take stock B, subtract a fixed multiple of stock A, and look at what is left over. If that leftover series, the spread, stays inside a bounded range instead of wandering away, the two names are cointegrated. The prices themselves are free to go anywhere. The combination is not.

The picture textbooks use is a dog on a leash. The walker and the dog both take unpredictable paths down the street, and the leash keeps the distance between them bounded. Correlation asks whether the two step at the same moment. Cointegration asks whether the leash holds.

The panel below measures the first idea on five familiar pairs across 2024 and 2025. The return_corr column is the correlation of daily percentage moves, which is what people usually mean by the word. The price_corr column is the correlation of the raw closing prices, a number that flatters almost any two names that both went up.

QueryDaily-return correlation vs price-level correlation, five familiar pairs (2024-2025)
The exact SQL behind every number
WITH
    px AS (
        SELECT
            ticker,
            date,
            toFloat64(any(close)) AS close_px
        FROM global_markets.stocks_daily_aggs
        WHERE ticker IN ('KO', 'PEP', 'HD', 'LOW', 'XOM', 'CVX', 'V', 'MA', 'AAPL', 'MSFT')
          AND date BETWEEN '2024-01-01' AND '2025-12-31'
        GROUP BY ticker, date
    ),
    rets AS (
        SELECT
            ticker,
            date,
            close_px,
            close_px / lagInFrame(close_px) OVER (
                PARTITION BY ticker ORDER BY date
                ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
            ) - 1 AS ret
        FROM px
    )
SELECT
    p.pair                                  AS pair,
    round(corr(a.ret, b.ret), 3)            AS return_corr,
    round(corr(a.close_px, b.close_px), 3)  AS price_corr
FROM
(
    SELECT
        tupleElement(t, 1) AS leg_a,
        tupleElement(t, 2) AS leg_b,
        tupleElement(t, 3) AS pair
    FROM
    (
        SELECT arrayJoin([
            ('KO',   'PEP',  'KO / PEP'),
            ('HD',   'LOW',  'HD / LOW'),
            ('XOM',  'CVX',  'XOM / CVX'),
            ('V',    'MA',   'V / MA'),
            ('AAPL', 'MSFT', 'AAPL / MSFT')
        ]) AS t
    )
) AS p
INNER JOIN rets AS a ON a.ticker = p.leg_a
INNER JOIN rets AS b ON b.ticker = p.leg_b AND b.date = a.date
WHERE isFinite(a.ret) AND isFinite(b.ret)
GROUP BY pair
ORDER BY return_corr DESC
Run this yourself

Ranked by daily-move correlation, HD / LOW sits at the top of the panel at 0.868, while the correlation of the two price levels over the same window measured 0.849. On the bottom row, AAPL / MSFT carries a return correlation of 0.479 next to a price correlation of 0.508. Neither column answers the question a pairs trader is asking. Both measure co-movement, and the trade needs a measure of distance.

How do you calculate the hedge ratio and spread?

Five steps, in order, over two lists of closing prices. Plain Python covers all of it with math as the only import: no packages, no data vendor, no network.

  1. Center each series. mean_a = sum(a) / len(a), and the same for list b.
  2. Fit the hedge ratio. It is the slope of a straight line of B against A: beta = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(len(a))) / sum((x - mean_a) ** 2 for x in a). Read it as the number of units of A that offsets one unit of B.
  3. Build the spread. spread = [b[i] - beta * a[i] for i in range(len(a))]. On a cointegrated pair this series hovers around a level instead of trending.
  4. Take a trailing window. With w = spread[i - win + 1 : i + 1], the window mean is mu = sum(w) / len(w) and the sample standard deviation is sd = math.sqrt(sum((x - mu) ** 2 for x in w) / (len(w) - 1)). Trailing means the window ends on the day being scored and uses nothing after it.
  5. Score and print. z = (spread[i] - mu) / sd, then print an entry line while abs(z) > 1.5 and an exit line once abs(z) < 0.5. A z of 2 puts the spread two standard deviations above its own recent average.

Two hard-coded price lists are enough to watch this run, and a win of 5 keeps a toy example readable. Serious work uses something closer to 63 sessions, about a quarter. Those thresholds, 1.5 to enter and 0.5 to exit, are conventions passed from one tutorial to the next. They are not findings, and nothing in the panels below picked them.

What does a pairs trading z score look like on real prices?

Here is the same calculation on two household beverage names, KO and PEP. The hedge ratio comes from 2023 closes only, then stays fixed across 2024 and 2025, the window the chart covers. Fitting the slope on the same sample you go on to score is look-ahead bias in backtesting, and keeping the fit window and the scoring window apart is the cheapest defense against it.

QueryWeekly z score of the KO/PEP spread, hedge ratio fitted on 2023 only
The exact SQL behind every number
WITH
    daily AS (
        SELECT
            date,
            anyIf(toFloat64(close), ticker = 'PEP') AS pep,
            anyIf(toFloat64(close), ticker = 'KO')  AS ko
        FROM global_markets.stocks_daily_aggs
        WHERE ticker IN ('KO', 'PEP')
          AND date BETWEEN '2023-01-01' AND '2025-12-31'
        GROUP BY date
        HAVING pep > 0 AND ko > 0
    ),
    fitted AS (
        SELECT covarSamp(pep, ko) / varSamp(ko) AS beta
        FROM daily
        WHERE date < '2024-01-01'
    ),
    spread AS (
        SELECT
            daily.date                          AS date,
            daily.pep - fitted.beta * daily.ko  AS spread_usd
        FROM daily
        CROSS JOIN fitted
    ),
    scored AS (
        SELECT
            date,
            (spread_usd - avg(spread_usd) OVER (
                 ORDER BY date ROWS BETWEEN 62 PRECEDING AND CURRENT ROW))
            / stddevSampStable(spread_usd) OVER (
                 ORDER BY date ROWS BETWEEN 62 PRECEDING AND CURRENT ROW) AS z
        FROM spread
    )
SELECT
    toString(toMonday(date))   AS week,
    round(argMax(z, date), 2)  AS z_score
FROM scored
WHERE date >= '2024-01-01'
  AND isFinite(z)
GROUP BY week
ORDER BY week
Run this yourself

The trace carries 105 weekly readings. It opens the window at a z score of -1.53 and leaves it at -0.68. Read the shape rather than any single point. A pair behaving the way the strategy assumes crosses its threshold, spends a few weeks working back toward zero, and crosses again. A pair that has stopped mean reverting parks beyond the threshold and stays there, which on a chart looks like a long flat stretch far from the middle.

Does a cointegrated spread always come back?

No, and the honest way to see that is to measure what followed from every starting point. The panel below buckets each session from 2019 through late 2025 by that day's z score, then reports the average z score of the same spread twenty sessions later. It uses the log of the price ratio rather than a fitted spread, so no regression estimated after the fact touches the chart.

QueryWhere the KO/PEP spread sat twenty sessions later, by starting z score (2019-2025)
The exact SQL behind every number
WITH
    daily AS (
        SELECT
            date,
            anyIf(toFloat64(close), ticker = 'PEP') AS pep,
            anyIf(toFloat64(close), ticker = 'KO')  AS ko
        FROM global_markets.stocks_daily_aggs
        WHERE ticker IN ('KO', 'PEP')
          AND date BETWEEN '2018-01-01' AND '2025-12-31'
        GROUP BY date
        HAVING pep > 0 AND ko > 0
    ),
    spread AS (
        SELECT
            date,
            log(pep / ko) AS log_ratio
        FROM daily
    ),
    scored AS (
        SELECT
            date,
            (log_ratio - avg(log_ratio) OVER (
                 ORDER BY date ROWS BETWEEN 62 PRECEDING AND CURRENT ROW))
            / stddevSampStable(log_ratio) OVER (
                 ORDER BY date ROWS BETWEEN 62 PRECEDING AND CURRENT ROW) AS z
        FROM spread
    ),
    horizon AS (
        SELECT
            date,
            z,
            leadInFrame(z, 20) OVER (
                ORDER BY date ROWS BETWEEN CURRENT ROW AND 20 FOLLOWING
            ) AS z_fwd
        FROM scored
        WHERE isFinite(z)
    )
SELECT
    multiIf(z < -2, 'z below -2',
            z < -1, 'z -2 to -1',
            z <  0, 'z -1 to 0',
            z <  1, 'z 0 to 1',
            z <  2, 'z 1 to 2',
                    'z above 2')  AS z_bucket,
    round(avg(z), 2)              AS avg_z_start,
    round(avg(z_fwd), 2)          AS avg_z_20d_later,
    count()                       AS episode_count
FROM horizon
WHERE date >= '2019-01-01'
  AND date <= '2025-11-15'
  AND isFinite(z_fwd)
GROUP BY z_bucket
ORDER BY avg_z_start
Run this yourself

Sessions that began in the z below -2 bucket averaged -2.42 on the day, and the same spread averaged -0.84 twenty sessions later, across 138 sessions. At the other end, the z above 2 bucket started at 2.64 and measured 0.92 twenty sessions on. A curve that pulls toward zero from both ends is the shape mean reversion makes.

Read it with the count column in view. The outer buckets hold the fewest sessions, and sessions inside a bucket overlap heavily, so a handful of long stretches supplies most of the rows. An average is not a promise about any single entry.

Why does pairs trading stop working?

The hedge ratio is not a constant. Refit it on each calendar year and it moves.

QueryHedge ratio refitted each calendar year, two sector pairs
The exact SQL behind every number
WITH daily AS (
    SELECT
        date,
        anyIf(toFloat64(close), ticker = 'PEP') AS pep,
        anyIf(toFloat64(close), ticker = 'KO')  AS ko,
        anyIf(toFloat64(close), ticker = 'LOW') AS lowes,
        anyIf(toFloat64(close), ticker = 'HD')  AS hd
    FROM global_markets.stocks_daily_aggs
    WHERE ticker IN ('KO', 'PEP', 'HD', 'LOW')
      AND date BETWEEN '2019-01-01' AND '2025-12-31'
    GROUP BY date
    HAVING ko > 0 AND pep > 0 AND hd > 0 AND lowes > 0
)
SELECT
    toYear(date)                                  AS year,
    round(covarSamp(pep, ko) / varSamp(ko), 3)    AS pep_on_ko_beta,
    round(covarSamp(lowes, hd) / varSamp(hd), 3)  AS lowes_on_hd_beta
FROM daily
GROUP BY year
ORDER BY year
Run this yourself

The slope of PEP on KO measured 2.338 across 2019 and -0.543 across 2025, with the second sector pair alongside for comparison. A backtest that fits one slope over the full history and trades it from day one hands every early trade a number nobody could have held at the time. The fix is dull and effective: refit on a trailing window, and score each day with the parameters available that day. A reproducible backtest makes that discipline checkable by someone else.

Corporate actions rewrite one leg overnight. A 4-for-1 split turns one share into four and quarters the quoted price, and a spread computed across that date on unadjusted prices jumps to a level that never traded. Mergers, spin-offs and index changes do quieter versions of the same thing. Start with split-adjusted price history, and note that a symbol can be reassigned to an entirely different company, which is why ticker symbols break datasets.

A break and an opportunity look identical while they happen. The widest z score a pair has ever printed is either the best entry it ever offered or the first week of a relationship that has ended. Both look like a stretched spread on the day. Sizing carries the weight the statistics cannot: Kelly criterion position sizing and volatility targeting position sizing are two frameworks for how much of an account one spread is allowed to move, and maximum drawdown measures what a long stretch on the wrong side of the mean does to an equity curve.

A rolling z score is not a cointegration test. Formal tests exist. The Engle-Granger procedure runs the regression, then tests the leftover spread for stationarity, and the Johansen test extends the idea past two names. A z score assumes the property those tests check, and it will happily print a large number on a spread with no stable mean at all, since a trending series always sits a long way from its own trailing average.

Data notes and what these numbers leave out

The correlation panel uses daily closes from 2024-01-01 through 2025-12-31 and computes returns from consecutive closes, dropping each name's first session. The z score trace fits the hedge ratio on 2023 closes only, then scores 2024 and 2025 with a trailing 63-session mean and sample standard deviation, sampled to the last reading of each week. The reversion panel loads history from 2018 and reports from 2019 onward, so every scored day has a full trailing window behind it, and it stops in mid-November 2025 so every day has twenty sessions ahead of it inside the sample.

None of these panels is a strategy. They carry no trading costs, no bid-ask spread, no borrow fee on the short leg, no dividends and no financing. Closing prices only, which means an entry marked at a close is an entry nobody could have filled there.

FAQ

What is the difference between correlation and cointegration?

Correlation measures whether two stocks move on the same days and in the same direction, on a scale of -1 to +1. Cointegration measures whether the distance between their prices stays inside a stable range. A pair can score high on the first and fail the second, which is the usual reason a correlation screen throws up pairs that then drift apart.

How do you calculate the hedge ratio in pairs trading?

Fit a straight line of one stock's price against the other's and take the slope. That slope is the number of units of the first stock that offsets one unit of the second, and it is a moving quantity, so most implementations refit it on a trailing window rather than once over the whole sample.

What z score do pairs traders use to enter and exit?

Tutorial examples usually enter around 1.5 to 2 standard deviations from the trailing mean and exit near 0.5. Those numbers are conventions inherited from earlier write-ups rather than measured optima, and changing them changes both the number of trades and how long each is held.

Can two stocks be cointegrated with low correlation?

Yes. Correlation is computed on daily changes and cointegration on the level of the gap, so two names whose day-to-day moves rarely line up can still hold a stable long-run distance. The reverse happens too: a high daily correlation alongside a gap that widens year after year.

Why does a cointegrated pair stop working?

The link holding the gap together can change: a merger, a spin-off, a shift in one company's business mix, or an index change that alters who owns the stock. The statistics in the days before a break look the same as the days before an ordinary reversion, which is the part that has to be sized for rather than predicted.


Every panel here ships with the exact SQL underneath, so the windows, the fit period and the bucket edges are all visible. To run the same spread and z score on a pair you follow, ask the question in plain English on the Strasmore terminal.

#pairs trading#cointegration#mean reversion#backtesting#statistics