4、配对交易实战:价差序列计算、Z-score标准化、交易信号生成
好,咱们直接进入正题。
前面聊了那么多理论,什么协整检验、什么均值回归,听起来都挺美。但理论归理论,真到了写代码的时候,你会发现——嗯,坑还挺多的。
这一章,我就带你手把手把配对交易的整个流程跑一遍。从拿到两个股票的价格序列开始,到最终生成买卖信号,每一步我都会拆开来讲。代码我会贴出来,但更重要的是背后的逻辑——为什么这么做?不这么做会怎样?
4.1 价差序列的计算
配对交易的核心,说白了就是赌两个股票的价格差会回归均值。那第一步,自然就是把这个「价差」算出来。
价差不是简单的价格相减。你想想看,如果A股票100块,B股票50块,直接相减得到50,这有意义吗?没意义。因为它们的价格尺度不一样。
正确的做法是:先做线性回归,找到两个股票之间的均衡关系。然后用这个关系去算价差。
具体来说,假设我们找到了一对协整的股票,比如浦发银行和招商银行。我们用浦发银行的价格去回归招商银行的价格,得到回归系数β。那么价差就是:
spread = price_A - β * price_B
这个价差,才是真正有统计意义的「偏离程度」。
核心要点:价差序列必须基于协整关系计算,而不是简单相减。否则你看到的「机会」可能只是幻觉。
我在项目中遇到过一件事。有次我直接用价格差去做回测,结果发现信号特别多,但一实盘就亏钱。后来一查,原来是两只股票的价格尺度差太多,价差序列根本不平稳。从那以后,我再也不敢偷懒了。
代码实现其实很简单:
import numpy as np
import pandas as pd
import statsmodels.api as sm
# 假设 df_A 和 df_B 是两只股票的价格序列
def calculate_spread(df_A, df_B):
# 做线性回归,得到 beta
X = sm.add_constant(df_B)
model = sm.OLS(df_A, X).fit()
beta = model.params[1]
# 计算价差
spread = df_A - beta * df_B
return spread, beta
这里要注意一点:回归用的数据窗口要多长?我个人习惯用60个交易日,也就是大概3个月。太短了,回归结果不稳定;太长了,股票的基本面可能已经变了。
小技巧:你可以试试滚动回归,也就是每天重新算一次beta。这样能捕捉到两只股票关系的动态变化。不过计算量会大一些。
4.2 Z-score标准化
好,价差算出来了。但问题来了——价差的数值本身没有标准。今天价差是2,明天价差是5,这算大还是小?
这就需要标准化了。
Z-score,说白了就是把价差转换成「偏离均值多少个标准差」。公式很简单:
Z = (当前价差 - 价差均值) / 价差标准差
当Z=2的时候,意味着当前价差已经偏离均值2个标准差。按照正态分布,这种情况发生的概率只有5%左右。嗯,这就是我们等待的「极端机会」。
代码实现:
def calculate_zscore(spread, window=60):
# 滚动计算均值和标准差
mean = spread.rolling(window=window).mean()
std = spread.rolling(window=window).std()
# 计算Z-score
zscore = (spread - mean) / std
return zscore
这里有个细节很多人会忽略:窗口大小怎么选?
窗口太小,比如20天,Z-score会频繁触发信号,但很多都是假信号。窗口太大,比如120天,信号会变得稀少,可能一年都等不到几次交易机会。
我个人的经验是:60天是个不错的起点。你可以根据股票的波动特性去调,波动大的股票用短窗口,波动小的用长窗口。
避坑指南:我曾经犯过一个错误——直接用全样本的均值和标准差去算Z-score。结果回测曲线漂亮得不行,一实盘就崩了。为什么?因为全样本包含了未来的信息,这叫「未来函数」。记住,Z-score必须用滚动窗口,只用历史数据。
4.3 交易信号生成
有了Z-score,信号生成就水到渠成了。
典型的规则是这样的:
- 当Z-score > 2.0 时,做空价差(卖A买B)
- 当Z-score < -2.0 时,做多价差(买A卖B)
- 当Z-score 回归到 0 附近时,平仓
为什么是2.0?这其实是个经验值。理论上,Z-score超过2的概率只有5%,这意味着我们只捕捉最极端的偏离。如果你觉得信号太少,可以放宽到1.5;如果你怕假信号多,可以收紧到2.5。
代码实现:
def generate_signals(zscore, entry_threshold=2.0, exit_threshold=0.5):
signals = pd.Series(index=zscore.index, dtype=int)
signals[:] = 0 # 0表示空仓
position = 0 # 当前持仓状态:1=多头,-1=空头,0=空仓
for i in range(1, len(zscore)):
if position == 0:
# 开仓条件
if zscore.iloc[i] > entry_threshold:
position = -1 # 做空价差
elif zscore.iloc[i] < -entry_threshold:
position = 1 # 做多价差
else:
# 平仓条件:回归到均值附近
if abs(zscore.iloc[i]) < exit_threshold:
position = 0
signals.iloc[i] = position
return signals
你可能会问:为什么平仓阈值设成0.5,而不是0?
嗯,这里有个小门道。如果设成0,意味着你必须等到价差完全回归均值才平仓。但现实中,价差往往会在均值附近来回震荡,你可能永远等不到精确的0。设成0.5,相当于给了个「缓冲区」,让交易更平滑。
重要提醒:信号生成只是第一步。真正赚钱的关键在于——你能否严格执行信号。很多人在Z-score达到2.5的时候不敢开仓,觉得「这次不一样」。结果呢?往往就是这次「不一样」的行情,最后回归得最猛烈。
4.4 完整流程可视化
说了这么多,咱们用一张图把整个流程串起来:
这张图把整个流程串起来了。从数据获取到信号生成,每一步都环环相扣。你想想看,如果哪一步出了问题,后面的信号质量都会受影响。
4.5 实战中的注意事项
最后,我总结几个实战中容易踩的坑:
| 问题 | 表现 | 解决方案 |
|---|---|---|
| 价差不平稳 | 信号频繁触发,但无法回归 | 重新做协整检验,换配对 |
| Z-score窗口太小 | 信号太多,交易成本高 | 增大窗口到80-100天 |
| Z-score窗口太大 | 信号太少,错过机会 | 减小窗口到40-50天 |
| 未来函数 | 回测漂亮,实盘亏损 | 严格使用滚动窗口 |
| 忽略交易成本 | 回测盈利,实盘亏损 | 每笔交易扣除0.1%成本 |
我的建议:刚开始做配对交易,别急着上实盘。先用历史数据跑一遍回测,看看信号的质量。重点关注两个指标:信号频率(一年多少次)和胜率(赚钱的比例)。如果一年只有5次信号,那这个策略可能太保守了;如果胜率低于60%,那可能需要调整参数。
好了,这一章的内容就到这里。代码有了,逻辑清楚了,剩下的就是动手去试。记住,量化交易不是纸上谈兵,代码跑起来才是硬道理。
公众号:蓝海资料掘金营,微信deep3321