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 价差序列构建的完整流程

下面这张图,是我自己总结的价差序列构建流程,你可以把它当作一个检查清单:

价差序列构建流程图 获取价格数据 P1, P2 日线/分钟线 滚动协整回归 估计 β 和 α 计算价差 Spread = P1 - β×P2 - α 标准化 Z-Score 滚动均值和标准差 设定交易阈值 开仓线 ±2.0,平仓线 ±0.5 生成交易信号 开仓/平仓/止损 回测验证与参数优化 调整窗口大小、阈值参数,检查收益曲线和最大回撤

这张图把整个流程串起来了。你从左上角开始,一步步往下走,最后到回测验证。如果回测结果不理想,就回到前面调整参数,形成一个闭环。

我个人觉得,价差序列构建是整个统计套利策略中最关键的一环。配对选得再好,如果价差构建出了问题,后面全是白搭。所以,我建议你多花点时间在这上面,把每一步的逻辑都搞清楚。

好了,价差序列构建就讲到这里。记住:价差是基础,Z-Score是工具,阈值是规则。三者缺一不可。