第 08 章 时间序列与统计套利策略
前几章搭好了研究、组合、回测和执行的骨架,本章把它们用在几类最经典的时间序列策略上:时间序列动量与均值回复、波动率择时、配对交易和统计套利组合。理论基础分散在前面各册:ARMA 与单位根见第 06 册第 02a、02b 章,GARCH 见第 06 册第 03a、03b 章,协整与配对交易见第 06 册第 08b 章,Kalman 滤波与时变 β 见第 06 册第 11a、11b 章,多重检验见第 03 册第 10b 章。本章不重复推导,重点是每类策略的收益从哪里来、怎样检验它是否存在、实现时最容易犯什么错。
学习目标
读完本章,你应当能够:
- 用方差比检验区分短期反转和长期趋势,实现波动率缩放的时间序列动量(TSMOM),并理解跨资产汇总为什么能发现单个资产上检验不出来的效应。
- 用 EWMA 和 GJR-GARCH 的事前波动预测构造目标波动策略,解释它在什么条件下提高 Sharpe,并评估其杠杆和换手代价。
- 在大规模配对筛选中控制多重检验,识别"单腿看似平稳"造成的伪配对。
- 用 AR(1) 拟合 OU 过程、估计半衰期,设计阈值交易规则;用 Kalman 滤波跟踪时变对冲比,并理解静态对冲比失效时会发生什么。
- 实现一个 PCA 残差 + s-score 的统计套利组合,分析其成本敏感度和收益来源。
读前导读
这一章在解决什么问题。 本章把前几章搭好的研究、回测和执行框架,用在四类你在买方机构多半见过的策略上,每一类都能对应到一个熟悉的业务:时间序列动量对应 CTA/管理期货基金的趋势跟踪;波动率择时对应资产配置里的"风险预算"和风险平价产品的目标波动机制;配对交易对应相对价值交易台做的 AH 股溢价、期现基差、新旧券利差;PCA 统计套利对应市场中性量化基金。对每一类,本章都问同样三个问题:收益从哪里来(价格可预测,还是风险可预测);怎样用统计检验确认它存在,而不是被噪声骗;落到实盘时成本、对冲比漂移、伪发现会怎样把它吃掉。
你在 CFA 里学过的简单回归、\(t\) 检验、相关系数、多因子模型和 Sharpe 比率是本章的基础;你可能没系统学过的是时间序列模型(AR(1)、GARCH、协整、Kalman 滤波)和主成分分析。本章不推导这些模型本身(推导在第 06 册),而是讲怎样用它们。读的时候,可以把每一个模型都当作"一种估计某个随时间变化的量的方法":GARCH 估计今天的波动,Kalman 估计今天的对冲比,AR(1) 估计价差回到均值有多快。
需要先想起来的数学。
- AR(1) 与半衰期。 \(X_{t+1}=a+bX_t+\zeta_t\),\(0<b<1\) 时序列围绕均值 \(m=a/(1-b)\) 波动。偏离每期缩小为原来的 \(b\) 倍,缩到一半需要 \(\ln0.5/\ln b\) 期。例:\(b=0.9\) 时 \(0.9^{6.6}\approx0.5\),半衰期 6.6 天。\(b=1\) 就是随机游走,永不回复。
- 和的方差与自相关。 \(\operatorname{Var}(r_1+r_2)=\operatorname{Var}(r_1)+\operatorname{Var}(r_2)+2\operatorname{Cov}(r_1,r_2)\)。收益正自相关时,多期收益的方差大于单期方差的倍数;负自相关时小于。方差比检验只用到这一条。参见 第 00 册第 07 章 概率中的分析工具。
- Jensen 不等式。 对凸函数 \(g\),\(\mathbb E[g(X)]\ge g(\mathbb E[X])\)(\(\mathbb E\) 表示期望)。例:\(g(x)=1/x\) 是凸的,若 \(\sigma\) 一半时间是 10%、一半是 30%,则 \(\mathbb E[1/\sigma]=6.67\),而 \(1/\mathbb E[\sigma]=5\)。这是波动择时能提高 Sharpe 的数学根源。
- 特征值与特征向量、主成分分析。 对相关矩阵 \(C\),若 \(Cv=\lambda v\),\(v\) 是一个"方向"(一组股票权重),\(\lambda\) 是沿这个方向的方差。最大特征值对应的方向通常就是"市场因子"。PCA 就是按方差从大到小找出这些正交方向。参见 第 00 册第 06 章 线性代数速成。
- 多重检验。 做 \(m\) 次 5% 水平的检验,纯噪声下预期有 \(0.05m\) 次"显著"。Bonferroni 把每次的门槛降到 \(0.05/m\);BH 方法控制的是"选出来的结果里假发现的比例",更宽松。
怎么读这一章。 8.1 节的两句话(价格可预测、风险可预测)是全章的骨架,先记住。8.2 节动量与 8.3 节波动择时可以按任意顺序读,代码后的逐项解读比代码本身重要。8.4 节配对交易是本章最长、也最实用的部分:8.4.2 节的多重检验陷阱必读;8.4.3 节 OU 与半衰期必读;8.4.4 节 Kalman 滤波第一次可以只看结论(对冲比会漂移,要用自适应方法跟踪),配合讲解框理解滤波公式。8.5 节统计套利需要 PCA 背景,线性代数生疏的读者可以先读 8.5.1 的流程和代码后的三条解读,跳过代码细节。
8.1 问题与动机
时间序列策略的收益来源只有两种:价格变化的可预测性(自相关、协整关系带来的均值回复、慢变漂移带来的趋势),以及风险的可预测性(波动聚集让我们能在风险低时多承担、风险高时少承担)。前者对应第 06 册第 02a 章的条件均值建模,后者对应第 03a 章的条件方差建模。
这两种可预测性在真实市场里都很弱,而且会变化。所以本章的每一节都有同样的结构:先用统计检验确认效应存在,再设计交易规则,然后用样本外和成本检验它能不能变成收益。合成数据在这里的局限尤其要说明:我们人为地放进了趋势、反转、协整和均值回复,所以策略在模拟中有效是设计使然,它只能说明方法在效应存在时能否把它找出来、在效应不存在时会不会被噪声骗。真实市场里这些效应是否存在、有多大,必须用真实数据和本册第 06 章的方法检验。
8.2 时间序列动量与均值回复
8.2.1 方差比检验
若收益序列不相关,\(q\) 期收益的方差是单期方差的 \(q\) 倍。Lo 和 MacKinlay(1988)的方差比
在随机游走下等于 1;\(VR(q)>1\) 说明 \(q\) 期内正自相关占优(趋势),\(VR(q)<1\) 说明负自相关占优(均值回复)。它把多个阶的自相关汇总成一个统计量,比逐阶检验自相关(第 06 册第 02a 章的 Ljung–Box)更有针对性:你可以直接问"在 1 周、1 个月、1 年的尺度上,价格是趋势还是回复"。同方差下的 \(z\) 统计量见代码;厚尾和波动聚集下应使用异方差稳健版本或 bootstrap。
推导拆解:近似式 \(VR(q)\approx1+2\sum(1-k/q)\rho_k\) 的来历与第 06 章 Lo 的 Sharpe 调整完全相同。\(q\) 期收益是 \(q\) 个日收益之和,和的方差 \(=\) 所有两两协方差之和:对角线 \(q\) 个 \(\sigma^2\),相隔 \(k\) 天的配对有 \(2(q-k)\) 个,每个 \(\rho_k\sigma^2\)。于是 \(\operatorname{Var}=\sigma^2\big(q+2\sum_{k=1}^{q-1}(q-k)\rho_k\big)\),除以 \(q\sigma^2\) 即得。权重 \(1-k/q\) 说明:离得越近的自相关,在 \(q\) 期窗口里出现的次数越多,权重越大。 数值例:只有 \(\rho_1=-0.05\)(代码 8.1 的短期反转量级)时,\(VR(2)=1+2\times\tfrac12\times(-0.05)=0.95\),与输出的 0.956 一致。 代码
variance_ratio里的np.convolve(x, np.ones(q), "valid")是在算所有重叠的 \(q\) 日累计收益(相当于滚动求和);分母里的(1 - q / n)是对重叠造成的小样本偏差做的修正。
8.2.2 时间序列动量
Moskowitz、Ooi 和 Pedersen(2012)的 TSMOM 很简单:对每个资产,看它过去 12 个月的收益符号,做多或做空,持仓规模按事前波动率缩放到同一目标波动:
波动缩放有两个作用:让不同波动的资产(国债和原油)贡献相近的风险;并且在高波动时自动降杠杆,这正是下一节波动率择时的内容。
与之对照,横截面动量(本册第 03 章的因子研究)比较的是资产之间的相对强弱,做多赢家、做空输家,与市场整体涨跌无关;TSMOM 比较的是资产与自己的过去,可以整体偏多或偏空。
8.2.3 代码:方差比与 TSMOM
代码 8.1 生成 20 个资产 15 年的日收益:每个资产有一个缓慢变化的漂移(半衰期约 140 天的 AR(1),制造趋势),收益一阶负自相关(制造短期反转),GARCH 波动和 \(t(5)\) 冲击,资产间相关 0.2。
# 代码 8.1 时间序列动量与均值回复:方差比检验与 TSMOM(合成多资产期货)
import numpy as np
import pandas as pd
def make_futures(T=252 * 15, N=20, seed=0, trend=True):
"""N 个资产的日收益:GARCH(1,1)+t(5) 波动;慢变漂移 mu_t(AR(1), φ=0.995)制造趋势;
一阶负自相关 -0.05 制造短期反转。各资产年化波动 10%–40%,资产间相关 0.2。"""
rng = np.random.default_rng(seed)
vol_ann = rng.uniform(0.10, 0.40, N); h = (vol_ann / np.sqrt(252)) ** 2
omega = h * 0.03; a, b = 0.06, 0.91
L = np.linalg.cholesky(0.2 + 0.8 * np.eye(N))
mu = np.zeros(N); r = np.zeros((T, N)); e_prev = np.zeros(N)
for t in range(T):
z = L @ (rng.standard_t(5, N) / np.sqrt(5 / 3))
e = np.sqrt(h) * z
if trend:
mu = 0.995 * mu + np.sqrt(1 - 0.995**2) * 0.04 * (vol_ann / np.sqrt(252)) * rng.standard_normal(N)
r[t] = mu + e - 0.05 * e_prev
h = omega + a * e**2 + b * h; e_prev = e
return pd.DataFrame(r, index=pd.bdate_range("2008-01-01", periods=T))
def variance_ratio(x, q):
"""Lo–MacKinlay 方差比 VR(q) 与同方差下的 z 统计量(重叠 q 期收益)。"""
x = np.asarray(x); n = len(x); m = x.mean()
s1 = np.sum((x - m) ** 2) / (n - 1)
xq = np.convolve(x, np.ones(q), "valid")
sq = np.sum((xq - q * m) ** 2) / (q * (n - q + 1) * (1 - q / n))
vr = sq / s1
z = (vr - 1) / np.sqrt(2 * (2 * q - 1) * (q - 1) / (3 * q * n))
return vr, z
R = make_futures()
print("方差比(20 个资产的中位数)及 z 统计量中位数")
for q in [2, 5, 20, 60, 250]:
v = np.array([variance_ratio(R[c], q) for c in R])
print(f" q={q:3d}: VR={np.median(v[:, 0]):.3f} z={np.median(v[:, 1]):+.2f}")
def tsmom(R, look=252, target=0.40, cost=3e-4, skip=0):
"""Moskowitz–Ooi–Pedersen 式 TSMOM:sign(过去 look 日收益) × 目标波动 / 事前波动,月度调仓。"""
sig = np.sign(R.rolling(look).sum().shift(1 + skip))
vol = np.sqrt((R**2).ewm(com=60).mean().shift(1) * 252) # 事前年化波动(EWMA)
m = R.index.to_period("M"); month_end = m != m.shift(-1) # 每月最后一个交易日
w = (sig * target / vol)[month_end].reindex(R.index).ffill().fillna(0) / R.shape[1] # 月末定仓,等权分配
pnl = (w.shift(1) * R).sum(axis=1) - cost * w.diff().abs().sum(axis=1)
return pnl.iloc[look + 60:]
print("\n回看期 年化收益 年化波动 Sharpe")
for look in [5, 21, 63, 126, 252]:
p = tsmom(R, look)
print(f"{look:6d} {p.mean()*252:8.3f} {p.std()*np.sqrt(252):8.3f} {p.mean()/p.std()*np.sqrt(252):6.2f}")
p0 = tsmom(make_futures(trend=False), 252)
print(f"无趋势成分的对照市场:252 日 TSMOM Sharpe {p0.mean()/p0.std()*np.sqrt(252):.2f}")
输出:
方差比(20 个资产的中位数)及 z 统计量中位数
q= 2: VR=0.956 z=-2.69
q= 5: VR=0.910 z=-2.53
q= 20: VR=0.929 z=-0.88
q= 60: VR=0.992 z=-0.05
q=250: VR=1.258 z=+0.87
回看期 年化收益 年化波动 Sharpe
5 -0.071 0.113 -0.63
21 0.018 0.114 0.15
63 0.060 0.114 0.52
126 0.051 0.118 0.43
252 0.085 0.120 0.71
无趋势成分的对照市场:252 日 TSMOM Sharpe -0.33
- 方差比清楚地显示了两个尺度上的结构:\(q=2,5\) 时 VR 显著小于 1(短期反转),\(q=250\) 时 VR 为 1.26(趋势)。但注意 \(q=250\) 的 \(z\) 只有 0.87,在单个资产上并不显著:15 年里只有 15 个不重叠的年度观测,长尺度的检验功效很低。
- 而 20 个资产汇总后的 TSMOM(252 日回看)Sharpe 是 0.71。单个资产检验不出来、组合层面却很明显,原因是 20 个资产的趋势相互独立,组合把信噪比提高了约 \(\sqrt{20}\) 倍(扣除相关后略少)。这也是 TSMOM 在实务中总是做成几十个品种的组合的原因。
- 回看期从 5 天到 252 天,Sharpe 从 −0.63 变到 0.71:5 天回看撞上了短期反转。回看期是一个参数,几个回看期的结果都要报告,并计入本册第 06 章的试验数。
白话解释:
tsmom函数的每一行都在防止前视或控制风险,值得逐行看。R.rolling(look).sum().shift(1 + skip):过去look天累计收益,再整体后移一天,保证 \(t\) 日的信号只用到 \(t-1\) 日及以前(skip用于练习 2 跳过最近一个月)。(R**2).ewm(com=60).mean().shift(1):用指数加权的平方收益估计方差,也后移一天,是"事前"波动;com=60对应衰减系数 \(1/61\),有效记忆大约两三个月。month_end挑出每月最后一个交易日,只在这些日子定仓,ffill把仓位一直沿用到下个月末,这样换手只发生在月末。最后除以R.shape[1]是把 20 个资产等权分配。损益一行里w.shift(1) * R再次后移,因为月末收盘定的仓位只能赚下一天的收益。 金融直觉:target / vol这一步就是 CTA 行业里说的"风险平价式加权":国债波动 5%、原油波动 35%,若都按名义金额等权,组合风险几乎全部来自原油;按波动缩放后,每个品种对组合的风险贡献大致相当。
- 对照市场(只有反转、没有趋势)中 TSMOM 的 Sharpe 为 −0.33。14 年样本的 Sharpe 标准误约 0.27,这个负值还在噪声范围内,但它提醒我们:趋势策略在没有趋势的市场里不是"不赚不赔",而是会因为成本和反转持续亏损。
8.2.4 短期均值回复为什么难以变现
代码 8.1 里的短期反转在统计上比长期趋势更显著(\(q=2\) 时 \(z=-2.69\)),但用它交易却难得多。原因有三个。第一,短期反转的预测期只有一两天,信号每天都在变,换手极高,单位换手能赚的钱只有几个基点,与价差和冲击同一量级(本册第 07 章)。第二,真实数据中相当一部分短期负自相关来自买卖价差反弹(第 07 册第 14 章的 Roll 模型):成交价在买价和卖价之间跳动,看起来像均值回复,但它恰恰是交易者要付出的成本,而不是可以赚到的收益。用收盘成交价检验出的反转,要用中间价重新检验一次。第三,短期反转往往是流动性提供的报酬:在别人急于卖出时买入,承担的是库存风险,在市场压力时期会集中亏损。所以短期反转更适合作为执行层的信号(本册第 07 章 7.7 节限价单的挂单时机),或者作为降低动量策略换手的过滤条件,而不是单独的策略。
8.3 波动率择时
8.3.1 为什么目标波动策略可能提高 Sharpe
设资产的条件期望收益为 \(\mu_t\),条件波动为 \(\sigma_t\)。持仓 \(w_t=c/\hat\sigma_t\) 的策略,其 Sharpe 取决于 \(\mu_t/\sigma_t\) 与 \(1/\sigma_t\) 的关系。如果期望收益不随波动上升(或上升得比方差慢),那么高波动时期的单位风险回报更低,在这些时期降杠杆、低波动时期加杠杆,就能提高整体 Sharpe。Moreira 和 Muir(2017)在美国股票和多个因子上发现了这种现象。反过来,如果高波动时期的期望收益同比例甚至更大幅度地上升(风险溢价随风险上升),波动择时就没有好处。这是一个关于风险–收益权衡的经验问题,不是数学必然。
推导拆解:在"期望收益恒定"这个最有利的情形下,可以把好处算出来。设日收益 \(r_t=\mu+\sigma_tz_t\),\(z_t\) 均值 0、方差 1,且假设波动预测完全准确。 买入持有:均值 \(\mu\),方差 \(\mathbb E[\sigma_t^2]\)(\(\mu\) 很小,忽略),Sharpe \(=\mu/\sqrt{\mathbb E[\sigma_t^2]}\)。 目标波动:持仓 \(c/\sigma_t\),收益 \(c\mu/\sigma_t+cz_t\),均值 \(c\mu\,\mathbb E[1/\sigma_t]\),方差约 \(c^2\),Sharpe \(\approx\mu\,\mathbb E[1/\sigma_t]\)。 比较两者:由 Jensen 不等式(\(1/x\) 是凸函数)\(\mathbb E[1/\sigma]\ge1/\mathbb E[\sigma]\),又 \(\mathbb E[\sigma]\le\sqrt{\mathbb E[\sigma^2]}\),所以目标波动的 Sharpe 不低于买入持有,波动变化越剧烈,差距越大。 若期望收益与方差成正比(\(\mu_t=k\sigma_t^2\)),目标波动的均值变成 \(ck\,\mathbb E[\sigma_t]\),买入持有的 Sharpe 是 \(k\,\mathbb E[\sigma^2]/\sqrt{\mathbb E[\sigma^2]}=k\sqrt{\mathbb E[\sigma^2]}\),后者反而更高,这就是练习 3 的答案方向。 金融直觉:这和 CFA 里讲的"恒定比例组合保险""风险预算"同源。如果市场在动荡时并没有给你更高的补偿,那么动荡时少承担风险就是白捡的 Sharpe;如果动荡时风险溢价同步上升(例如危机后的高期望收益),降仓就会错过最赚钱的时候。
工程上要回答三个问题:用什么预测波动(EWMA、GARCH 族、已实现波动,见第 06 册第 03a、03b 章),杠杆上限设多少,换手成本有多高。
8.3.2 代码:EWMA 与 GJR-GARCH
代码 8.2 用 GJR-GARCH(1,1) 加 \(t(5)\) 冲击生成 20 年的股指日收益,期望收益恒定(年化 6%)。目标波动 15%,杠杆上限 2 倍。GARCH 参数每季度用扩展窗口重估一次,然后用固定参数逐日滤波——这保证 \(\hat\sigma_t\) 只用到 \(t-1\) 及以前的数据。
# 代码 8.2 波动率择时:EWMA 与 GARCH 预测下的目标波动策略
import numpy as np
import pandas as pd
from arch import arch_model
T = 252 * 20
def make_index(seed):
"""股指日收益:GJR-GARCH(1,1)(负收益抬高波动更多)+ t(5) 冲击;期望收益恒定(年化 6%)。"""
rng = np.random.default_rng(seed)
omega, a, g, b = 2e-6, 0.03, 0.10, 0.90
h = omega / (1 - a - g / 2 - b); r = np.empty(T)
for t in range(T):
e = np.sqrt(h) * rng.standard_t(5) / np.sqrt(5 / 3)
r[t] = 0.06 / 252 + e
h = omega + (a + g * (e < 0)) * e**2 + b * h
return pd.Series(r, index=pd.bdate_range("2004-01-01", periods=T))
r = make_index(7)
# 事前波动预测(只用 t-1 及以前的信息)
ewma = np.sqrt((r**2).ewm(alpha=0.06).mean().shift(1))
garch = pd.Series(np.nan, index=r.index)
for start in range(756, T, 63): # 每季度用扩展窗口重估一次
res = arch_model(r.iloc[:start] * 100, mean="Constant", vol="GARCH", p=1, o=1, q=1, dist="t").fit(disp="off")
end = min(start + 63, T)
# 用固定参数对后续 63 天做逐日一步预测(滤波),不使用这些天之后的数据
am = arch_model(r.iloc[:end] * 100, mean="Constant", vol="GARCH", p=1, o=1, q=1, dist="t")
sig = am.fix(res.params).conditional_volatility / 100 # 条件波动 σ_t 只依赖 t-1 及以前
garch.iloc[start:end] = sig[start:end]
def target_vol(sig_hat, target=0.15, cap=2.0, cost=2e-4, r=r):
w = (target / (sig_hat * np.sqrt(252))).clip(upper=cap)
pnl = w * r - cost * w.diff().abs()
return pnl, w
out = {}
mask = garch.notna()
for name, sh in [("买入持有", None), ("EWMA 目标波动", ewma), ("GJR-GARCH 目标波动", garch)]:
if sh is None:
pnl, w = r.copy(), pd.Series(1.0, index=r.index)
else:
pnl, w = target_vol(sh)
p, ww = pnl[mask], w[mask]
nav = np.exp(np.cumsum(p))
out[name] = dict(年化收益=p.mean() * 252, 年化波动=p.std() * np.sqrt(252), Sharpe=p.mean() / p.std() * np.sqrt(252),
最大回撤=(1 - nav / nav.cummax()).max(), 峰度=p.kurt() + 3,
平均杠杆=ww.mean(), 年换手=ww.diff().abs().sum() / (len(ww) / 252))
print(pd.DataFrame(out).T.round(3))
# 波动预测本身的质量:对已实现平方收益的 MSE 与 Mincer–Zarnowitz 回归斜率
for name, sh in [("EWMA", ewma), ("GJR-GARCH", garch)]:
x, y = sh[mask] ** 2, r[mask] ** 2
print(f"{name:9s} 预测方差对实现平方收益的回归斜率 {np.polyfit(x, y, 1)[0]:.2f}(无偏预测应为 1)")
# 单条路径的 Sharpe 差别可能是运气:在 30 条独立路径上比较(只用 EWMA,计算快)
d = []
for s in range(30):
rr = make_index(100 + s)
pnl, _ = target_vol(np.sqrt((rr**2).ewm(alpha=0.06).mean().shift(1)), r=rr)
d.append([rr[756:].mean() / rr[756:].std(), pnl[756:].mean() / pnl[756:].std()])
d = np.array(d) * np.sqrt(252)
print(f"30 条路径:买入持有平均 Sharpe {d[:, 0].mean():.2f},EWMA 目标波动 {d[:, 1].mean():.2f},"
f"后者更高的路径 {np.sum(d[:, 1] > d[:, 0])}/30")
输出:
年化收益 年化波动 Sharpe 最大回撤 峰度 平均杠杆 年换手
买入持有 0.020 0.163 0.122 0.704 15.369 1.000 0.000
EWMA 目标波动 0.063 0.160 0.391 0.441 8.780 1.270 9.407
GJR-GARCH 目标波动 0.051 0.154 0.331 0.456 7.798 1.216 11.431
EWMA 预测方差对实现平方收益的回归斜率 0.87(无偏预测应为 1)
GJR-GARCH 预测方差对实现平方收益的回归斜率 0.98(无偏预测应为 1)
30 条路径:买入持有平均 Sharpe 0.35,EWMA 目标波动 0.41,后者更高的路径 26/30
- 在这条路径上,两种目标波动策略的 Sharpe(0.39、0.33)都明显高于买入持有(0.12),最大回撤从 70% 降到 45%,峰度从 15 降到 8 左右。买入持有的 Sharpe 偏低是这条路径的运气(真实 Sharpe 约 0.37)。
- 30 条路径的平均更能说明问题:目标波动策略的平均 Sharpe 是 0.41,买入持有 0.35,26/30 条路径上前者更高。改进是真实的,但幅度比单条路径显示的小得多。
- GJR-GARCH 的波动预测更准(Mincer–Zarnowitz 斜率 0.98,EWMA 为 0.87),策略的 Sharpe 却略低。预测精度与策略收益不是一回事:策略对预测误差的敏感度是非线性的(\(1/\hat\sigma\)),GARCH 对冲击的反应更快,换手也更高(年换手 11.4 对 9.4)。选模型时应当直接比较策略层面的指标,而不只是预测误差。
- 平均杠杆 1.2–1.3 倍。实盘中需要融资或用期货实现,融资成本和保证金约束会吃掉一部分改进。
白话解释:代码 8.2 中 GARCH 部分为什么写得这么绕?直接对全样本拟合一次 GARCH 再取条件波动,参数里就含有未来 20 年的信息,这是第 06 章 6.3 节"全样本预处理"的前视。正确做法分两层:参数每季度用"截至当时"的数据重估一次(
r.iloc[:start]);在下一个季度里,参数固定不动,只让 GARCH 的递推式逐日往前走(am.fix(res.params))。GARCH 的条件波动 \(\sigma_t\) 本来就只依赖 \(t-1\) 及以前的收益,所以只要参数不含未来信息,滤波出来的 \(\sigma_t\) 就是当天开盘前真正能算出的预测。这和公司每季度更新一次预算假设、季度内按假设滚动预测是同一种做法。 Mincer–Zarnowitz 回归是检验预测是否无偏的标准工具:把实现值对预测值回归,好的预测应当截距为 0、斜率为 1。EWMA 斜率 0.87 说明它的预测在高位时偏高、低位时偏低,即对波动的变化反应过度。
8.3.3 实现细节
目标波动策略在实盘中还要处理几件事。杠杆上限:低波动时期预测波动很小,\(1/\hat\sigma\) 会给出很高的杠杆,代码里设了 2 倍上限;实务中还会给预测波动设一个下限(例如长期平均的一半),避免在"风平浪静"的末尾加满杠杆。调仓阈值:预测波动每天都在变,逐日调到目标会产生大量小额换手;可以只在目标仓位偏离当前仓位超过一定比例(例如 10%)时才调仓,这就是本册第 05 章 5.5 节的不交易区间。波动跳升时的反应速度:EWMA 的衰减参数和 GARCH 的参数决定了遇到冲击时降仓的速度,反应太慢会在危机初期承受最大的损失,反应太快会在噪声中频繁进出。组合层面:对多资产组合做波动目标时,应该用组合的协方差预测(本册第 04 章、第 06 册第 10 章),而不是对每个资产单独缩放,否则资产间相关性上升时(危机中常见)组合波动会超出目标。
8.4 配对交易
8.4.1 流程
配对交易的理论框架见第 06 册第 08b 章 8.11 节:若两只股票的对数价格协整,价差 \(w_t=p_{1t}-\gamma p_{2t}\) 平稳并均值回复,价差偏离时做空高估的一腿、做多低估的一腿。工程上的完整流程是:
- 候选池:同行业、同类资产(ETF 与成分、AH 股、期现、跨期)。经济上有理由的配对,先验成立概率高。
- 筛选:Engle–Granger 两步法或 Johansen 检验(第 05 册第 17b 章、第 06 册第 08b 章 8.9 节)。
- 估计:对冲比、价差的均值回复速度和波动。
- 交易规则:入场阈值、出场阈值、止损、最长持有期。
- 监控:协整关系是否破裂。
8.4.2 筛选中的多重检验
\(N\) 只股票有 \(N(N-1)/2\) 个配对。40 只股票就是 780 对,在 5% 水平下纯噪声也会产生约 39 个"显著"配对。这是第 03 册第 10b 章的标准多重检验问题,但配对筛选还有两个特殊的陷阱:
- 检验前提:Engle–Granger 检验假设两条腿都是 I(1)。如果某只股票在筛选期内恰好"看起来平稳"(随机游走在有限样本中常常如此),它和几乎任何股票的回归残差都可能显得平稳,于是一只股票制造出一大串伪配对。
- 检验不独立:同一只股票出现在 \(N-1\) 个配对中,检验统计量高度相关。Bonferroni 在相关检验下仍然控制 FWER(只是保守),但上面这种"一只坏股票污染一串配对"的情形,会让多个伪配对的 p 值同时极小,这时任何多重检验校正都挡不住,必须从源头剔除。
代码 8.3 生成 40 只股票(市场因子、8 个行业因子、特异随机游走),其中 4 对是真协整(价差为半衰期约 4 天的 OU 过程)。前 2 年筛选,第 3 年检验。
# 代码 8.3 配对筛选中的多重检验:780 对股票里找 4 对真协整
import warnings
import numpy as np
from itertools import combinations
from statsmodels.tsa.stattools import coint, adfuller
warnings.filterwarnings("ignore", category=FutureWarning) # statsmodels 0.15 对 adfuller 返回类型的提示
rng = np.random.default_rng(11)
N, T = 40, 756 # 40 只股票,3 年:前 2 年筛选,第 3 年检验
mkt = np.cumsum(0.012 * rng.standard_t(5, T) / np.sqrt(5 / 3))
ind = np.repeat(np.arange(8), 5) # 8 个行业
indf = np.cumsum(0.008 * rng.standard_normal((T, 8)), axis=0)
logp = np.empty((T, N))
for i in range(N):
logp[:, i] = 3 + rng.uniform(0.7, 1.3) * mkt + indf[:, ind[i]] + np.cumsum(0.015 * rng.standard_normal(T))
true_pairs = [(0, 1), (10, 11), (20, 21), (30, 31)]
for a, b in true_pairs: # b = 1.0·a + OU 价差(半衰期约 4 天)
s = np.zeros(T)
for t in range(1, T):
s[t] = 0.85 * s[t - 1] + 0.012 * rng.standard_normal()
logp[:, b] = logp[:, a] + 0.2 + s
form, trade = logp[:504], logp[504:]
pairs = list(combinations(range(N), 2))
pv = np.array([coint(form[:, i], form[:, j])[1] for i, j in pairs])
m = len(pairs)
print(f"检验对数 {m};5% 水平下单独显著 {np.sum(pv < 0.05)} 对(纯噪声预期约 {0.05 * m:.0f} 对)")
def bh(p, q=0.05):
o = np.argsort(p); k = np.where(p[o] <= q * np.arange(1, len(p) + 1) / len(p))[0]
sel = np.zeros(len(p), bool)
if len(k): sel[o[:k.max() + 1]] = True
return sel
def report(tag, idx):
"""idx:参与检验的配对下标。对三种选择规则报告命中数与样本外表现。"""
p = pv[idx]
print(f"--- {tag}:检验 {len(idx)} 对 ---")
for name, sel in [("未校正 p<0.05", p < 0.05), ("Bonferroni", p < 0.05 / len(idx)), ("BH (FDR 5%)", bh(p))]:
chosen = [pairs[idx[k]] for k in np.flatnonzero(sel)]
hit = sum(c in true_pairs for c in chosen)
oos_ok = 0 # 样本外:用筛选期 OLS 对冲比,检查交易期价差是否平稳
for i, j in chosen:
beta = np.polyfit(form[:, j], form[:, i], 1)[0]
oos_ok += adfuller(trade[:, i] - beta * trade[:, j])[1] < 0.05
print(f"{name:14s} 选出 {len(chosen):3d} 对,其中真协整 {hit}/4;交易期价差仍平稳的 {oos_ok} 对")
report("全部配对", np.arange(m))
# 协整检验的前提是两条腿都是 I(1)。筛选期内单独看已经"平稳"的个股,会和许多股票组成伪配对
adf_leg = np.array([adfuller(form[:, i])[1] for i in range(N)])
print(f"筛选期内 ADF 拒绝单位根(p<0.05)的个股: {np.flatnonzero(adf_leg < 0.05).tolist()}")
ok = [k for k, (i, j) in enumerate(pairs) if adf_leg[i] >= 0.05 and adf_leg[j] >= 0.05]
report("剔除这些个股后", np.array(ok))
report("只在同行业内配对", np.array([k for k, (i, j) in enumerate(pairs) if ind[i] == ind[j]]))
输出:
检验对数 780;5% 水平下单独显著 76 对(纯噪声预期约 39 对)
--- 全部配对:检验 780 对 ---
未校正 p<0.05 选出 76 对,其中真协整 4/4;交易期价差仍平稳的 6 对
Bonferroni 选出 13 对,其中真协整 4/4;交易期价差仍平稳的 4 对
BH (FDR 5%) 选出 28 对,其中真协整 4/4;交易期价差仍平稳的 4 对
筛选期内 ADF 拒绝单位根(p<0.05)的个股: [4, 9, 12, 28, 33, 34]
--- 剔除这些个股后:检验 561 对 ---
未校正 p<0.05 选出 39 对,其中真协整 4/4;交易期价差仍平稳的 6 对
Bonferroni 选出 4 对,其中真协整 4/4;交易期价差仍平稳的 4 对
BH (FDR 5%) 选出 5 对,其中真协整 4/4;交易期价差仍平稳的 4 对
--- 只在同行业内配对:检验 80 对 ---
未校正 p<0.05 选出 8 对,其中真协整 4/4;交易期价差仍平稳的 4 对
Bonferroni 选出 4 对,其中真协整 4/4;交易期价差仍平稳的 4 对
BH (FDR 5%) 选出 5 对,其中真协整 4/4;交易期价差仍平稳的 4 对
- 不做校正时选出 76 对,比纯噪声的预期(39 对)还多:伪配对不仅来自"5% 的运气",还来自上面说的结构性原因。76 对里只有 4 对是真的,第 3 年价差仍平稳的只有 6 对。
- Bonferroni 选出 13 对、BH 选出 28 对,多重检验校正之后仍有 9 到 24 个伪配对。检查后发现,Bonferroni 选出的 9 个伪配对全部包含 12 号股票:它在筛选期内的 ADF 检验 p 值只有 0.006,看起来是平稳的。
- 先剔除筛选期内单独看来平稳的个股(这里有 6 只,比 5% 的名义水平高,部分原因是有限样本中 ADF 检验的水平扭曲),再做校正,Bonferroni 和 BH 分别选出 4 对和 5 对,4 个真配对全部命中。
- 只在同行业内配对,检验数从 780 降到 80,多重检验的负担减小一个数量级,未校正时也只选出 8 对。经济先验是最有效的多重检验校正:它在看数据之前就把检验数降了下来。
白话解释:两种校正方法的区别,用代码里的
bh函数说明最直接。Bonferroni:每个 p 值都要小于 \(0.05/m\),\(m=780\) 时门槛是 0.000064,目标是"780 次里一次都不错"(控制 FWER,即至少犯一次错的概率)。BH:把 p 值从小到大排序,第 \(k\) 小的 p 值与 \(0.05\cdot k/m\) 比较,找到最后一个满足条件的位置,它和它之前的全部入选。门槛随排名放宽,目标是"选出来的里面假的比例不超过 5%"(控制 FDR,错误发现率)。前者像审计里"零容忍"的重大错报标准,后者像"允许少量可容忍错报"的抽样标准。 为什么一只"看起来平稳"的股票能制造一串伪配对?Engle–Granger 第一步回归 \(p_i=\alpha+\gamma p_j+u\),第二步检验残差 \(u\) 是否平稳。若 \(p_i\) 本身在样本期内恰好平稳,取 \(\gamma\approx0\),残差就几乎等于 \(p_i\),检验自然"通过",和谁配对都一样。所以检验前要先确认每条腿单独是 I(1)(不平稳、差分后平稳),这就是"检验前提"的含义。
8.4.3 OU 过程、半衰期与交易阈值
价差的连续时间模型通常取 Ornstein–Uhlenbeck 过程 \(dX_t=\kappa(m-X_t)dt+\sigma dW_t\)(与第 08 册第 31 章的 Vasicek 利率模型是同一个过程)。以日为单位离散采样,它就是 AR(1):
半衰期决定了持仓时间、交易频率和资金周转:半衰期 5 天的价差一年能做几十次,半衰期 60 天的一年只能做几次,而且每次都要承受很长时间的风险。半衰期也是判断协整是否可信的标尺:如果估计出的半衰期接近或超过估计窗口长度,所谓"均值回复"就无法与随机游走区分。
推导拆解:OU 过程与 AR(1) 参数的对应关系。 第一步,OU 的含义:\(dX=\kappa(m-X)dt+\sigma dW\),偏离均值 \(m\) 时,有一个大小与偏离成正比的"拉力"把它拉回来,\(\kappa\) 是拉力强度。不看噪声时,偏离按 \(X_t-m=(X_0-m)e^{-\kappa t}\) 指数衰减(这是微分方程 \(\frac{d}{dt}(X-m)=-\kappa(X-m)\) 的解)。 第二步,间隔 1 天采样:\(X_{t+1}-m=e^{-\kappa}(X_t-m)+\text{噪声}\),展开得 \(X_{t+1}=m(1-e^{-\kappa})+e^{-\kappa}X_t+\zeta_t\)。对照 AR(1) 就有 \(b=e^{-\kappa}\),\(a=m(1-b)\),反解 \(m=a/(1-b)\)。 第三步,平衡标准差:平稳时 \(\operatorname{Var}(X_{t+1})=\operatorname{Var}(X_t)=V\),代入 AR(1) 得 \(V=b^2V+\operatorname{Var}(\zeta)\),所以 \(V=\operatorname{Var}(\zeta)/(1-b^2)\)。 第四步,半衰期:偏离缩小到一半,\(b^h=0.5\),取对数得 \(h=\ln0.5/\ln b\)。 金融直觉:这和 CFA 固定收益里 Vasicek 模型的利率均值回复是同一个过程(正文已提到)。\(\kappa\) 越大,价差回来得越快,同样的资金一年能周转更多次,这就是为什么半衰期比"价差偏离了多少"更决定配对交易的赚钱能力。
阈值规则在价差的 \(z\) 分数 \((X_t-m)/\sigma_{\text{eq}}\) 超过入场阈值时开仓、回到出场阈值时平仓。阈值越宽,单次利润越大,但次数越少;第 06 册第 08b 章 8.11.2 节说明了"单次利润 \(2\Delta\) 必须超过往返成本 \(\eta\)"。Bertram(2010)在 OU 假设下推导了使单位时间期望收益最大的阈值,但在实践中,参数估计误差和模型误设通常比阈值的精细优化重要得多。
8.4.4 静态对冲比失效与 Kalman 滤波
协整关系会漂移:公司业务结构、资本结构、指数权重的变化都会让对冲比改变。用固定的对冲比交易,价差会出现趋势,均值回复策略会在趋势中一直加仓或长期被套。
把对冲比写成状态变量,就得到第 06 册第 11b 章 11.11.1 节的时变系数回归:
Kalman 滤波给出 \(\beta_{t|t-1}\)(今天开盘前对今天对冲比的最优估计)和一步预测误差 \(e_t\) 及其方差 \(F_t\)。\(e_t/\sqrt{F_t}\) 就是一个自动标准化、对冲比自适应的价差 \(z\) 分数。状态噪声方差 \(Q\) 与观测噪声方差 \(R\) 之比决定了 \(\beta\) 的调整速度:比值太大,\(\beta\) 追着噪声跑,价差被"吸收"掉,没有交易机会;比值太小,就退化成静态回归。\(Q\) 和 \(R\) 可以用预测误差分解的极大似然估计(第 06 册第 11a 章 11.9 节),但实务中常把比值当作超参数在验证集上选,这又是一次试验。
白话解释:Kalman 滤波每天做两件事,对应代码
kalman_pair的循环。 预测:昨天对 \((\alpha,\beta)\) 的估计原样带到今天,但不确定性变大一点(P = P + Q),因为对冲比可能在一夜之间漂移了。用这个估计预测今天的 \(y_t\),与实际值相比得到预测误差 \(e_t\),并算出这个误差"应该有多大":\(F_t=H P H^\top+R\),即参数不确定性带来的误差加上观测噪声。 更新:新估计 \(=\) 旧估计 \(+K_t e_t\)。卡尔曼增益 \(K_t=PH^\top/F_t\) 决定了"今天的意外有多少被归因为对冲比变了":参数越不确定(\(P\) 大)、观测噪声越小(\(R\) 小),\(K\) 越大,估计调整得越多。 金融直觉:这就像分析师更新对一家公司盈利的估计:新季报出来与预测不符时,调整幅度取决于你对原有估计有多大把握、以及这次季报本身有多少噪声。\(Q/R\) 大,相当于"相信每次意外都反映基本面变化",估计追着噪声跑,价差的偏离被对冲比吸收,交易机会消失;\(Q/R\) 小,相当于"认定基本面不变",退化成静态回归。 注意代码里betas[t] = th[1]放在更新之前:交易用的是看到今天价格之前的对冲比,否则就是前视。\(e_t/\sqrt{F_t}\) 已经按自身的预期大小标准化,可以直接当 \(z\) 分数用。
8.4.5 代码:静态 OLS 与 Kalman
代码 8.4 生成一对股票,第 1 年对冲比恒为 1,后 2 年对冲比做缓慢随机游走;真实价差是半衰期 6.6 天的 OU 过程。比较"第 1 年估计静态 OLS 对冲比"和"Kalman 滤波时变对冲比"两种做法,单边成本 5bp。
# 代码 8.4 配对交易:OU 半衰期、阈值规则与 Kalman 时变对冲比
import warnings
import numpy as np
from statsmodels.tsa.stattools import adfuller
warnings.filterwarnings("ignore", category=FutureWarning)
rng = np.random.default_rng(21)
T, F = 756, 252 # 3 年;第 1 年估计,后 2 年交易
x = 3 + np.cumsum(0.015 * rng.standard_t(5, T) / np.sqrt(5 / 3)) # 腿 2 的对数价格
beta_t = 1.0 + np.r_[np.zeros(F), np.cumsum(0.003 * rng.standard_normal(T - F))] # 交易期 β 缓慢漂移
s = np.zeros(T)
for t in range(1, T):
s[t] = 0.90 * s[t - 1] + 0.01 * rng.standard_normal() # 真实价差:OU,半衰期约 6.6 天
y = 0.5 + beta_t * x + s
def ou_fit(z):
"""离散 AR(1) 拟合 OU:z_t = a + b z_{t-1} + e。返回均值、平衡标准差、半衰期。"""
b, a = np.polyfit(z[:-1], z[1:], 1)
e = z[1:] - a - b * z[:-1]
return a / (1 - b), e.std() / np.sqrt(1 - b**2), np.log(0.5) / np.log(b)
# 1) 静态 OLS:第 1 年估计 β,后 2 年沿用
bh, ah = np.polyfit(x[:F], y[:F], 1)
spr = y - ah - bh * x
mu, sd, hl = ou_fit(spr[:F])
print(f"静态 OLS β={bh:.3f};估计半衰期 {hl:.1f} 天(真实 {np.log(0.5)/np.log(0.9):.1f});"
f"交易期价差 ADF p={adfuller(spr[F:])[1]:.3f}")
# 2) Kalman 滤波:状态 (α_t, β_t) 随机游走,观测 y_t = α_t + β_t x_t + ε_t(第 06 册第 11b 章 11.11.1 节)
def kalman_pair(y, x, q=1e-5, r=1e-4):
th = np.array([0.0, 1.0]); P = np.eye(2); Q = np.diag([q, q])
e, Fv, betas = np.zeros(len(y)), np.zeros(len(y)), np.zeros(len(y))
for t in range(len(y)):
P = P + Q # 预测步
H = np.array([1.0, x[t]])
e[t] = y[t] - H @ th # 一步预测误差(只用 t-1 的状态估计)
Fv[t] = H @ P @ H + r
K = P @ H / Fv[t]
betas[t] = th[1] # 交易用的是更新前的 β_{t|t-1}
th = th + K * e[t]; P = P - np.outer(K, H) @ P
return e, np.sqrt(Fv), betas
e, sF, beta_kf = kalman_pair(y, x)
print(f"Kalman:交易期 β 估计误差的均方根 {np.sqrt(np.mean((beta_kf[F:] - beta_t[F:])**2)):.3f},"
f"静态 OLS {np.sqrt(np.mean((bh - beta_t[F:])**2)):.3f}")
def trade(z, hedge, entry=1.5, exit_=0.0, cost=5e-4):
"""z>entry 做空价差、z<-entry 做多价差,回到 exit_ 平仓。价差持仓 1 单位 = 多 1 份 y、空 hedge 份 x。"""
pos = np.zeros(T)
for t in range(F, T - 1):
p = pos[t - 1]
if p == 0 and z[t] > entry: p = -1
elif p == 0 and z[t] < -entry: p = 1
elif p == -1 and z[t] <= exit_: p = 0
elif p == 1 and z[t] >= -exit_: p = 0
pos[t] = p
dy, dx = np.diff(y), np.diff(x)
pnl = pos[:-1] * (dy - hedge[:-1] * dx) # t 日收盘定仓,赚 t→t+1
turn = np.abs(np.diff(np.r_[0, pos[:-1]])) * (1 + np.abs(hedge[:-1]))
net = (pnl - cost * turn)[F:]
return net.sum(), net.mean() / net.std() * np.sqrt(252) if net.std() > 0 else 0, int((np.diff(pos) != 0).sum() / 2)
z_static = (spr - mu) / sd
z_kf = e / sF
for name, z, hdg in [("静态 OLS + OU 阈值", z_static, np.full(T, bh)), ("Kalman 预测误差 z", z_kf, beta_kf)]:
tot, sr, n = trade(z, hdg)
print(f"{name:18s} 两年累计对数收益 {tot:+.3f},净 Sharpe {sr:5.2f},交易次数约 {n}")
# 阈值的敏感度
for ent in [1.0, 1.5, 2.0, 2.5]:
tot, sr, n = trade(z_kf, beta_kf, entry=ent)
print(f"Kalman,入场阈值 {ent}: 累计 {tot:+.3f},Sharpe {sr:5.2f},交易 {n} 次")
输出:
静态 OLS β=1.090;估计半衰期 7.9 天(真实 6.6);交易期价差 ADF p=0.353
Kalman:交易期 β 估计误差的均方根 0.070,静态 OLS 0.135
静态 OLS + OU 阈值 两年累计对数收益 -0.279,净 Sharpe -0.65,交易次数约 1
Kalman 预测误差 z 两年累计对数收益 +0.133,净 Sharpe 0.70,交易次数约 34
Kalman,入场阈值 1.0: 累计 +0.119,Sharpe 0.43,交易 73 次
Kalman,入场阈值 1.5: 累计 +0.133,Sharpe 0.70,交易 34 次
Kalman,入场阈值 2.0: 累计 -0.000,Sharpe -0.00,交易 6 次
Kalman,入场阈值 2.5: 累计 +0.013,Sharpe 0.83,交易 1 次
- 静态 OLS 在第 1 年的估计还算合理(β=1.09,半衰期 7.9 天对真实的 6.6 天),但交易期的价差已经不平稳(ADF p=0.35)。阈值策略在价差偏离后开仓,价差再也没回来,两年只做了一笔交易,亏损 28%。这是配对交易最典型的失败方式:关系破裂时,均值回复策略的仓位恰好开在错误的方向上,而且没有自然的出场点。必须有止损和最长持有期。
- Kalman 的对冲比误差(均方根 0.07)是静态的一半,策略做了 34 笔交易,净 Sharpe 0.70。
- 阈值敏感度:入场阈值 1.0 时交易 73 次、Sharpe 0.43(成本侵蚀),2.0 时只有 6 次,2.5 时只有 1 次——Sharpe 0.83 是一笔交易的结果,没有统计意义。阈值的选择要同时看交易次数,交易次数太少时任何 Sharpe 都不可信。
8.5 统计套利组合
8.5.1 从配对到组合
配对交易的问题是分散度不够:一对股票的关系破裂就是一次大亏损。统计套利把同样的思想推广到整个股票池:用因子模型(本册第 04 章)剥离每只股票的系统性收益,对特异收益的累计值建模,在它偏离均值时做反向交易,同时对冲因子暴露。Avellaneda 和 Lee(2010)给出了一个标准化的流程:
- 用过去 252 天收益的相关矩阵做 PCA,取前 \(K\) 个特征向量,按 \(v_{ij}/\sigma_i\) 构造特征组合(第 06 册第 09 章 9.4 节)。
- 对每只股票,在过去 60 天上把收益对特征组合收益回归,得到残差 \(\varepsilon_{i,t}\) 和因子暴露。
- 累计残差 \(X_{i,t}=\sum\varepsilon_{i,s}\) 视为 OU 过程,用 AR(1) 估计 \(m_i,\sigma_{\text{eq},i},\kappa_i\);只交易均值回复时间足够短(\(\kappa_i>252/30\))的股票。
- 计算 s-score \(s_i=(X_{i,t}-m_i)/\sigma_{\text{eq},i}\);\(s<-1.25\) 开多,\(s>1.25\) 开空,多头在 \(s>-0.5\) 时平仓,空头在 \(s<0.75\) 时平仓。
- 每只股票的仓位用因子暴露对冲,组合整体近似因子中性。
白话解释:五步流程逐步翻译。第 1 步,对 60 只股票的相关矩阵做特征分解,前 3 个特征向量是 3 个"最能解释共同波动"的方向,第一个通常近似市场组合。为什么权重是 \(v_{ij}/\sigma_i\)?因为 PCA 是在相关矩阵(标准化后的收益)上做的,第 \(i\) 只股票的标准化收益是 \(r_i/\sigma_i\),要把方向换算回真实资金权重,就得除以各自的波动。第 2 步,就是 CFA 里熟悉的多因子回归:把个股收益对这 3 个因子组合的收益回归,截距和残差就是"因子解释不了的部分"。第 3 步,把每日残差累加起来,得到一条"剔除了因子影响的个股价格",如果它均值回复,就用 AR(1) 估计回复速度,\(\kappa>252/30\) 等价于平均回复时间短于 30 个交易日(代码中写成 \(b<e^{-1/30}\approx0.967\))。第 4 步,s-score 就是这条累计残差的 \(z\) 分数;入场 \(\pm1.25\)、出场阈值不对称,是 Avellaneda–Lee 的经验设定。第 5 步,买入残差偏低的股票时,同时卖出相应数量的因子组合,组合对市场和行业的涨跌近似免疫。 所以统计套利本质上是"在多因子模型的残差上做配对交易":配对交易用一只股票对冲另一只,统计套利用因子组合对冲每一只。
第 07 册第 17 章从微观结构角度讨论了套利者的风险:模型风险、执行风险、以及在拥挤交易中被迫同时平仓的风险。
8.5.2 代码:PCA 残差 + s-score
代码 8.5 生成 60 只股票 6 年的日收益:3 个因子(GARCH 波动),特异对数价格中前 40 只为 OU(半衰期 3–15 天),后 20 只为随机游走。损益按"多/空残差、因子精确对冲"计算,成本按股票腿和对冲腿各计一次。
# 代码 8.5 统计套利组合:PCA 残差 + OU s-score(Avellaneda–Lee 式)
import numpy as np
rng = np.random.default_rng(31)
N, T, K = 60, 252 * 6, 3
# 收益 = 3 个因子 + 特异部分;特异对数价格中,2/3 的股票为 OU(半衰期 3–15 天),1/3 为随机游走
B = np.c_[rng.uniform(0.6, 1.4, N), rng.normal(0, 0.5, (N, 2))]
h = np.full(K, 1e-4); f = np.zeros((T, K))
for t in range(T): # 因子:GARCH(1,1) + t(5)
f[t] = np.sqrt(h) * rng.standard_t(5, K) / np.sqrt(5 / 3) * np.array([1.0, 0.6, 0.5])
h = 1e-6 + 0.07 * f[t] ** 2 / np.array([1.0, 0.36, 0.25]) + 0.92 * h
hl = rng.uniform(3, 15, N); phi = np.where(np.arange(N) < 40, 0.5 ** (1 / hl), 1.0)
Xi = np.zeros((T, N))
for t in range(1, T):
Xi[t] = phi * Xi[t - 1] + 0.012 * rng.standard_t(5, N) / np.sqrt(5 / 3)
R = f @ B.T + np.diff(np.r_[np.zeros((1, N)), Xi], axis=0) + 0.003 * rng.standard_normal((T, N))
W_pca, W_res = 252, 60
s_open, s_close_long, s_close_short = 1.25, -0.50, 0.75 # Avellaneda–Lee 的阈值
pos = np.zeros(N); pnl, turn, n_pos, by_group = [], [], [], []
Q = None
for t in range(W_pca, T - 1):
if (t - W_pca) % 21 == 0: # 每月重估一次特征组合
Rw = R[t - W_pca:t]; sd = Rw.std(0)
C = np.corrcoef(Rw, rowvar=False)
val, vec = np.linalg.eigh(C)
Q = vec[:, ::-1][:, :K] / sd[:, None] # 特征组合权重:v_j / σ_i
Fw = R[t - W_res:t] @ Q # 窗口内特征组合收益
Xd = np.c_[np.ones(W_res), Fw]
coef = np.linalg.lstsq(Xd, R[t - W_res:t], rcond=None)[0] # 60 只股票一起回归
eps = R[t - W_res:t] - Xd @ coef
X = np.cumsum(eps, axis=0) # 累计残差 ≈ 特异对数价格
x0, x1 = X[:-1], X[1:]
b = ((x0 - x0.mean(0)) * (x1 - x1.mean(0))).sum(0) / ((x0 - x0.mean(0)) ** 2).sum(0)
a = x1.mean(0) - b * x0.mean(0)
zeta = x1 - a - b * x0
ok = (b > 0) & (b < 0.967) # 均值回复时间 < 30 天才交易
m = a / (1 - b); s_eq = zeta.std(0) / np.sqrt(1 - b**2)
sc = np.where(ok, (X[-1] - m) / s_eq, 0.0)
new = pos.copy()
new[(pos == 0) & ok & (sc < -s_open)] = 1
new[(pos == 0) & ok & (sc > s_open)] = -1
new[(pos == 1) & ((sc > s_close_long) | ~ok)] = 0
new[(pos == -1) & ((sc < s_close_short) | ~ok)] = 0
# 次日损益:多/空残差,因子暴露用 beta 对冲(这里假设对冲按特征组合精确执行)
r_next = R[t + 1]; F_next = r_next @ Q
resid_next = r_next - coef[0] - F_next @ coef[1:]
w = new / N # 每个持仓 1/N 资金
by_group.append([w[:40] @ resid_next[:40], w[40:] @ resid_next[40:], np.abs(new[40:]).sum()])
pnl.append(w @ resid_next); turn.append(np.abs(new - pos).sum() / N); n_pos.append(np.abs(new).sum())
pos = new
pnl, turn = np.array(pnl), np.array(turn)
for c in [0, 5e-4, 1e-3, 2e-3]:
net = pnl - c * 2 * turn # 股票腿 + 对冲腿
print(f"单边成本 {c*1e4:4.0f}bp: 年化收益 {net.mean()*252:.3f},年化波动 {net.std()*np.sqrt(252):.3f},Sharpe {net.mean()/net.std()*np.sqrt(252):5.2f}")
print(f"平均持仓股票数 {np.mean(n_pos):.1f}/{N},日均换手 {turn.mean():.3f}")
g = np.array(by_group) # 损益来自哪里:真 OU 股票 vs 随机游走股票
print(f"毛损益年化:OU 股票 {g[:, 0].mean()*252:.3f},随机游走股票 {g[:, 1].mean()*252:.3f};"
f"持仓中随机游走股票占比 {g[:, 2].sum() / np.sum(n_pos):.1%}(它们占股票池的 1/3)")
输出:
单边成本 0bp: 年化收益 0.069,年化波动 0.015,Sharpe 4.75
单边成本 5bp: 年化收益 0.050,年化波动 0.015,Sharpe 3.46
单边成本 10bp: 年化收益 0.031,年化波动 0.015,Sharpe 2.17
单边成本 20bp: 年化收益 -0.006,年化波动 0.015,Sharpe -0.41
平均持仓股票数 18.2/60,日均换手 0.074
毛损益年化:OU 股票 0.068,随机游走股票 0.000;持仓中随机游走股票占比 40.1%(它们占股票池的 1/3)
- 毛 Sharpe 4.75 很高,这是因为我们放进了强而稳定的均值回复。真实市场中,Avellaneda 和 Lee 报告的这类策略在 2000 年代中期以后表现明显变弱,通常的解释是资金涌入后的拥挤。
- 成本敏感度极高:日均换手 7.4%(年换手约 19 倍),单边成本从 0 增加到 10bp,Sharpe 从 4.75 降到 2.17;到 20bp 时变为负值。统计套利的盈亏几乎完全由执行质量决定,本册第 07 章的内容在这里直接决定策略的生死。
- 收益来源的归因:随机游走股票占股票池的 1/3,却占持仓的 40%,它们贡献的毛损益是 0。"均值回复时间 < 30 天"的过滤在 60 天窗口上没能有效排除随机游走——60 个点估计的 AR(1) 系数噪声很大,随机游走经常被估计成"快速回复"。这些持仓不赚钱,却付出了一半的换手成本。改进方向有:用更长的窗口或贝叶斯收缩估计 \(b\);对每只股票的历史 s-score 交易表现做跟踪,淘汰长期无效的股票;或把 \(b\) 的估计不确定性纳入仓位大小。
8.5.3 实盘中的额外问题
- 因子对冲的执行:特征组合包含所有股票,实际对冲常用行业 ETF 或股指期货近似,带来残余的因子暴露。
- 融券:空头腿的融券可得性和费率,在 A 股市场是很强的约束。
- 事件:财报、并购、指数调整会造成特异收益的跳跃,均值回复假设在事件附近失效。常见做法是在事件窗口内不开新仓。
- 拥挤与去杠杆:同类策略的资金同时撤出时,残差会朝不利方向大幅偏离(2007 年 8 月美国量化基金的集体回撤是常被引用的例子)。仓位的风险预算要考虑这种同步平仓的尾部情形,本册第 11 章会讨论压力测试。
8.5.4 与组合构建的衔接
代码 8.5 用的是"每个信号等额持仓"的简化规则。更完整的做法是把 s-score 当作 alpha 信号(例如 \(\alpha_i\propto-s_i\cdot\sigma_{\text{eq},i}\cdot\kappa_i\),偏离越大、回复越快,预期收益越高),交给本册第 05 章的优化器:目标里有风险模型、因子中性约束和交易成本惩罚,优化器会自动在信号强度、风险和成本之间权衡,并在不交易区间内保持仓位不动。这样做的好处是成本和风险被统一处理,坏处是多了一层模型(alpha 的量纲、风险厌恶系数),需要用本册第 06 章的方法重新验证。无论采用哪种方式,信号、组合和执行三层用同一套成本假设,是统计套利能否在实盘中复现回测的关键。
8.6 常见陷阱与检查清单
陷阱
- 把模拟或单一样本里的效应当成稳定规律:动量、反转、均值回复的强度都随时间变化,并会因拥挤而衰减。
- 长尺度检验的功效很低:单个资产 15 年数据检验年度趋势几乎没有功效,汇总多个资产才可能看清,但汇总要考虑资产之间的相关。
- 只看预测精度选波动模型:策略层面的 Sharpe、换手和杠杆才是目标。
- 波动预测有前视:GARCH 用全样本参数、EWMA 未滞后。
- 配对筛选不做多重检验,或者做了多重检验但没有检查单腿的平稳性。
- 静态对冲比和没有止损:关系破裂时,均值回复策略会在错误方向上越套越深。
- 从极少的交易次数中得出结论。
- 忽略换手成本:统计套利的毛 Sharpe 看起来再高,也可能被 10–20bp 的成本完全吃掉。
- 残差模型把随机游走当作均值回复:短窗口的 AR(1) 估计噪声很大。
检查清单
- [ ] 效应检验:方差比、自相关或协整检验,样本外重做一次。
- [ ] 所有参数(回看期、阈值、窗口、\(Q/R\) 比)都计入试验数,报告参数敏感度。
- [ ] 波动预测、对冲比、OU 参数只用决策时刻之前的数据。
- [ ] 配对筛选:先用经济先验缩小候选,检查单腿 I(1),再做多重检验校正。
- [ ] 每笔交易有止损和最长持有期;监控价差的平稳性。
- [ ] 报告交易次数、平均持有期、换手和不同成本假设下的净 Sharpe。
- [ ] 做收益归因:收益来自哪些资产、哪些时段,是否集中在少数交易。
本章小结
- 方差比把多阶自相关汇总成"某个时间尺度上是趋势还是回复"的判断。合成数据里短期反转与长期趋势同时存在,回看期的选择决定了 TSMOM 是赚是亏。
- 单资产上检验不出来的弱趋势,可以在多资产 TSMOM 组合里显现,代价是要正确处理资产间相关和多重检验。
- 目标波动策略在期望收益不随波动同比上升时提高 Sharpe,改进幅度要用多条路径或长样本评估;波动预测越准不等于策略越好。
- 配对筛选的伪发现来自两方面:大量检验的运气,以及单腿看似平稳造成的结构性伪配对。经济先验和单腿 I(1) 检查与多重检验校正同样重要。
- OU/AR(1) 给出半衰期和 z 分数;对冲比会漂移,Kalman 滤波提供自适应的对冲比和标准化价差;没有止损的均值回复策略在关系破裂时损失最大。
- PCA 残差统计套利把配对思想推广到组合,盈亏对成本极其敏感,且短窗口的残差模型会把随机游走误判为均值回复。
练习
-
异方差稳健的方差比。 实现 Lo–MacKinlay 的异方差稳健 \(z^*(q)\) 统计量,在代码 8.1 的数据上与同方差版本比较。GARCH 波动下,哪个版本的拒绝率更接近名义水平?(提示:用无趋势、无反转的数据做模拟检验。)
-
跳过最近一个月。 横截面动量常常跳过最近一个月以避开短期反转。在代码 8.1 的
tsmom中设置skip=21,比较 63、126、252 日回看的 Sharpe。 -
波动择时与风险溢价。 修改代码 8.2 的数据生成过程,使期望收益与条件方差成正比(GARCH-M,第 06 册第 03a 章 3.7 节)。目标波动策略还有优势吗?期望收益与条件波动成正比时呢?
-
剔除伪配对的其他方法。 在代码 8.3 中,不剔除单腿平稳的股票,而是对两个方向(\(y\) 对 \(x\)、\(x\) 对 \(y\))都做 Engle–Granger 检验并要求都显著,或改用 Johansen 检验(第 06 册第 08b 章 8.9 节)。伪配对数量怎样变化?
-
止损与最长持有期。 在代码 8.4 的
trade函数中加入止损(\(|z|>4\) 时平仓)和最长持有期(3 倍半衰期),重新比较静态 OLS 和 Kalman 两种做法。 -
Kalman 的噪声比。 在代码 8.4 中把 \(Q/R\) 的比值从 \(10^{-3}\) 扫到 \(10\),画出交易次数和 Sharpe 的变化。然后用第 06 册第 11a 章 11.9 节的预测误差分解,用第 1 年的数据极大似然估计 \(Q\) 和 \(R\),看估计值落在哪里。
-
残差模型的改进。 在代码 8.5 中把残差窗口从 60 天改为 120 天,或对 AR(1) 系数做向 1 的收缩(例如 \(\tilde b=0.7\hat b+0.3\)),观察随机游走股票的持仓占比和净 Sharpe。
-
统计套利的容量。 给代码 8.5 加上本册第 06 章代码 6.3 的平方根冲击模型(假设每只股票 ADV 为 5000 万元),画出资金规模与年净利润的关系,找出容量。
延伸阅读
- 第 06 册第 02a 章(AR 模型、自相关检验)、第 02b 章(单位根)、第 03a 章与第 03b 章(GARCH、GJR、EWMA)、第 08b 章(协整、误差修正、配对交易)、第 09 章(主成分分析与因子模型)、第 11a 章与第 11b 章(Kalman 滤波、时变 β)。
- 第 05 册第 17b 章(单位根检验的非标准分布与协整)、第 17c 章(波动率聚集)。
- 第 03 册第 10b 章(多重检验与 BH)。
- 第 07 册第 17 章(套利者);第 08 册第 31 章(Vasicek/OU 过程)。
- 本册第 03 章(横截面因子)、第 04 章(风险模型)、第 05 章(成本惩罚)、第 06 章(回测与过拟合)、第 07 章(交易成本)、第 11 章(压力测试)。
- Engle, R., & Granger, C. (1987). Co-integration and Error Correction: Representation, Estimation, and Testing. Econometrica.
- Lo, A., & MacKinlay, C. (1988). Stock Market Prices Do Not Follow Random Walks: Evidence from a Simple Specification Test. Review of Financial Studies.
- Gatev, E., Goetzmann, W., & Rouwenhorst, K. G. (2006). Pairs Trading: Performance of a Relative-Value Arbitrage Rule. Review of Financial Studies.
- Avellaneda, M., & Lee, J.-H. (2010). Statistical Arbitrage in the U.S. Equities Market. Quantitative Finance.
- Moskowitz, T., Ooi, Y. H., & Pedersen, L. H. (2012). Time Series Momentum. Journal of Financial Economics.
- Moreira, A., & Muir, T. (2017). Volatility-Managed Portfolios. Journal of Finance.