层次聚类配对交易:用相关性树在几百只股票里挖出可交易的对
配对交易的第一道难关不是回测,是从 N 只股票的 C(N,2) 个组合里挑对——30 只就有 435 对,全跑一遍协整检验既慢又是数据挖掘的温床。本文用纯 numpy/scipy 合成 30 只股票(内嵌市场因子+5 个板块因子+一对刻意注入的协整对),把层次聚类当成配对的漏斗:先用相关性距离 √(0.5(1-ρ)) 建 Ward 连接树,聚类纯度 90.9%,把 435 对候选压到 100 对簇内高相关组合(筛掉 77%);再在簇内用『对冲比为正 + 相关>0.5』预筛、按价差 AR(1) 半衰期排序挑出真正均值回归的对(金融2/金融5,相关 0.64、半衰期 15.7 天)。配对回测 3 年 15 次开仓、日胜率 52%、Sharpe 0.58、累计价差收益 20.5%。全文最重要一节是打穿『相关≠协整』:刻意注入的高相关对若对冲比为负、价差不回归,照样亏钱;聚类只负责缩小搜索空间,真正决定盈亏的是价差平稳性。另拆穿聚类结构随窗口漂移、树状图切割高度主观、簇内组合仍需协整二次筛、样本内挑对必然乐观四类陷阱(中高阶)。
先说结论:配对交易真正的瓶颈不是”怎么交易一对股票”,而是”从几百只股票里挑出哪一对值得交易”。30 只股票就有 435 个组合,300 只就有近 45000 个——把每一对都跑一遍协整检验,不但慢,更是数据挖掘的重灾区:试的对越多,纯靠运气蒙到”看起来协整”的伪对就越多。本文的做法是用层次聚类当漏斗:先按相关性把股票聚成板块,把搜索空间从 435 对压到 100 对(筛掉 77%),再在簇内做协整二次筛。但全文最想让你记住的一句话是:聚类只负责缩小范围,它挑出的高相关对不等于能交易的对——相关是相关,协整是协整,混淆这两个概念的配对策略都会在实盘里流血。
一、组合爆炸:为什么不能暴力枚举所有对#
配对交易的逻辑很干净:找两只长期同涨同跌的股票,当它们的价差偏离历史均值时,做多落后的、做空领先的,赌价差回归。
问题出在”找”这个字。N 只股票的两两组合数是 C(N,2)=N(N-1)/2:
- 30 只 → 435 对
- 100 只 → 4950 对
- 300 只 → 44850 对
对每一对都跑一次协整检验(如 Engle-Granger 或 Johansen),计算量随 N 平方增长。更致命的是多重检验问题:假设真实协整对只占 1%,但你在 44850 对里做检验,即便每次的假阳性率只有 5%,也会蒙出两千多个”看起来协整”的伪对。你从里面挑最漂亮的去交易,本质是在挑运气,样本外必然崩。
所以正确的顺序是:先用便宜的方法(聚类)把搜索空间砍到一小撮结构上有理由相关的候选,再用贵的方法(协整检验)精挑。
二、相关性距离:把”像不像”变成”离多远”#
层次聚类需要一个距离矩阵,但相关系数不是距离——它越大表示越像,方向反了。经典做法(Mantegna 1999)是把相关系数转成一个满足距离公理的度量:
完全正相关(ρ=1)时距离为 0,完全负相关(ρ=-1)时距离为 1。先合成一个带板块结构的市场:30 只股票 = 市场公共因子 + 5 个板块因子 + 个股特质噪声,并在”消费”板块里刻意注入一对真正协整的股票(共享同一条随机趋势 + 一个平稳的 OU 价差)。
import numpy as np
from scipy.cluster.hierarchy import linkage, dendrogram, fcluster
from scipy.spatial.distance import squareform
C = np.corrcoef(rets) # 30x30 相关矩阵
D = np.sqrt(0.5 * (1 - C)) # 转成距离
np.fill_diagonal(D, 0.0)
condensed = squareform(D, checks=False) # 压缩成上三角向量
Z = linkage(condensed, method="ward") # Ward 层次聚类pythonWard 连接法每次合并两个簇时,选”合并后簇内方差增量最小”的那对,倾向于产生大小均衡的紧凑簇——对板块结构这种块状数据特别合适。
三、聚类重排:让隐藏的板块结构浮出水面#
层次聚类最直观的价值,是它能把杂乱的相关矩阵重新排序,让本来看不见的块状结构一眼可见。

左图是原始相关矩阵,股票按名称顺序排列,看起来一片红蓝斑驳,毫无规律。右图是按聚类树的叶子顺序重排后的同一个矩阵——5 个红色方块沿对角线整齐排开,每个方块就是一个板块内部的高相关簇。数据没变,只是换了顺序,隐藏的结构就自己跳出来了。这就是聚类的第一层价值:先看清市场的分组结构,再决定在哪里找配对。
树状图则给出完整的合并层级:

从底部往上,每次合并两个最近的簇,高度就是合并时的 Ward 距离。红色虚线是切割线——在哪里横切一刀,就得到几个簇。切得低,簇多而碎;切得高,簇少而粗。这里切在 5 个簇的位置,聚类纯度达到 90.9%(每个簇里主导板块的占比),说明相关性距离确实抓住了真实的板块结构。
clusters = fcluster(Z, t=5, criterion="maxclust")python四、漏斗第一级:把 435 对压到 100 对#
聚类的核心用途来了:只在同一个簇内找配对。跨簇的股票分属不同板块,长期同涨同跌的经济学理由弱,价差往往是发散的(不回归),是配对交易的噪声来源。

绿色是簇内配对的相关性分布(均值 0.53),红色是跨簇配对(均值 0.35)。簇内明显整体右移——聚类确实把高相关的对集中到了一起。数量上:435 个总组合里,簇内候选只剩 100 对,一步筛掉 77%。剩下的 300 只股票场景里,这个比例会把 44850 对压到几千对,把后续协整检验的计算量和多重检验风险都大幅降低。
五、漏斗第二级:相关≠协整,还要看价差回不回归#
这是全文最关键的一节。聚类给你的是”高相关”,但高相关的两只股票,价差未必回归。 两只都跟着大盘涨的股票相关性可以很高,但价差可能一路发散——你按均值回归去交易,就是往刀口上撞。
所以簇内候选还要过第二道筛:协整性。实操上用一个简单代理——价差的半衰期。对每个簇内高相关对,先用对数价格回归求对冲比 β,得到价差,再对价差拟合 AR(1),算它回到均值一半所需的时间:
def half_life(spread):
lag = spread[:-1]
dlt = np.diff(spread)
b = np.polyfit(lag - lag.mean(), dlt, 1)[0]
if b >= 0:
return np.inf # b>=0 表示不回归,直接淘汰
return -np.log(2) / np.log(1 + b)
best = None
for c in np.unique(clusters):
idx = np.where(clusters == c)[0]
for a in range(len(idx)):
for b in range(a+1, len(idx)):
i, j = idx[a], idx[b]
if C[i, j] < 0.5: # 预筛:簇内高相关才进
continue
beta = np.polyfit(np.log(prices[j]), np.log(prices[i]), 1)[0]
if beta <= 0: # 对冲比必须为正
continue
spread = np.log(prices[i]) - beta * np.log(prices[j])
hl = half_life(spread)
if best is None or hl < best[3]:
best = (i, j, C[i, j], hl)python注意两个硬性淘汰条件:对冲比必须为正(负对冲比意味着两只反向,不是配对逻辑),AR(1) 系数必须让价差收敛(否则半衰期为无穷,不回归)。跑下来选出的最优对是”金融2 vs 金融5”:相关 0.64、对冲比 0.45、半衰期 15.7 天——半衰期短意味着价差偏离后回归得快,是理想的配对交易标的。
六、配对回测:z-score 越界开仓、回归平仓#
选定对之后,交易逻辑就是标准的均值回归:对价差算滚动 60 日 z-score,超过 ±2 个标准差开仓,回到 ±0.5 以内平仓。
W, ENTRY, EXIT = 60, 2.0, 0.5
z = np.full(T, np.nan)
for t in range(W, T):
win = spread[t-W:t]
z[t] = (spread[t] - win.mean()) / (win.std() + 1e-12)
pos, state = np.zeros(T), 0
for t in range(W, T):
if state == 0:
if z[t] > ENTRY: state = -1 # 价差过高,做空价差
elif z[t] < -ENTRY: state = 1 # 价差过低,做多价差
elif abs(z[t]) < EXIT: state = 0 # 回归,平仓
pos[t] = statepython
上图是价差本身(围绕均值上下波动,不发散),下图是 z-score 与开平仓阈值。z 每次冲出红色虚线(±2)就建仓,回到绿色点线(±0.5)就了结——赌的就是价差每次偏离后都会被拉回来。

累计收益曲线(价差口径)3 年爬升 20.5%,紫色阴影是持仓段。15 次开仓、日胜率 52.1%、年化 Sharpe 0.58——不惊艳,但这才是配对交易该有的样子:低相关于大盘、靠价差回归的稳定小钱,而不是暴利。
A. 实现细节#
- 距离度量:用 Mantegna 相关性距离 √(0.5(1-ρ)) 而非直接用 1-ρ,保证满足距离公理(三角不等式),层次聚类才有几何意义。
- 聚类方法:Ward 连接(最小化簇内方差增量),对块状板块结构友好,产生大小均衡的簇。切割用
fcluster(t=5, criterion="maxclust")固定 5 簇。 - 对冲比:用对数价格 OLS 回归斜率作为静态对冲比 β,价差 = log(P_i) - β·log(P_j)。全程静态,不做滚动更新。
- 信号字段:只用价差的滚动 60 日均值与标准差算 z-score,warmup 前 60 天不产生信号(
z保持 NaN)。 - 执行时点:次日执行。
t日的 z 决定t日收盘的持仓pos[t],收益用pos[t-1] * dspread[t]计算,绝不用当天信号赚当天价差。 - 收益口径:价差口径(对数价差的累计变化 × 持仓方向),不含融券成本、手续费、保证金占用,用于隔离”配对挑选”这一个变量。
B. 已知偏差#
- 相关≠协整是根本局限:聚类基于相关性,但相关性高的对价差未必平稳。本文靠”对冲比为正 + AR(1) 收敛 + 半衰期排序”做了二次筛,但半衰期只是协整的粗代理,不能替代正式的 Engle-Granger 或 Johansen 检验。跳过第二级筛选、直接拿簇内最高相关对去交易,是新手最常见的亏钱方式。
- 聚类结构随窗口漂移:相关矩阵是用整段 750 天算的,本身就用到了”未来”。真实交易里相关性会随市场状态变化——牛市里板块齐涨、相关性普遍抬升,聚类结构会和熊市完全不同。用固定窗口聚类、然后长期不更新,配对关系会慢慢失效。
- 切割高度主观:切成几个簇没有客观标准,切得粗则簇内异质、切得细则候选太少。本文直接指定 5 簇是因为已知真实板块数,实盘里这是个需要反复调的超参。
- 样本内挑对必然乐观:选出的对是在同一段数据里”挑出来”又”回测”的,Sharpe 0.58 是样本内数字。真实的样本外表现一定更差,必须用滚动窗口做 walk-forward:在 T1 窗口聚类选对,在 T2 窗口交易,反复滚动才能得到诚实的估计。
C. 结果解读#
聚类的贡献是”缩小搜索空间”,不是”提高胜率”。 它把 435 对压到 100 对、筛掉 77%,最大价值在于把后续协整检验的计算量和多重检验风险都砍下来——你只在结构上有理由相关的板块内部找配对,而不是在全市场瞎试。这一步本身不产生 alpha,但它是防止”数据挖掘出伪协整对”的第一道防线:试的对越少,蒙到假阳性的概率越低。
决定盈亏的是价差平稳性,不是相关性高低。 从两级漏斗能看出,最终选中的”金融2 vs 金融5”相关性只有 0.64,并不是簇内相关性最高的那对,但它的价差半衰期只有 15.7 天——回归快。如果只按相关性排序、拿最高的那对去交易,很可能选到一对”齐涨齐跌但价差发散”的股票,相关性再高也是亏。这就是为什么第二级的协整筛选比第一级的聚类更接近钱:聚类告诉你去哪里找,协整告诉你哪个能交易。
这套框架的实盘价值在流程,不在单次回测数字。 Sharpe 0.58、20.5% 三年收益是样本内、无成本的理想数字,真实部署后会被融券成本、手续费、相关性漂移、样本外衰减层层打折。它真正可复用的是这个漏斗结构:聚类缩范围 → 相关性预筛 → 协整二次筛 → 半衰期排序 → z-score 交易。把这个流程套上滚动窗口 walk-forward、加上真实的融券成本建模、再对选出的对做正式协整检验,才是能进实盘讨论的配对交易系统。记住那句话——聚类只负责缩小范围,让价差回归的是协整,不是相关。