4、价差序列构建:从原始价格到交易信号
好,咱们进入最核心的一步了。前面我们讲了配对选择、协整检验,说白了都是在为这一步做准备。价差序列构建,就是把两个资产的价格关系,变成一个可以交易的信号。
我个人习惯把这一步叫做「翻译」——把市场语言翻译成交易语言。你想想看,两个股票的价格本身是杂乱无章的,但它们的价差,却可能藏着规律。
4.1 什么是价差(Spread)?
价差,就是两个资产价格之间的差值。但这里有个坑——如果两个资产价格不在一个量级上,直接相减就没意义了。
举个例子:茅台一股2000块,五粮液一股200块。直接算价差是1800,这个数字能用来交易吗?显然不能。因为价差波动完全被茅台的价格主导了。
所以,我们通常用两种方式构建价差:
- 简单价差:Spread = P1 - P2(仅适用于价格量级相近的品种)
- 回归价差:Spread = P1 - β × P2(β来自协整回归,消除量级差异)
我在项目中遇到过不少新手,上来就用简单价差做统计套利,结果回测曲线漂亮得不行,实盘一跑就崩。为什么?因为简单价差忽略了两个资产之间的「弹性关系」。说白了,β就是那个弹性系数。
回归价差 = P1 - β × P2 - α
其中β和α来自协整回归的系数。
4.2 价差的标准化——Z-Score
有了价差序列,下一步就是标准化。为什么要标准化?因为价差的绝对值没有意义,你得知道它偏离均值多少个标准差,才能判断是不是「极端」了。
Z-Score的计算很简单:
Z = (当前价差 - 价差均值) / 价差标准差
嗯,这里要注意:均值和标准差要用滚动窗口计算,还是用全样本?我个人建议用滚动窗口。全样本会引入未来信息,回测时看着漂亮,实盘就露馅了。
我曾经犯过这个错误。2018年做回测时,用全样本算Z-Score,年化收益做到30%,回撤不到5%。结果实盘跑了三个月,亏了8%。后来一查,原来是全样本均值包含了未来的数据,把当前价差「拉」回了均值附近。说白了,就是作弊了。
我一般用60个交易日(约3个月)作为滚动窗口。太短了噪声大,太长了反应迟钝。你可以根据品种的波动特性调整,但别低于20,也别超过120。
4.3 交易阈值——开仓与平仓线
Z-Score算出来了,那什么时候开仓?什么时候平仓?这就涉及到阈值设定。
常见的阈值设定方式有两种:
| 阈值类型 | 典型值 | 说明 |
|---|---|---|
| 开仓线 | |Z| > 2.0 | 价差偏离均值2个标准差以上,认为出现套利机会 |
| 平仓线 | |Z| < 0.5 | 价差回归到均值附近,平仓获利了结 |
| 止损线 | |Z| > 3.0 | 价差继续扩大,说明配对可能失效,强制止损 |
你可能会问:为什么是2.0和0.5?其实这不是绝对的。我见过有人用1.5和0.3,也有人用2.5和0.8。关键要看你的品种波动特性和风险偏好。
我个人习惯先跑一遍历史数据,看看Z-Score的实际分布。如果大部分时间Z-Score都在±1.5以内,那开仓线设在2.0就偏保守了,可能一年都开不了几次仓。反过来,如果Z-Score经常跑到±3.0,那2.0的开仓线就太容易触发,交易成本会吃掉利润。
我曾经在螺纹钢和热卷的配对中,用了2.0的开仓线,结果一个月触发了20次开仓,手续费把利润全吃光了。后来改成2.5,频率降下来了,收益反而上去了。所以阈值不是越大越好,也不是越小越好,得根据品种特性来调。
4.4 构建价差序列的Python实现
好了,理论说完了,咱们上代码。下面是一个完整的价差序列构建流程:
import pandas as pd
import numpy as np
import statsmodels.api as sm
def build_spread_series(price1, price2, window=60):
"""
构建回归价差序列
参数:
price1: 资产1的价格序列
price2: 资产2的价格序列
window: 滚动窗口大小(默认60天)
返回:
spread: 价差序列
zscore: 标准化后的Z-Score序列
"""
# 第一步:滚动协整回归,计算动态β
betas = []
alphas = []
for i in range(window, len(price1)):
y = price1[i-window:i]
x = price2[i-window:i]
x = sm.add_constant(x)
model = sm.OLS(y, x).fit()
alphas.append(model.params[0])
betas.append(model.params[1])
# 第二步:计算价差
betas = pd.Series(betas, index=price1.index[window:])
alphas = pd.Series(alphas, index=price1.index[window:])
spread = price1[window:] - betas * price2[window:] - alphas
# 第三步:计算滚动Z-Score
spread_mean = spread.rolling(window=window).mean()
spread_std = spread.rolling(window=window).std()
zscore = (spread - spread_mean) / spread_std
return spread, zscore
# 使用示例
# spread, zscore = build_spread_series(price_a, price_b, window=60)
# 生成交易信号
def generate_signals(zscore, entry_threshold=2.0, exit_threshold=0.5):
"""
根据Z-Score生成交易信号
返回:
1: 做多价差(买入P1,卖出P2)
-1: 做空价差(卖出P1,买入P2)
0: 空仓
"""
signals = pd.Series(0, index=zscore.index)
# 开仓条件
signals[zscore > entry_threshold] = -1 # 价差过高,做空
signals[zscore < -entry_threshold] = 1 # 价差过低,做多
# 平仓条件:回归到均值附近
signals[abs(zscore) < exit_threshold] = 0
return signals
这段代码我用了滚动窗口的方式,每次重新估计β和α。这样做的好处是能捕捉到两个资产关系的动态变化。比如,当公司基本面发生变化时,β可能会漂移,滚动窗口能自动适应。
不过要注意:滚动窗口的计算量比较大。如果你做全市场扫描,几百个配对同时跑,建议用向量化操作或者并行计算。我当年用纯循环跑500个配对,跑了整整一个周末才出结果。
4.5 价差序列构建的完整流程
下面这张图,是我自己总结的价差序列构建流程,你可以把它当作一个检查清单:
这张图把整个流程串起来了。你从左上角开始,一步步往下走,最后到回测验证。如果回测结果不理想,就回到前面调整参数,形成一个闭环。
我个人觉得,价差序列构建是整个统计套利策略中最关键的一环。配对选得再好,如果价差构建出了问题,后面全是白搭。所以,我建议你多花点时间在这上面,把每一步的逻辑都搞清楚。
好了,价差序列构建就讲到这里。记住:价差是基础,Z-Score是工具,阈值是规则。三者缺一不可。