从零开始的配对交易:两枚拴在一起的币

套利课程第 15 课。为什么比较两个原始价格没有意义,价差及其 z-score 如何显示两枚关联币何时偏离,以及一个展示手续费如何吃掉优势的小型回测。附有可复现输出的 Python 代码。

《自己动手做套利机器人》课程第 15 课。前几种策略寻找的是两个交易所之间的价差。这一种寻找的是两枚通常一起移动的币之间的价差。

狗和它的主人

一个人牵着狗在长牵引绳上散步。狗会跑到前面,又落到后面,再跑向另一边。但牵引绳很长,不是无限长,狗不会长时间远离主人。如果你看到狗远远跑在前面,可以赌一分钟后它会更靠近主人。你不知道这一对会走向哪里,只知道它们之间的距离会回到正常水平。

两枚币也可能像这样。想想同一板块的两枚相似币,或一枚币和包装它的代币。每枚币都会因自己的原因上下波动,但有某种力量把它们拴在一起,它们之间的距离会不断回来。交易这个距离的回归叫作配对交易。它是最简单的一种统计套利。

你并不押注任一枚币的方向。距离太大时,卖出跑在前面的币,买入落在后面的币。距离缩小时,同时平掉两边。如果整个市场下跌,空头赚钱,多头亏钱,两者大致抵消。

直接的方法:比较价格

直白的想法是拿两个价格比较,看哪个更高。币 A 价格为 50,币 B 价格为 100。B“更贵”。卖 B,买 A?

这什么也说明不了。价格取决于发行了多少枚币,而不取决于币是否昂贵。较低价格的币在任何有用的意义上都不是更便宜。下面的演示市场中,开始时 A 为 50,B 为 100,固定的一半比例对这组币而言是正常的。

值得看的是比率的变化,不是比率本身。价格像乘法那样变化,一枚币涨 1%,再涨 2%,所以我们用对数把它们变成加法。一个比率的对数就是两个对数之差,这个差叫作价差:

spread = log(price of A) - log(price of B)

如果两枚币都翻倍,价差不会移动,因为两个对数都上升同样的数量。只有一枚币跑得比另一枚快时,它才会移动。这就是牵引绳上的距离。

多远才算太远:z-score

“价差很大”需要一个数字。取价差最近的 100 个值,计算它们的平均值以及围绕平均值的典型波动,标准差。然后问:今天的值比平均值高出多少个典型波动?

z = (spread now - average of the last 100) / wobble of the last 100

这就是 z-score。如果 z 为 0,这组币处在平常的距离。如果 z 为 2,价差比正常高出两个典型波动。在平静的钟形分布世界中,这大约每 100 天有 2 或 3 天会发生,因此是值得行动的罕见事件。

规则:

  • z 高于 +2:价差过高,A 跑在前面。做空 A,买入 B。
  • z 低于 −2:A 落后。买入 A,做空 B。
  • 当 z 回到接近 0 时平掉这组仓位,我们使用绝对值低于 0.5。
  • 每笔交易的四条腿都要付手续费:开 A、开 B、平 A、平 B。

代码

演示市场是刻意构造的:B 是随机游走,A 跟随 B,再加上一个总会向零回归的噪声,这就是牵引绳。然后我们运行规则。

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} %")

输出:

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 %

读一读这些结果。

  • 平均价差是 −0.693,即 log(0.5):“正常”的一半比率。它的波动为 0.026,约 2.6%。你交易的就是这个波动。
  • 规则在 1500 步中完成 22 次完整往返。一笔交易赚取价差回归的距离,这里是手续费前每笔约 3.3%,计算方法是第一行的 64.3 加上 22 笔交易 × 0.4 手续费,再除以 22 笔交易。
  • 手续费决定一切。每条腿 0.1% 时,四条腿每笔花 0.4%,总结果为 +64.3%。每条腿 1% 时,一枚小币的买卖价差很宽,四条腿花 4%,超过赚到的 3.3%,所以每笔交易都亏损,总结果为 −14.9%。
  • 结果是以一条腿的名义金额的百分比表示,并假设牵引绳确实存在。

这个玩具隐藏了什么

把 +64% 当作机制的示意图。数字之所以很大,是因为我们构建了一个牵引绳永不断裂的世界。在真实市场中:

  • 关联可能断裂。 两枚一起移动了一年的币,可能因其中一枚的新闻而永久分开。原本“过高”的价差会继续上升,跑掉的币上的空头会持续亏损。真实机器人需要一条规则:止损。如果 z 超过 4,或这组仓位开得太久,就平仓并接受损失。
  • 关联必须检查。 牵引绳的检验有个名字,协整。用通俗的话说:取价差,检查它是否真的会回到平均值,而不是不断走远。一个简单的初步检查是比较数据前半段和后半段价差的波动。
  • 两条腿的比例不一定是一比一。 这里 A 与 B 完全同步移动。真实数据中,一枚币可能比另一枚移动强 1.3 倍,因此两条腿的大小应为 1 比 1.3。寻找这个数字,回归,是完整方法的下一步。
  • 成本比表中更大。 要计入手续费、买卖价差,以及空头腿的借币成本。

让 AI 智能体编写并测试代码

一个含糊的请求:

编写一个配对交易机器人。

一个自带检查条件的请求:

按第 15 课的代码编写 pairs.py。用 random.seed(3) 创建相同的合成市场,并精确打印本课中的各行。手续费为 0.1% 的行必须显示 22 笔交易和接近 +64.3 的结果。然后添加止损:绝对 z-score 高于 4 时平掉这组仓位,单独统计被止损的交易,并打印结果如何变化。再添加一个函数,它接收两枚币的收盘价列表,返回价差及其前半段和后半段的标准差。暂时不要交易,也不要下载数据。

先将第一个输出逐行与本课比较,再让智能体添加任何内容。

练习

  1. 运行代码,核对打印出的全部六行。
  2. 将入场阈值从 2.0 改为 1.5,再改为 3.0。在每条腿手续费为 0.1% 时,交易数量和结果如何变化?较低阈值的代价是什么?
  3. 故意让牵引绳断裂。第 750 步后,每一步都给噪声加 0.004,这是持续漂移。结果还能保持在零以上吗?加入智能体请求中的止损,看看数字如何变化。
  4. 下载你认为有关联的两枚币的每日收盘价,例如使用公开交易所 API,如第 9 课所示,并计算它们的价差和波动。价差是平的,还是会游走?

接下来

你为机器人有了第三种交易。第 16 课是终点:机器人以纸面模式运行,有仪表盘、README 和公开仓库,你会把它展示给其他人。