第 4 章 · 01 Pair Trading:协整与"不存在无风险统计套利" 本节摘要:本节精读 Pair Trading——统计套利的入门经典。它的逻辑很性感:找两只"被看不见的狗绳拴在一起"的资产,当价差偏离均值时,做多便宜的那只、做空贵的那只,等狗跑回醉汉身边。我们重点讲清楚 里的 Engle-Granger 两步法(step1 OLS 残差 + ADF 平稳性检验,step2 误差修正模型要求调整系数为负),以及 如何用 250 天滚动窗口复检协整、用 ±1σ 双尾阈值开仓、用"协整破裂即清仓"保命。但本章真正的爆点是那个反直觉结论——"不存在无风险的统计套利":NVDA 与 AMD 在 2013-2014 年协整得好好的,比特币挖矿与机器学习热潮一来,关系就这么断了。
本节摘要:本节精读 Pair Trading——统计套利的入门经典。它的逻辑很性感:找两只"被看不见的狗绳拴在一起"的资产,当价差偏离均值时,做多便宜的那只、做空贵的那只,等狗跑回醉汉身边。我们重点讲清楚
EG_method里的 Engle-Granger 两步法(step1 OLS 残差 + ADF 平稳性检验,step2 误差修正模型要求调整系数为负),以及signal_generation如何用 250 天滚动窗口复检协整、用 ±1σ 双尾阈值开仓、用"协整破裂即清仓"保命。但本章真正的爆点是那个反直觉结论——"不存在无风险的统计套利":NVDA 与 AMD 在 2013-2014 年协整得好好的,比特币挖矿与机器学习热潮一来,关系就这么断了。这一节会彻底改变你对"统计关系永久性"的看法。
涉及源码:原项目
Pair trading backtest.py(约 349 行),数据 yfinance 拉取 NVDA/AMD 2013-01-01 至 2014-12-31 日线。
⚠️ 注意:本节策略的核心风控不是阈值,而是"每次下单前复查协整"。务必先消化 Engle-Granger 两步法的逻辑再看
signal_generation,否则会把prev_status and not coint_status那段清仓代码看成无意义的重置。
阅读完本节,你应当能够:
signal_generation 里 4 个分支:复查协整 / 破裂清仓 / 起步设阈 / 越界开仓。配对交易又叫均值回归(mean reversion)交易。我们找两只"协整"的资产——通常是一只股票和一个 ETF 指数,或同行业的两只股票,或任何能通过协整检验的配对。理论上这两者不能漂得太远。作者用了一个传神的比喻:
"its like a drunk man with a dog, the invisible dog leash would keep both assets in check"(就像一个醉汉牵着一条狗,看不见的狗绳把两者拴在一起)。
当一只股票变得过于看涨,我们就做空看涨的、做多看跌的;反之亦然。迟早那条狗会跑回醉汉身边。这就是 README 里那句核心比喻——协整像一对痴缠的情侣被 502 胶水黏在一起。
关键区别:协整不是相关。相关只看两者是否同涨同跌,协整看的是两者的线性组合是否平稳。两只股票可以各自乱涨(都不平稳),但它们的某个组合(比如 Y - βX)却可能围绕一个固定均值震荡——这种"差距会被拉回"的关系才是配对交易能赚钱的根基。
协整怎么检验?作者选了 Engle-Granger 两步法,理由很坦诚——"这方法朴素、好实现,而且它是我导师的导师发明的!!!"作者在代码顶部致敬:
#grazie a my mentor Prof Giampiero M Gallo #and his mentor Robert Engle, the nobel laureate! #for their tremendous contributions to VECM
导师是 Giampiero Gallo(前统计学教授,现任意大利政府官员),而 Gallo 的导师正是 Robert Engle——2003 年诺贝尔经济学奖得主、VECM(向量误差修正模型)的发明人。两步法的步骤:
Step 1 —— 估长期均衡,验残差平稳:
对 Y 回归 X(加常数项),拿到残差 ε:
Y = α + β·X + ε
对这个残差 ε 做 ADF 检验(增广迪基-富勒检验)。原假设是"ε 有单位根(非平稳)"。若 p 值 > 0.05,说明残差非平稳,协整不成立,直接返回 False。只有 ε 平稳,才说明 Y 与 βX 之间有一条"看不见的狗绳"。
Step 2 —— 估误差修正模型,看调整系数符号:
对 Y 的一阶差分 ΔY 回归 ΔX 与 lagged 残差 ε.shift(1):
ΔY = γ + δ·ΔX + λ·ε_{t-1}
调整系数 λ 必须为负。直觉:ε_{t-1} 是上一期的偏离,λ 为负意味着"上一期偏得越远,这一期往回拉得越狠"——这正是均值回归的数学表达。若 λ > 0,说明偏离会被放大,协整破裂,返回 False。
两步法的局限:只能检验一阶协整(一个协整向量)。作者注释里推荐更通用的 Johansen 检验(sm.tsa.var.vecm.coint_johansen),它可以同时检验多个协整关系。但两步法胜在直观、好讲、好实现,而且——有导师血脉加持。
一旦协整成立,我们用 step1 的模型预测 fitted 值,算出残差:
residual = asset2 - fitted
再把这个残差标准化成 z-score:
z = (residual - mean(model.resid)) / std(model.resid)
理论上 z 是一个服从 N(0,1) 的白噪声。作者用 ±1σ 双尾阈值:z 破上限(说明 asset2 相对 asset1 偏贵)就做多 asset1、做空 asset2;破下限则反向。为什么不用 2σ?作者解释:"conventionally one sigma is the threshold, two sigma reaches 95% which is relatively difficult to trigger"——2σ 虽然更"安全",但触发概率太低,信号太少;1σ 是频率与质量的折中。
Pair trading backtest.py:64-96 的 EG_method(X, Y) 是整个策略的"守门人"。它返回一个元组 (bool, model)——bool 表示协整是否成立,model 是 step1 的 OLS 模型(后续算 fitted 用)。
两道关卡,任何一道不过就返回 False:
| 关卡 | 检验 | 不通过条件 | 含义 |
|---|---|---|---|
| Step 1 | 残差 ε 的 ADF 检验 | adfuller(epsilon)[1] > 0.05 |
残差非平稳,无狗绳 |
| Step 2 | 调整系数 λ 的符号 | list(model2.params)[-1] > 0 |
偏离被放大而非收敛 |
注意 step2 取的是 list(model2.params)[-1]——即最后一个参数,对应 ε.shift(1) 那一项(就是调整系数 λ)。这里用 list(...)[-1] 而不是按名字取,是因为构造 X_dif 时把 X.diff() 和 epsilon.shift(1) 拼在一起,列名是默认的 0/1,直接取最后一个最稳。
Pair trading backtest.py:108-183 的 signal_generation 是策略主体。它的核心循环从 i = bandwidth(默认 250,约一年)开始,对每一天 i,都用过去 250 天的数据重新跑一次 EG_method。
coint_status, model = method( signals['asset1'].iloc[i-bandwidth:i], signals['asset2'].iloc[i-bandwidth:i])
这个滚动复查是本策略的灵魂——它不是一次性算协整然后赌它永远成立,而是每天都问一遍"今天它还成立吗?"。prev_status 记录上一天的状态,用于检测"协整是否刚刚破裂"。
循环里有 4 个分支(顺序很关键):
分支① 协整破裂,立即清仓(L135-143):
if prev_status and not coint_status: if signals.at[signals.index[i-1],'signals1'] != 0: signals.at[signals.index[i],'signals1'] = 0 signals.at[signals.index[i],'signals2'] = 0 signals['z'].iloc[i:] = np.nan # ... 后续列全部置 NaN
"昨天还协整,今天不协整了"——这是最危险的时刻。只要昨天有持仓,今天就强制清仓,并把 z、fitted、residual 全部置 NaN,防止后续误触发。这是"不存在无风险统计套利"在代码里的具象化。
分支② 协整起步,设阈值(L149-163):
if not prev_status and coint_status: signals['fitted'].iloc[i:] = model.predict(...) signals['residual'].iloc[i:] = signals['asset2'].iloc[i:] - signals['fitted'].iloc[i:] signals['z'].iloc[i:] = (signals['residual'].iloc[i:] - np.mean(model.resid)) / np.std(model.resid) signals['z upper limit'].iloc[i:] = signals['z'].iloc[i] + np.std(model.resid) signals['z lower limit'].iloc[i:] = signals['z'].iloc[i] - np.std(model.resid)
"昨天不协整,今天协整了"——用当前 model 预测未来所有 fitted,算出 residual 与 z,并以 z 为中心 ±1σ 设上下限。注意这里有个作者自陈的细节:fitted/residual/z 一旦设好就用 .iloc[i:] 一次性铺到序列末尾,这不是 look-ahead bias(前视偏差),只是为了减少 pandas 重复计算——因为每天的 model 可能变,但只有在协整"起步"那一刻才重算一次,中间日子复用这个 model。
分支③ z 破上限,做多 asset1 做空 asset2(L167-168):
if coint_status and signals['z'].iloc[i] > signals['z upper limit'].iloc[i]: signals.at[signals.index[i], 'signals1'] = 1
z 破上限说明 asset2 相对 asset1 偏贵(asset2 涨过头了),所以做多便宜的 asset1,做空贵的 asset2。
分支④ z 破下限,做空 asset1 做多 asset2(L169-170):
if coint_status and signals['z'].iloc[i] < signals['z lower limit'].iloc[i]: signals.at[signals.index[i], 'signals1'] = -1
对称地,破下限说明 asset1 偏贵,做空 asset1 做多 asset2。
由于 z 不可能同时破上下限,这两个 if 是互斥的。最后用 signals2 = -signals1 生成 asset2 的反向信号,positions = signals.diff() 把"持仓"转成"执行动作"。
把 4 个分支压缩成一句话:永远做多便宜的那只,做空贵的那只,但前提是协整还在;协整一断,立刻全平。

上图是两只资产的价格曲线(NVDA 蓝线、AMD 棕线,双 y 轴),可以看到 2013-2014 年间它们大体上同涨同跌,这正是"协整良好"的视觉表现。

中间这张图展示了 Engle-Granger 两步法在某个滚动窗口里的拟合结果——step1 的 OLS 回归线与残差,残差的均值回归特性肉眼可见。

最后这张是组合总资产与 z 统计的双轴图,橙色带是 ±1σ 阈值区间,z 在区间内时不动,冲出区间时开仓。
🎯 反直觉发现(本章高潮):"不存在无风险的统计套利"。作者在代码注释里写了一段几乎是整个仓库最重要的告白:
"i am talking about nvidia and amd, two gpu companies. after bitcoin mining boom and machine learning hype, stock price of nvidia went skyrocketing, on the contrary amd didnt change much. the cointegrated relationship just broke up. so be extremely cautious with cointegration, there is no such thing as riskless statistical arbitrage, always check the cointegration status before trading execution."
这正是为什么
signal_generation要每天滚动复检、为什么分支①"破裂即清仓"要写在最前面。NVDA/AMD 在 2013-2014 年协整良好,回测漂亮——但作者警告你,这只是一段被精心挑选的历史。真实市场里,"大多数关系迟早会断,只有极少数能走到婚姻"。所以:永远在执行前复查协整。这是本章、甚至全书最重要的风控心法。
list(model2.params)[-1] 取调整系数:列名是默认数字,按位置取最后一个(即 ε.shift(1) 项)最稳,避免列名拼接出错。prev_status and not coint_status 是生命线:检测"刚刚破裂"的那一刻,立即清仓 + 置 NaN,杜绝后续误触发。.iloc[i:] 批量赋值不是前视偏差:作者特别注释"this is no forward bias, just to minimize the calculation done in pandas"——只在协整起步那一刻算一次 model,中间日子复用,是性能优化而非作弊。signals2 = -signals1:只需为 asset1 生成信号,asset2 自动取反,保证两边永远对冲。prev_status and not coint_status 是全策略最关键的一行。下一节,我们换一个完全不同的形态识别思路——Bollinger Bands W 底。同样是"找形态",但它不需要协整检验,只需 75 日窗口里找 5 个节点,用 alpha=0.0001 的容差判定"贴近"。作者还甩出一句宣言:"为什么用机器学习,当算术更快更简单?"