3、价差序列构建:如何计算配对价差、标准化处理(Z-score)、价差的统计特征

好,咱们接着聊。上一章我们把配对选好了,股票对也敲定了。那接下来干嘛?

说白了,就是要把这对股票的“价差”给算出来。

你想想看,配对交易的核心逻辑是什么?就是赌它们的价差会回归均值。所以,价差序列就是我们的交易信号源。如果价差算错了,后面所有策略都是白搭。

我个人习惯把这一步叫做“数据精加工”。原始价格数据就像刚挖出来的矿石,价差序列才是提炼后的黄金。

3.1 配对价差的计算方法

价差怎么算?最直接的想法就是:A股票价格减去B股票价格。但这里有个坑——两只股票的价格量级可能差很多。

举个例子,茅台一股2000块,五粮液一股200块。直接相减,价差波动几乎全被茅台带着走。这就不对了。

所以,我们通常用两种方式来计算价差:

  1. 价格差(Price Spread)spread = P_A - β * P_B
  2. 对数价差(Log Spread)spread = ln(P_A) - β * ln(P_B)

这里的β,就是我们在上一章用协整回归算出来的那个系数。它解决了量级不匹配的问题。

核心要点:

β 的作用是“对齐”两只股票的价格尺度。没有β,价差序列就是扭曲的。

我在项目中遇到过一种情况:有人直接用价格差,没加β。结果回测曲线漂亮得不行,实盘一跑就崩。为什么?因为β会随时间漂移,你不加它,相当于无视了股票之间的真实比例关系。

下面这段代码,是我常用的价差计算方式:

import numpy as np
import pandas as pd
import statsmodels.api as sm

def calculate_spread(price_A, price_B, method='log'):
    """
    计算配对价差
    :param price_A: 股票A的价格序列
    :param price_B: 股票B的价格序列
    :param method: 'log' 或 'price'
    :return: spread 序列, beta 系数
    """
    if method == 'log':
        # 对数价差,更稳定
        log_A = np.log(price_A)
        log_B = np.log(price_B)
        # OLS回归求beta
        X = sm.add_constant(log_B)
        model = sm.OLS(log_A, X).fit()
        beta = model.params.iloc[1]  # 注意pandas版本差异
        spread = log_A - beta * log_B
    else:
        # 价格差,适合量级相近的股票
        X = sm.add_constant(price_B)
        model = sm.OLS(price_A, X).fit()
        beta = model.params.iloc[1]
        spread = price_A - beta * price_B
    
    return spread, beta

嗯,这里要注意:我习惯用对数价差。为什么?因为对数化之后,价差序列更接近正态分布,统计性质更好。你想想看,金融数据大多是指数增长的,取个对数就把趋势变成了线性,处理起来顺手很多。

3.2 标准化处理:Z-score 的妙用

价差算出来了,但问题又来了——这个价差的数值本身没有意义。它可能是0.5,也可能是50,取决于股票的价格水平。

我们需要一个统一的标尺,来判断当前价差是“偏高”还是“偏低”。

这时候,Z-score 就登场了。

Z-score 公式:

Z = (当前价差 - 价差均值) / 价差标准差

Z-score 告诉我们:当前价差偏离均值多少个标准差。通常来说:

  • |Z| > 2:价差严重偏离,可以考虑开仓
  • |Z| < 0.5:价差在均值附近,考虑平仓
  • |Z| > 3:极端情况,小心反转或趋势改变

我的经验:

Z-score 的窗口期选择很关键。我一般用60个交易日作为滚动窗口。太短了,均值不稳定;太长了,反应迟钝。你可以根据股票的交易频率调整,但60天是个不错的起点。

我曾经犯过一个错误:用全量历史数据算Z-score。结果呢?五年前的数据把均值拉偏了,导致开仓信号迟迟不出来。后来改成滚动窗口,信号就灵敏多了。

代码实现也很简单:

def calculate_zscore(spread, window=60):
    """
    计算滚动Z-score
    :param spread: 价差序列
    :param window: 滚动窗口大小
    :return: Z-score 序列
    """
    rolling_mean = spread.rolling(window=window).mean()
    rolling_std = spread.rolling(window=window).std()
    zscore = (spread - rolling_mean) / rolling_std
    return zscore

你看,就这几行代码。但背后的逻辑,值得你反复琢磨。

3.3 价差的统计特征分析

价差序列建好了,Z-score也算出来了。但别急着跑策略。我们得先看看这个价差到底“长什么样”。

我一般会从四个维度去分析:

统计指标 含义 理想值
均值 价差的长期中心位置 接近0(标准化后)
标准差 价差的波动幅度 越小越好,但需适中
偏度 分布是否对称 接近0
峰度 尾部厚度 接近3(正态分布)
自相关性 价差是否依赖过去值 低自相关,最好白噪声

说白了,我们想要的是一个“稳定、对称、低自相关”的价差序列。如果偏度很大,说明价差经常往一个方向偏,那配对可能有问题。如果自相关性高,说明价差有趋势,不适合做均值回归策略。

避坑指南:

我曾经遇到一个价差序列,看起来完美——均值0,标准差稳定。但自相关系数高达0.8。这意味着什么?价差一旦偏离,就会持续偏离,根本不会回归。这种序列做配对交易,必亏。

所以,一定要检查自相关函数(ACF)。如果前几阶自相关显著不为0,这个配对就要重新考虑了。

下面是我常用的统计特征分析代码:

def spread_statistics(spread):
    """
    计算价差的统计特征
    """
    from scipy import stats
    
    stats_dict = {
        '均值': np.mean(spread),
        '标准差': np.std(spread),
        '偏度': stats.skew(spread.dropna()),
        '峰度': stats.kurtosis(spread.dropna()),
        'JB检验p值': stats.jarque_bera(spread.dropna())[1],
        'ADF检验p值': sm.tsa.stattools.adfuller(spread.dropna())[1]
    }
    
    # 自相关检验(前5阶)
    acf_values = sm.tsa.stattools.acf(spread.dropna(), nlags=5)
    for i, val in enumerate(acf_values[1:], 1):
        stats_dict[f'自相关_{i}阶'] = val
    
    return stats_dict

嗯,这里我加了一个JB检验和ADF检验。JB检验看正态性,ADF检验看平稳性。这两个通过了,价差序列才算合格。

3.4 知识体系总览

说了这么多,我画了一张图,帮你把整个流程串起来:

价差序列构建知识体系 ① 价差计算 价格差 / 对数价差 OLS回归求β系数 ② Z-score标准化 滚动均值 & 滚动标准差 开仓/平仓阈值设定 ③ 统计特征 均值/标准差 偏度/峰度/自相关 关键检查点:平稳性检验(ADF)→ 正态性检验(JB)→ 自相关检验(ACF) 常见问题 • β系数不稳定 → 滚动更新β • Z-score阈值太窄 → 放宽到2.5 • 价差有趋势 → 检查协整关系 • 自相关过高 → 考虑其他配对 最佳实践 • 滚动窗口60天作为默认值 • 对数价差优先于价格差 • 每次开仓前重新计算β • 结合多个统计指标综合判断

这张图把整个流程分成了三步:计算价差、标准化、分析统计特征。每一步都有对应的检查点。你照着这个流程走,基本不会出大问题。

最后说一句:价差序列是配对交易的灵魂。花再多时间在这上面都不为过。我见过太多人,选了对就急着下单,结果价差本身就有问题,亏了钱还找不到原因。

先把价差搞明白,再谈策略。这是铁律。


公众号:蓝海资料掘金营,微信deep3321