Pairs Trading From Zero: Two Coins on a Leash
Lesson 15 of the arbitrage course. Why comparing two raw prices means nothing, how the spread and its z-score show when two linked coins drifted apart, and a small backtest that shows how fees eat the edge. Python code with output you can reproduce.
Lesson 15 of the course "Build your own arbitrage bot". The first strategies looked for a gap between two exchanges. This one looks for a gap between two coins that usually move together.
The dog and its owner
A man walks his dog on a long leash. The dog runs ahead, then falls behind, then crosses to the other side. But the leash is long, not endless, and the dog is never far from the man for long. If you see the dog far ahead, you can bet that in a minute it will be closer to the man. You do not know where the pair goes. You know only that the distance between them returns to normal.
Two coins can behave like that. Think of two similar coins from one sector, or a coin and the token that wraps it. Each one wanders up and down for its own reasons, but some force holds them together, and the distance between them keeps coming back. Trading the return of that distance is called pairs trading. It is the simplest kind of statistical arbitrage.
You do not bet on the direction of either coin. When the distance is too big, you sell the one that ran ahead and buy the one that fell behind. When the distance shrinks, you close both. If the whole market falls, your short makes money and your long loses, and the two roughly cancel.
The head-on way: compare the prices
The blunt idea is to take the two prices and see which is higher. Coin A costs 50, coin B costs 100. B is "more expensive". Sell B, buy A?
That tells you nothing. A price depends on how many coins were issued, not on whether the coin is dear. The coin with the lower price is not cheaper in any useful sense. In the demo market below A costs 50 and B costs 100 at the start, and that fixed ratio of one half is normal for that pair.
What is worth looking at is the change of the ratio, not the ratio itself. Prices behave like multiplications (a coin goes up 1 percent, then 2 percent), so we turn them into additions with a logarithm. The log of a ratio is the difference of logs. That difference is called the spread:
spread = log(price of A) - log(price of B)
If both coins double, the spread does not move, because both logs rise by the same amount. It moves only when one coin outruns the other. That is the distance on the leash.
How far is too far: the z-score
"The spread is big" needs a number. Take the last 100 values of the spread. Compute their average and their typical wobble around the average (the standard deviation). Then ask: how many wobbles is today's value above the average?
z = (spread now - average of the last 100) / wobble of the last 100
This is the z-score. If z is 0, the pair is at its usual distance. If z is 2, the spread is two wobbles above normal. In a calm bell-shaped world that happens in about 2 or 3 days out of 100, so it is a rare event worth acting on.
The rules:
- z above +2: the spread is too high, A ran ahead. Short A, buy B.
- z below −2: A fell behind. Buy A, short B.
- Close the pair when z comes back close to 0 (we use under 0.5 in absolute value).
- Each trade pays fees on four legs: open A, open B, close A, close B.
The code
The demo market is made on purpose: B is a random walk, and A follows B plus a noise that always drifts back toward zero (this is the leash). Then we run the rules.
import math, random, statistics
random.seed(3)
n = 1500
log_b, noise = [math.log(100)], [0.0]
for t in range(n):
log_b.append(log_b[-1] + random.gauss(0, 0.01)) # coin B: a random walk
noise.append(noise[-1] * 0.9 + random.gauss(0, 0.01)) # a noise that always drifts back to 0
log_a = [log_b[t] + noise[t] + math.log(0.5) for t in range(n + 1)] # coin A follows B
price_a = [math.exp(x) for x in log_a]
price_b = [math.exp(x) for x in log_b]
print("prices at the start:", round(price_a[0], 2), round(price_b[0], 2))
spread = [log_a[t] - log_b[t] for t in range(n + 1)]
print("spread mean", round(statistics.mean(spread), 3), "stdev", round(statistics.pstdev(spread), 3))
def backtest(spread, window=100, entry=2.0, exit_=0.5, fee=0.001):
pos, entry_spread, pnl, trades = 0, 0.0, 0.0, 0
for t in range(window, len(spread)):
w = spread[t - window:t]
z = (spread[t] - statistics.mean(w)) / statistics.pstdev(w)
if pos == 0:
if z > entry: pos, entry_spread = -1, spread[t] # spread high: short A, long B
elif z < -entry: pos, entry_spread = +1, spread[t] # spread low: long A, short B
elif abs(z) < exit_:
pnl += pos * (spread[t] - entry_spread) - 4 * fee # 2 legs in, 2 legs out
trades += 1
pos = 0
return trades, pnl * 100
for fee in (0.001, 0.003, 0.006, 0.01):
trades, result = backtest(spread, fee=fee)
print(f"fee {fee*100:.1f}% per leg: trades {trades}, result {result:+.1f} %")
Output:
prices at the start: 50.0 100.0
spread mean -0.693 stdev 0.026
fee 0.1% per leg: trades 22, result +64.3 %
fee 0.3% per leg: trades 22, result +46.7 %
fee 0.6% per leg: trades 22, result +20.3 %
fee 1.0% per leg: trades 22, result -14.9 %
Read it.
- The spread mean is −0.693, which is log(0.5): the "normal" ratio of one half. Its wobble is 0.026, about 2.6 percent. That wobble is what you trade.
- In 1500 steps the rules made 22 round trips. A trade earns the distance the spread travelled back, here about 3.3 percent per trade before fees (the first row: 64.3 plus 22 trades × 0.4 fees, divided by 22 trades).
- Fees decide everything. At 0.1 percent per leg, four legs cost 0.4 percent per trade and the total is +64.3 percent. At 1 percent per leg (a small coin with a wide spread between buy and sell), four legs cost 4 percent, more than the 3.3 percent earned, so every trade loses and the total is −14.9 percent.
- The result is in percent of one leg's notional, and it assumes the leash exists.
What this toy hides
Treat the +64 percent as a picture of the mechanism. The numbers are huge because we built a world where the leash never breaks. In a real market:
- The link can break. Two coins that moved together for a year may separate for good after news about one of them. A spread that was "too high" keeps rising, and the short on the runaway coin keeps losing. A rule needed in the real bot: a stop. If z goes beyond 4 or the pair has been open for too long, close it and accept the loss.
- The link must be checked. The test for a leash has a name, cointegration. In plain words: take the spread and check that it really returns to its average instead of wandering off. A simple first check is to compare the spread's wobble in the first half of the data with the second half.
- The ratio of the two legs may not be one to one. Here A moves exactly with B. In real data one coin may move 1.3 times as much as the other, so you size the legs 1 to 1.3. Finding that number (a regression) is the next step in the full method.
- Costs are bigger than in the table. Include fees, the gap between buy and sell prices, and the cost of borrowing for the short leg.
Let an AI agent write and test it
A vague request:
Write a pairs trading bot.
A request with checks built in:
Write pairs.py from the lesson 15 code. Make the same synthetic market with random.seed(3) and print the lines exactly as in the lesson. The line for fee 0.1% must show 22 trades and a result near +64.3. Then add a stop: close the pair when the absolute z-score is above 4, count stopped trades separately, and print how the result changes. Then add a function that takes a list of closing prices of two coins and returns the spread and its first-half and second-half standard deviation. Do not trade anything and do not download data yet.
Check the first output line by line against the lesson before you let the agent add anything.
Your exercise
- Run the code and match all six printed lines.
- Change the entry threshold from 2.0 to 1.5 and then to 3.0. How do the number of trades and the result change at fee 0.1 percent? What does a lower threshold cost you?
- Break the leash on purpose. After step 750, add 0.004 to the noise at every step (a steady drift). Does the result stay above zero? Add the stop from the agent request and see how the numbers move.
- Download the daily closing prices of two coins you think are linked (for example from a public exchange API, as in lesson 9) and compute their spread and its wobble. Is the spread flat, or does it wander?
What comes next
You have a third kind of trade for the bot. Lesson 16 is the finish: the bot runs in paper mode with a dashboard, a README, and a public repository, and you show it to other people.