上一篇我们认识了平稳性和 ADF 检验,还留了一个悬念:价格序列本身几乎都不平稳,那均值回归类策略到底在「回归」什么?这一篇就来揭晓一个漂亮的答案——单只资产的价格不平稳,但两只走势高度绑定的资产,它们价格的差(价差)可以是平稳的。这种关系叫协整(Cointegration),它是最经典的策略之一「配对交易」(Pairs Trading)的地基。
先泼一盆冷水:很多人用「相关系数高」来挑配对,这是错的。相关衡量的是每日涨跌是否同向,协整衡量的是长期水平是否绑在一起。两只相关 0.95 的 ETF,价格可以一个越走越高、一个越走越低——每天同涨同跌,距离却越拉越远,这种价差你越抄底越深。协整才是「偏离会回来」的统计学保证。
本篇我们完整走一遍配对交易流程:检验协整、估计对冲比率、用 z-score 定入出场规则、跑一个简化回测,最后专门讲协整关系漂移这个「策略杀手」。
本篇目标
- 能用白话向别人区分「相关」与「协整」,并说出为什么挑配对不能只看相关
- 能用 statsmodels 的
coint检验两只资产是否存在协整关系,并能手工复现 Engle-Granger 两步法 - 能估计对冲比率、构造价差,并用滚动 z-score 写出「±2 进场、±0.5 出场」的完整信号逻辑
- 能跑通一个带手续费的配对交易简化回测,读懂它的净值与回撤
- 能说出协整关系漂移的三种迹象,以及为什么配对交易必须设「止损出场」
核心概念
相关不等于协整:白话版
相关(Correlation):每天你动我也动,说的是步调。协整(Cointegration):我俩各自的轨迹都会漂,但之间的距离始终被拉回某个范围,说的是缰绳。
图形直觉:想象两条被一根橡皮筋连着的小狗各自遛弯——它们可以一个往东一个往西走很远,但皮筋总会把它们拽回来,距离不会无限扩大,这是协整;而两只都拴着长度不同的绳子跟主人散步,步调可能一致(相关高),但绳子一长一短,距离会稳定地越拉越远,这是相关而不协整。配对交易赚的是「皮筋收缩」的钱:价差拉大时做多便宜的一腿、做空贵的一腿,等价差回落平仓。如果皮筋断了(协整消失),这套逻辑就变成持续亏损的抄底。
协整检验:Engle-Granger 两步法
statsmodels 提供的 coint 函数内部做的是 Engle-Granger 两步法,值得手工过一遍:
- 第一步:用 OLS 回归
log(价格A) = α + β·log(价格B) + 残差,估计出对冲比率β——它告诉你 1 份 A 要配多少份 B 才能对冲掉共同漂移。 - 第二步:对残差(也就是价差)做上一篇学的 ADF 检验。原假设是「残差不平稳」;p < 0.05 说明价差平稳,两资产存在协整关系。
coint(la, lb) 一行就能返回这个 p 值,但手工走一遍的价值在于:第一步的 β 正是实盘下单需要的对冲比率,检验和交易用的是同一个数。
价差与 z-score:把「偏离多少」标准化
有了价差 spread = log(A) − β·log(B),交易问题变成「价差偏离它的均值多少算极端」。z-score(标准分)给出统一刻度:
z = (spread − 均值) / 标准差
白话:z = 0 表示价差正好处在平均水平,z = +2 表示比平均高出了 2 个标准差。经典的入场规则:z < −2 做多价差(买 A 卖 B,赌它涨回 0),z > +2 做空价差(卖 A 买 B);|z| < 0.5 说明已经回归,平仓离场。均值和标准差用滚动窗口估计(比如 120 日),千万不能用全样本——那是拿未来的答案算今天的偏离。
样本局限:本篇示例的诚实声明
代码里首选两只真实 ETF(510050 上证50ETF 与 510300 沪深300ETF,成分高度重叠,天然适合演示),akshare 不可用时降级为造出来的「保证协整」数据。两点必须说清:
- 真实数据未必通过协整检验——相关很高但 coint 的 p 值大于 0.05 完全正常,这本身就是最生动的一课:不是长得像就能配对。
- 示例把样本切成训练段(估 β、做检验)和测试段(跑交易),这比「全样本一锅炖」诚实,但仍是最简教学版:真实配对交易还要考虑融券做空的券源与成本、保证金占用、以及下面要讲的漂移风险。
协整漂移:皮筋是会断的
协整关系不是终身制的。公司基本面变化、指数编制调整、政策冲击,都可能让那条「橡皮筋」悄悄断掉。三种危险迹象:价差的滚动均值持续单边漂移、z-score 长期趴在 2 以上不回来、对冲比率需要越调越大才能压平价差。对应的风控只有一条:z-score 进场后,再设一条硬止损(比如 |z| > 4 强制平仓)——承认这次皮筋可能真断了,亏小钱离场,而不是「越跌越买」死扛到协整复活。
划重点:配对交易的对手盘不是市场,是「关系是否还活着」。进场前检验协整,持仓中盯住漂移,出场规则里永远包含一条「认错线」。
完整代码
依赖安装:uv pip install numpy pandas matplotlib statsmodels akshare。代码分四块:取数与降级、协整检验、信号与回测、可视化。
# —— 第一块:取两只真实 ETF,接口不可用则降级造协整数据 ——
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import akshare as ak
rng = np.random.default_rng(11)
def get_etf_close(symbol: str) -> pd.Series | None:
"""拉一只 ETF 的日线收盘价,失败返回 None"""
try:
df = ak.fund_etf_hist_em(symbol=symbol, period="daily", adjust="qfq")
s = pd.to_datetime(df["日期"])
return pd.Series(df["收盘"].values, index=s, name=symbol).loc["2020-01-01":"2024-12-31"]
except Exception:
return None
pa = get_etf_close("510050") # 上证50ETF
pb = get_etf_close("510300") # 沪深300ETF
if pa is None or pb is None: # 降级方案:造一对真正协整的「ETF」
n = 1218
idx = pd.bdate_range("2020-01-01", periods=n)
lb = pd.Series(8.0 + np.cumsum(rng.normal(0, 0.012, n)), index=idx) # 随机游走
eps = np.zeros(n)
for t in range(1, n): # 平稳噪声:绕 0 往返(AR(1))
eps[t] = 0.90 * eps[t - 1] + rng.normal(0, 0.02)
la = pd.Series(0.80 * lb.values + 1.2 + eps, index=idx)
pa, pb = np.exp(la), np.exp(lb)
print("akshare 不可用,使用造数据(保证协整)")
else:
pa, pb = pa.align(pb, join="inner") # 按共同交易日对齐
print(f"akshare 数据:各 {len(pa)} 个交易日")
# —— 第二块:训练段上做检验与对冲比率估计 ——
from statsmodels.tsa.stattools import coint, adfuller
import statsmodels.api as sm
la, lb = np.log(pa), np.log(pb)
split = int(len(la) * 0.7) # 前 70% 估计参数,后 30% 模拟交易
la_tr, lb_tr = la.iloc[:split], lb.iloc[:split]
corr = pa.pct_change().corr(pb.pct_change())
print(f"全样本日收益率相关系数:{corr:.3f}")
X = sm.add_constant(lb_tr.values) # Engle-Granger 第一步:OLS 估 β
ols = sm.OLS(la_tr.values, X).fit()
beta = float(ols.params[1])
print(f"对冲比率 beta ≈ {beta:.3f}")
spread_tr = la_tr - beta * lb_tr # 第二步:对残差做 ADF
_, p_adf, *_ = adfuller(spread_tr)
print(f"训练段价差 ADF:p值={p_adf:.4f} → {'平稳' if p_adf < 0.05 else '不平稳'}")
_, p_coint, _ = coint(la_tr, lb_tr) # 一行等价于上面两步
print(f"训练段 coint 检验:p值={p_coint:.4f} → {'存在协整' if p_coint < 0.05 else '不存在协整'}")
# —— 第三块:测试段上的 z-score 信号与简化回测 ——
window = 120
spread_all = la - beta * lb # 用训练段的 β 套全样本价差
z = ((spread_all - spread_all.rolling(window).mean())
/ spread_all.rolling(window).std()).dropna()
z = z[z.index >= la.index[split]] # 只在测试段交易,避免用未来数据
pos = np.zeros(len(z)) # 仓位:+1 多价差 / -1 空价差 / 0 空仓
for i, zv in enumerate(z.values):
if zv < -2:
pos[i] = 1 # 价差过低:买 A 卖 B
elif zv > 2:
pos[i] = -1 # 价差过高:卖 A 买 B
elif abs(zv) > 4: # 硬止损:皮筋可能断了,认错离场
pos[i] = 0
elif abs(zv) < 0.5:
pos[i] = 0 # 回归到位,止盈平仓
else:
pos[i] = pos[i - 1] if i > 0 else 0 # 其余时间维持原仓位
pos = pd.Series(pos, index=z.index)
ra = pa.pct_change().reindex(z.index).fillna(0) # 两腿的日收益
rb = pb.pct_change().reindex(z.index).fillna(0)
spread_ret = (ra - beta * rb) / (1 + beta) # 归一化到总名义 1 的价差日收益
cost = pos.diff().abs().fillna(0) * 0.001 # 每次换手按双边 0.1% 计费
strat_ret = pos.shift(1).fillna(0) * spread_ret - cost
nav = (1 + strat_ret).cumprod()
turns = int((pos.diff().abs() > 0).sum())
ann = nav.iloc[-1] ** (244 / len(strat_ret)) - 1
sharpe = (strat_ret.mean() * 244 - 0.02) / (strat_ret.std() * np.sqrt(244))
mdd = (nav / nav.cummax() - 1).min()
print(f"测试段换手次数:{turns},年化:{ann:.3f},夏普:{sharpe:.2f},最大回撤:{mdd:.2%}")
# —— 第四块:价差与 z-score 可视化 ——
fig, axes = plt.subplots(2, 1, figsize=(10, 6), sharex=True)
axes[0].plot(spread_all.index, spread_all.values, lw=0.8, color="gray")
axes[0].axvline(la.index[split], color="black", ls="--", lw=1, label="训练/测试分界")
axes[0].set_title("价差(对数):训练段估计,测试段交易")
axes[0].legend()
axes[1].plot(z.index, z.values, lw=0.8, color="steelblue")
axes[1].axhline(2, color="red", ls="--", lw=1, label="做空线 +2")
axes[1].axhline(-2, color="green", ls="--", lw=1, label="做多线 -2")
axes[1].axhline(4, color="darkred", ls=":", lw=1, label="止损线 +4")
axes[1].axhline(-0.5, color="gray", ls=":", lw=1, label="平仓区")
axes[1].axhline(0.5, color="gray", ls=":", lw=1)
axes[1].set_title("z-score:穿 ±2 进场,回 ±0.5 出场")
axes[1].legend(loc="upper left", fontsize=8, ncol=2)
plt.tight_layout()
plt.show()
代码逐段解释
-
第一块取数:
get_etf_close把「拉数 + 清洗 + 截取区间」封装成函数,任何一腿失败就走降级分支。造数据的关键设计在la = 0.80 × lb + 1.2 + eps:A 的对数价格被一条固定关系拴在 B 上,eps是绕 0 往返的平稳噪声——这正是「协整」在数据生成层面的长相。真实数据路径下两腿还要align对齐交易日。 示例输出(示意,造数据路径):akshare 不可用,使用造数据(保证协整)。 -
第二块检验:相关系数先打印出来给你对照——它接近 0.98,非常高;但决定生死的不是它,而是后面的 coint p 值。OLS 那两行是 Engle-Granger 第一步,
beta就是实盘每买 1 份 A 要做空beta份 B 的对冲比率;第二步对训练段价差做 ADF,coint的结果应当与「OLS + ADF」方向一致。 示例输出(示意,造数据路径):
全样本日收益率相关系数:0.977
对冲比率 beta ≈ 0.802
训练段价差 ADF:p值=0.0003 → 平稳
训练段 coint 检验:p值=0.0012 → 存在协整
走真实 ETF 路径时,相关系数大概率同样很高,但两个 p 值可能大于 0.05——这时正确结论是「这对不能做」,而不是回去换窗口重测到显著为止(那是上一篇刚讲过的 p-hacking)。
- 第三块信号与回测:z-score 用 120 日滚动均值和标准差,只依赖历史;仓位循环是状态机——极端进场、回归出场、|z|>4 认错、中间地带维持原仓位。价差日收益把两腿名义资金归一化到 1,换手时按双边 0.1% 扣费。注意
pos.shift(1):今天算出的信号,明天才开始赚钱,坚决不用未来数据。 示例输出(示意,造数据路径):测试段换手次数:38,年化:0.052,夏普:0.91,最大回撤:-3.4%。数字随参数与样本明显变化,真实数据路径完全可能不赚钱——这很正常,教学示例本来就不保证盈利。
划重点:把第三块里的
window改成 60 或 250 再跑,结果会明显不同。配对交易对参数的敏感度不比均线策略低,「检验通过」只是入场券,不是利润保证书。
- 第四块可视化:上图价差线上的黑色虚线是训练/测试分界——注意价差在测试段是否还在绕同一个水平震荡;下图 z-score 的四条参考线就是完整交易规则。如果测试段价差整体抬升或下沉(图中会表现为灰线「爬台阶」),那就是协整漂移的直观证据,此时 z-score 会频繁触顶,止损线会经常被激活。
常见坑与小练习
常见坑
- 用相关系数挑配对。现象:两只相关 0.95 的 ETF 做配对,价差越「抄」越深。原因:相关只保证步调同向,不保证距离收敛,协整检验才是缰绳的证明。避免:先
coint后交易,p 值不过关直接换对子。 - 全样本估计 β 和 z-score 的均值方差。现象:回测曲线漂亮得像艺术品。原因:那是用未来的统计量交易过去的行情,未来函数。避免:像本篇一样训练/测试分段,z 用滚动窗口。
- 忽略做空的现实约束。现象:回测里「卖 A 买 B」轻松执行。原因:A 股融券有券源、成本与保证金约束,很多对子根本空不进去。避免:设计策略前先确认两腿都可交易,或只用「做多一腿 + 降低仓位」的近似实现,并如实承认它不是完整配对。
- 检验通过就终身持有这对组合。现象:策略做了一年突然持续亏损,检查发现价差早已台阶式漂移。原因:协整关系会失效。避免:每月对滚动窗口重新做一次 coint,加上 |z|>4 的硬止损,关系死了就换对子。
- 批量筛对子只报告显著的。现象:从 500 只股票里筛出 20 个「协整对」,回测全赚钱。原因:这正是上一篇 p-hacking 的翻版——测得越多假阳性越多。避免:记录筛选总数,用样本外(本篇的测试段思路)验证每一对。
小练习
- 把造数据里的
eps噪声系数从 0.02 加大到 0.06,重跑全流程,观察换手次数、年化与最大回撤的变化。提示:噪声越大价差越颠,交易更频繁、止损更容易被触发,把示意输出里的数字变化逐项记下来对比。
- 在第三块之后加一段「滚动协整监控」:每 60 个交易日对最近 250 日数据重算一次 coint p 值并打印,观察测试段内 p 值是否始终小于 0.05。
提示:把循环范围设为
range(split, len(la), 60),切片用la.iloc[t-250:t]。 - 把入场阈值 ±2 改成 ±1.5 与 ±2.5 各跑一遍,对比「交易更频繁」与「偏离更极端才进场」两种风格的年化、回撤与换手次数。
提示:±1.5 换手多、手续费吃得多但抓得早;±2.5 交易少、单次空间大但可能错过回归,没有免费午餐。
风险提示:本文策略仅用于教学,不构成投资建议,实盘前请充分测试。