Technique explained as code
Pairs spread
Watch the gap between two instruments that have moved together and act when it is unusually wide. The invented data is built to work at first and then break, so you can see both.
Risk warning: CFDs and spread bets are complex instruments and come with a high risk of losing money rapidly due to leverage. You can lose money on these products, and each provider publishes its own loss figure under FCA rules. You should consider whether you understand how they work and whether you can afford to take the high risk of losing your money. Automated and API trading can amplify losses through bugs, connectivity failures and runaway orders.
Teaching material only. This page explains how a technique works as code and how to test an idea honestly. It is not investment advice, not a recommendation and not an invitation to trade. Every price on it is invented, so no result here says anything about any real market, and nothing here suggests that a technique makes money.
What it is
A pairs rule does not look at one price. It looks at the difference between two, called the spread. When the spread is unusually wide it sells the higher one and buys the lower one, and waits for the gap to close.
Unusual is measured with a z score: how many standard deviations the spread is from its own recent average.
The idea behind it
If two instruments are driven by the same things, each should rise and fall with the other, and a gap between them should be temporary. Holding one against the other is meant to cancel the shared movement and leave only the gap.
Everything rests on the word if. The technique is mean reversion applied to a relationship, and a relationship can end.
The code
Be clear about the data before reading the result. The script that made these two series pulls their gap back towards zero for the first 300 days, on purpose. Then it switches the pull off and pushes the gap steadily apart. The first part of the output therefore shows a rule working on data that was built for it. That proves only that the code does what it says.
The second part is the lesson. Nothing in the prices announces the day the link ends.
Data the script reads: invented-pair.csv. Invented values, never a real market. Save it in one folder and run the script from that folder.
The file that was run: pairs_spread.py
# A pairs spread: watch the gap between two instruments that have moved together.
# Prices come from invented-pair.csv. Both series were made by a script, never a real market.
# The script that made them pulls the gap back towards zero for the first 300 days and then
# switches that off. So the first part works because it was built to, and the second part
# shows what happens when a relationship ends. Neither says anything about any real market.
import csv
a, b = [], []
with open("invented-pair.csv", newline="") as f:
for row in csv.DictReader(f):
a.append(float(row["a"]))
b.append(float(row["b"]))
spread = [x - y for x, y in zip(a, b)]
def average(values):
return sum(values) / len(values)
def standard_deviation(values):
mean = average(values)
return (sum((v - mean) ** 2 for v in values) / len(values)) ** 0.5
def z_score(end, days):
"""How unusual the spread at position `end` is, measured against the `days` days before it."""
window = spread[end - days:end]
return (spread[end] - average(window)) / standard_deviation(window)
# Example values, picked only because they are easy to follow. They are not a suggestion.
DAYS = 30 # history the spread is measured against
ENTER = 2.0 # how unusual the spread must be before acting
COST = 0.20 # points per completed trade: two instruments, so two spreads to pay
def run(first, last):
"""Run the rule over days first..last-1 and return the result of each trade."""
side = 0 # +1 means long the spread (bought a, sold b). -1 is the reverse. 0 is flat.
entry = 0.0
results = []
for day in range(first, last):
z = z_score(day - 1, DAYS) # yesterday's reading, from yesterday's history
price = spread[day]
if side == 0 and z > ENTER:
side, entry = -1, price # spread unusually wide: act as if it will narrow
elif side == 0 and z < -ENTER:
side, entry = 1, price # spread unusually narrow: act as if it will widen
elif (side == 1 and z >= 0) or (side == -1 and z <= 0):
results.append(side * (price - entry) - COST)
side = 0
if side != 0:
results.append(side * (spread[last - 1] - entry) - COST)
return results
def report(label, first, last):
results = run(first, last)
print(label)
print(" Trades:", len(results), "of which above zero:", len([r for r in results if r > 0]))
if results:
print(" Worst single trade:", round(min(results), 2))
print(" Total points after costs:", round(sum(results), 2))
print(" Simply holding a over the same days:", round(a[last - 1] - a[first] - COST / 2, 2))
print(" Simply holding b over the same days:", round(b[last - 1] - b[first] - COST / 2, 2))
BREAK = 300 # the day the generator stops pulling the gap back
report("Days when the link was built in:", DAYS + 1, BREAK)
report("Days after the link was switched off:", BREAK, len(spread))What the run printed
Days when the link was built in: Trades: 8 of which above zero: 8 Worst single trade: 0.09 Total points after costs: 10.26 Simply holding a over the same days: 20.11 Simply holding b over the same days: 19.29 Days after the link was switched off: Trades: 4 of which above zero: 0 Worst single trade: -8.25 Total points after costs: -13.19 Simply holding a over the same days: -0.82 Simply holding b over the same days: -13.8
Output of the run on , copied as printed. Every input to this run was invented, so the result says nothing about any real market.
How to read it
In the first block every trade ended above zero. Do not read that as a result. The generator forces the gap to close, so a rule that waits for the gap to close cannot fail there. It is a check that the code is right, nothing more. Even so, simply holding either series ended higher over those days.
In the second block the same code, with the same settings, lost on every trade, and lost more in four trades than it had gained in eight. The gaps it judged unusually wide did not come back.
This is the shape of the technique's risk: a long run of small gains, then losses that are larger and arrive together. Real pairs carry no label saying which block you are in. Neither block says anything about any real market.
How it fails or misleads
- The relationship ends. A merger, a change in one business or a change in regulation can separate two prices for good. The rule reads the widening gap as a bigger opportunity and holds on.
- Finding the pair is itself a search. Test enough pairs and some will have moved together by chance. Those are the ones most likely to stop.
- Two instruments mean two sets of costs on the way in and two on the way out, and one side has to be sold short, which has costs and limits of its own that this script leaves out.
- The rule as written has no exit other than the gap closing. Adding one is necessary and changes the results.
- A plain difference in price is a crude spread. If one instrument moves twice as much as the other, the shared movement does not cancel.
The page on how a backtest lies shows the mistakes that make any rule look better in a test than it is.
What to try changing
These are programming exercises. Each one changes the code and asks you to explain what you see. None of them is a search for settings to use.
- Add a stop: close the trade if the z score goes beyond 4. Run both blocks again and compare the worst single trade.
- Print the spread at the start, at day 300 and at the end, so you can see what the generator did.
- Open make_invented_data.py, change the day the link breaks, regenerate the file and run again.
- Replace the fixed 30 day window with all the history so far. Does that make the break easier or harder to notice?
- Write a function that measures how closely the two series moved together in any stretch of days, and print it for each block.
How this page was made
Each script on this page was run with Python 3.12 on Windows, standard library only, from a folder holding the data files listed. No network and no account. Each exited with code 0, and what is shown is everything it printed.
The data files whose names begin with the word invented are written by make_invented_data.py from fixed seeds, so anyone can make the same files again and get the same output. This site is not published if the code or a data file has changed since the run.