QuantJourney Backtester

QuantJourney Backtester

Share product feedback

Thank you.

Your note is now in the QuantJourney inbox.

Research guide · Pairs trading

Related is not revertible

Pairs trading is the cleanest laboratory in quantitative finance: one economic hypothesis (this spread mean-reverts), one state machine, two legs. It is also where wishful thinking hides best, because almost any two related instruments look like a pair. This guide builds the machinery — spread construction, hedge ratios, the z-score state machine — then runs it honestly on a pair that looks perfect and measurably is not, and shows the one-line diagnostic that would have told you first.

Two runnable specifications Half-life diagnostic, derived A measured negative result Market-neutral weight mode

What a pairs backtest actually tests

The thesis is conditional and precise: this particular definition of the spread between A and B reverts to this particular definition of its mean, fast enough to beat costs. Nothing less specific is testable.

That precision is the point of the exercise. "KO and PEP move together" is a correlation observation — cheap, common and not a strategy. The tradeable claim is stronger: some combination of the two prices is stationary — it has a level it keeps coming back to — and deviations from that level are large and frequent enough to pay the round trips. Correlated returns do not imply any of this: two assets can be 0.9-correlated daily while their price ratio drifts forever (they co-move but re-scale). The statistical name for the property you actually need is cointegration, and the operational quantity that summarizes it for a trader is the spread's half-life of mean reversion — both measurable before a single backtest runs.

Where the selection bias hides

Scan 100 tickers for "good pairs" and you evaluate ~5,000 candidates — the best-looking spread of five thousand is spectacular by construction, cointegration tests included. Pair selection is a search process like any other: it needs the multiple-testing corrections (Romano–Wolf across the candidate family) and out-of-sample confirmation, or it needs to be replaced by a pre-declared economic argument, which is what the engine's examples do (KO/PEP, EWA/EWC — chosen for the economics, not mined from the data).

Constructing the spread

Two specifications ship with the engine; they differ in exactly one decision — whether the relationship between the legs is fixed or estimated.

st  =  lnPtAlnPtB(fixed 1:1),st  =  lnPtAβtlnPtB(rolling hedge)s_t \;=\; \ln P^{A}_{t} - \ln P^{B}_{t} \qquad \text{(fixed 1:1)}\,, \qquad s_t \;=\; \ln P^{A}_{t} - \beta_t \ln P^{B}_{t} \qquad \text{(rolling hedge)}

The fixed log-ratio (example W13) assumes the pair moves 1:1 in log space — the simplest possible relationship, with no fitted hedge coefficient. It is an easy-to-reproduce baseline whose dominant model risk is long-run relative drift between the legs; pair selection, z-score settings, execution, borrow and financing risks still remain. The rolling hedge ratio (example W14) estimates the relationship from a trailing window by OLS:

βt  =  Cov60(lnPA,lnPB)Var60(lnPB)\beta_t \;=\; \frac{\operatorname{Cov}_{60}\big(\ln P^A,\, \ln P^B\big)}{\operatorname{Var}_{60}\big(\ln P^B\big)}
rolling_spread.py — example_weights_14 corepython
# example_weights_14: the spread adapts, the sizing does not.
la, lb = np.log(close[a]), np.log(close[b])

beta = la.rolling(60).cov(lb) / lb.rolling(60).var()   # strictly trailing
spread = la - beta * lb
z = (spread - spread.rolling(60).mean()) / spread.rolling(60).std()

# beta changes WHICH series is normalized - the legs stay +/-0.5.
# A beta-SIZED book (w_B = -beta * w_A) is a different strategy with
# different gross exposure; normalize it explicitly if you build one.

The adaptive spread tracks a changing relationship — at the price of estimation risk (β is one more trailing window that can lag a break) and a subtlety the code comment above spells out: in W14 the estimated β changes the series being normalized, not the position sizes. The legs stay ±0.5. A book that sizes leg B at −β per unit of A is a different, leverage-varying strategy; if you want it, build it deliberately with explicit gross-exposure normalization — do not let it emerge from a spread formula by accident.

DecisionW13 — fixed ratioW14 — rolling hedge
Spread modellog(A) − log(B)log(A) − β₆₀·log(B)
Estimated parametersNoneRolling β (strictly trailing)
Fails when…The 1:1 relation driftsThe relation breaks faster than 60 bars
Normalization60-bar rolling z-score, both examples
Sizing±0.5 per leg — gross 1.0, net ≈ 0

The z-score state machine

Entry and exit thresholds do not generate isolated signals — they drive a persistent position state. The asymmetry between them (enter at 2, exit at 0.5) is what prevents thrashing at the boundary.

zt  =  stμ60(s)σ60(s)enter z>2,exit z<0.5z_t \;=\; \frac{s_t - \mu_{60}(s)}{\sigma_{60}(s)} \qquad \text{enter } |z| > 2\,, \quad \text{exit } |z| < 0.5
example_weights_13_pairs_ratio_zscore.pypython
class PairsRatioZScore(Backtester):
    """Market-neutral pair on a log-ratio z-score (example_weights_13)."""

    LOOKBACK = 60
    ENTRY = 2.0
    EXIT = 0.5

    def _compute_signals(self) -> pd.DataFrame:
        close = self.instruments_data.get_feature("adj_close")
        a, b = self.instruments[0], self.instruments[1]
        spread = np.log(close[a]) - np.log(close[b])
        z = (spread - spread.rolling(self.LOOKBACK).mean()) \
            / spread.rolling(self.LOOKBACK).std()

        signals = pd.DataFrame(0.0, index=close.index, columns=close.columns)
        state = 0            # +1 = long A / short B, -1 = short A / long B
        for i, zi in enumerate(z):
            if pd.isna(zi):
                state = 0                    # warm-up: flat, honestly
            elif state == 0:
                if zi > self.ENTRY:
                    state = -1               # A rich vs B -> short A, long B
                elif zi < -self.ENTRY:
                    state = 1                # A cheap vs B -> long A, short B
            elif abs(zi) < self.EXIT:
                state = 0                    # reverted -> unwind
            signals.iloc[i] = [state, -state]
        return signals

    def _compute_weights(self) -> pd.DataFrame:
        return self.signals * 0.5            # +/-0.5 per leg: gross 1.0, net ~0
z > +2Short A, long B — A is rich
z < −2Long A, short B — A is cheap
|z| < 0.5Reverted — close both legs
NaN / warm-upFlat, never forward-filled

Two engine disciplines do quiet work here. The warm-up rows are honest zeros (the workflow guide's trap 2), and the state machine's decisions are shifted one bar by the engine's accounting — the z-score computed from tonight's closes earns tomorrow's spread move, never tonight's. Every rolling statistic in both examples is strictly trailing for the same reason: a sizing or signal layer that peeks is look-ahead wearing a costume.

The diagnostic that comes before the backtest

One regression tells you whether the spread reverts on a horizon your window can trade — before you spend a single backtest on it.

Model the spread as an AR(1) in changes: regress today's spread change on yesterday's spread level. A negative coefficient means deviations pull back toward the mean, and its size converts directly into the half-life — the expected time for a deviation to close half the gap:

Δst  =  a+bst1+εthalf-life  =  ln2ln(1+b)(b<0)\Delta s_t \;=\; a + b\, s_{t-1} + \varepsilon_t \qquad\Longrightarrow\qquad \text{half-life} \;=\; \frac{-\ln 2}{\ln(1+b)} \qquad (b < 0)
half_life.py — run this firstpython
# The diagnostic that comes BEFORE any backtest: does this spread
# actually mean-revert on a horizon your window can trade?

spread = np.log(close["TLT"]) - np.log(close["IEF"])

ds = spread.diff().dropna()                  # AR(1) on spread changes
s_lag = spread.shift(1).dropna().loc[ds.index]
b = np.polyfit(s_lag - s_lag.mean(), ds, 1)[0]

half_life = -np.log(2) / np.log(1 + b)       # b must be negative
print(f"half-life: {half_life:.0f} trading days")
# TLT/IEF, 2007-2026:  473 days

# Rule of thumb: the z-score LOOKBACK should live on the same scale
# as the half-life. A 60-day window on a 473-day spread does not
# measure reversion - it measures noise around a drifting level.

The rule of thumb it enables: the z-score lookback and the half-life must live on the same scale. A 60-day window on a 20–60-day half-life spread measures genuine stretch and snap-back. The same window on a 473-day half-life spread measures noise around a level that migrates slower than the window can see — every "entry" is a bet that a random wiggle reverts, and the position's fate is decided by the drift, not the spread.

A measured example: TLT/IEF, and why it loses

Long and intermediate Treasuries: same issuer, same rate factor, correlation ~0.9. The most plausible-looking pair in the ETF universe — and the diagnostic above already said no.

We ran the W13 machinery exactly as shipped — 60-day z-score, ±2 entry, 0.5 exit, daily rebalance, 5 bps per side — on TLT/IEF over 2007–2026, on the engine's fast-weights path. The half-life of the log-ratio is 473 trading days: the spread is dominated by slow, persistent moves in the yield curve's shape (duration exposure differs by design), not by a stationary relationship. The backtest agrees with the diagnostic:

MetricValueReading
Sharpe−0.25The machinery traded noise, net of costs
CAGR−0.88%A slow bleed, not a blow-up
Max drawdown−22.3%19 years of compounding small losses
Ann. volatility3.3%"Market neutral" ≠ safe — just quiet
Round trips84~4.4 per year, in market 45% of days
Turnover / costs9.5× · $8,2680.47%/yr of drag on a 3.3%-vol book
Spread half-life473 daysvs a 60-day window — the mismatch that decided everything
TLT/IEF log-ratio z-score with entry bands and position shading, and the resulting equity curve
The z-score crosses ±2 constantly — the state machine finds 84 "opportunities" — while the equity curve (bottom) bleeds from $100k to ~$85k. On a 473-day half-life spread, a 60-day z-score is a random-number generator with conviction.
pairs_tlt_ief.py — the run above, reproduciblepython
pair = PairsRatioZScore(
    api_key=os.environ.get("QJ_API_KEY"),
    strategy_name="Pairs_TLT_IEF",
    strategy_type="Market Neutral",
    initial_capital=100_000,
    instruments=["TLT", "IEF"],
    backtest_period={"start": "2007-01-03", "end": "2026-01-01"},
    benchmark_symbol="SPY",
    source="yfinance",
    execution_mode="weights",
    rebalance_policy=RebalancePolicy(frequency="D"),
    weight_cost_model=FixedBpsWeightCostModel(total_bps=5.0),
    indicators_config=[],
)
await pair.run_strategy()
pair.print_summary()

Why publish a losing run? Because this is the most common pairs-trading failure in the wild, and it never looks like a failure in advance: high correlation, an obvious economic link, a z-score that generates confident entries — every surface indicator says trade it. The half-life regression is the one-line test that separates this pair from a tradeable one, and it costs nothing. The shipped KO/PEP and EWA/EWC examples pass the same sanity scale; TLT/IEF is the control group.

Dollar-neutral is not risk-neutral

±0.5 per leg targets near-zero net dollar exposure. Everything else — beta, duration, factor and currency exposure — must be checked, not assumed.

The TLT/IEF book above is dollar-neutral and still carries a large structural exposure: long one duration, short another, it is a leveraged bet on the yield curve's slope — which is precisely the slow factor that dominated its spread. Equity pairs inherit sector and market beta whenever the legs' betas differ; country pairs (EWA/EWC) carry currency. The portfolio reports make this visible — per-leg realized weights, gross and net exposure, and the benchmark-relative rolling beta chart — and reading them is part of the strategy, because a "market-neutral" label on a factor bet is how quiet books produce loud surprises.

Costs, borrow and capacity

A mean-reversion book pays costs on every round trip and holds two legs while it waits. Small frictions dominate small edges.

The measured run already shows the arithmetic: 9.5× annual turnover at 5 bps costs 47 bps a year — on a book whose gross volatility is 3.3%, that is a seventh of the risk budget spent on friction before any edge is earned (the costs guide develops the full turnover-drag machinery). Two costs the engine's research baseline deliberately does not model deserve explicit mention in any pairs write-up: short borrow — availability, fees and recall risk on the short leg, which for hard-to-borrow names can exceed the spread edge outright — and leg execution risk — the two legs do not fill simultaneously in practice, and a fast-moving spread charges the gap to you. Both belong in the orders-path stress test before any live deployment.

Validating a pairs strategy

Everything in the site's validation toolkit applies, with one pairs-specific emphasis: the selection of the pair is part of the process being tested.

The MCPT signal-timing test asks whether the state machine's timing beats circular-shift null paths of itself; bar-permutation kills the spread's temporal structure and demands the edge die with it (the pure weight_fn both examples use makes them directly testable). If parameters were tuned — lookback, entry, exit — the search belongs inside walk-forward folds with DSR and explicitly labeled rank stability on the record. And if the pair itself was screened from a universe, the candidate family goes through Romano–Wolf: the best of 5,000 spreads must survive being the best of 5,000, not just beat its own shuffles.

Run both specifications

terminalbash
# Fixed 1:1 log-ratio, KO / PEP (consumer staples)
./strategy.sh example_weights_13_pairs_ratio_zscore

# Rolling OLS spread, EWA / EWC (country ETFs)
./strategy.sh example_weights_14_pairs_hedge_ratio

The pairs checklist

  1. Declare the pair before touching data

    An economic argument for the relationship, written first — or a mined selection corrected for the full family it was mined from.

  2. Measure the half-life first

    One AR(1) regression. If it is several times your intended z-window, stop — the machinery will trade noise around a drift, as TLT/IEF demonstrates.

  3. Match the window to the physics

    Lookback on the half-life's scale; entry/exit asymmetric (2.0 / 0.5) so the state machine does not thrash at the boundary.

  4. Audit both legs, not the combined NAV

    Per-leg weights, gross and net exposure, rolling beta vs benchmark — dollar-neutral books hide factor bets in plain sight.

  5. Stress the frictions the baseline omits

    Borrow fees on the short leg, leg-fill asynchrony, and the cost ladder — a 3%-vol book has no room for 1% of friction.

  6. Validate like any other strategy

    MCPT on the final configuration, walk-forward for tuned parameters, Romano–Wolf when the pair came from a screen.

Need this run on your strategy?

We do independent validation engagements — permutation tests, walk-forward, Deflated Sharpe and rank stability on your code or track record, delivered as a signed, reproducible report.

Validation services →

References

  • Gatev, E., Goetzmann, W. & Rouwenhorst, K. G. (2006). Pairs Trading: Performance of a Relative-Value Arbitrage Rule. Review of Financial Studies 19(3) — the canonical empirical study.
  • Engle, R. F. & Granger, C. W. J. (1987). Co-integration and Error Correction. Econometrica 55(2) — why correlation is not the property you need.
  • Avellaneda, M. & Lee, J.-H. (2010). Statistical Arbitrage in the US Equities Market. Quantitative Finance 10(7) — OU modeling of spreads at scale.
  • Vidyamurthy, G. (2004). Pairs Trading: Quantitative Methods and Analysis. Wiley.
  • Chan, E. (2013). Algorithmic Trading: Winning Strategies and Their Rationale. Wiley, ch. 2–3 — the half-life heuristic this guide operationalizes.
  • Do, B. & Faff, R. (2010). Does Simple Pairs Trading Still Work? Financial Analysts Journal 66(4) — the decay of the classic rule after costs.

Continue the research