4. 构建价差序列:从价格到价差,如何标准化价差(Z-Score)并设定交易阈值

好,咱们进入正题。

前面我们聊了配对选择,也讲了协整检验。但说实话,那些都只是准备工作。真正让策略跑起来的,是价差序列的构建和标准化。我见过不少新手,协整检验做得漂漂亮亮,结果一上实盘就亏钱——问题就出在价差处理上。

4.1 从价格到价差:这一步没那么简单

很多人以为价差就是A价格减B价格。嗯,理论上没错。但实际做起来,坑不少。

先看最基础的形式:

# 简单价差
spread = price_A - price_B

这有什么问题?你想想看,如果A是茅台,B是五粮液,价格差几百块,这个价差序列的波动范围会很大。而且随着时间推移,股票本身会除权除息,价格中枢会漂移。直接用原始价差,你会发现阈值根本没法设——今天价差在10块,明天可能就跳到50块了。

我个人习惯用对数价差。为什么?因为对数价格更接近正态分布,而且对数价差天然就是百分比关系,不受绝对价格影响。

# 对数价差(我推荐的方式)
import numpy as np
import pandas as pd

log_price_A = np.log(price_A)
log_price_B = np.log(price_B)
spread = log_price_A - log_price_B

还有一种方式是用回归残差。如果你做了OLS回归,残差本身就是一种价差:

# 回归残差作为价差
import statsmodels.api as sm

X = sm.add_constant(price_B)
model = sm.OLS(price_A, X).fit()
spread = model.resid

这三种方式各有适用场景。简单价差适合同行业、价格接近的品种。对数价差适合大多数股票对。回归残差适合协整关系比较弱的配对。

我的经验: 做A股配对交易,我一般先用对数价差。如果效果不好,再试回归残差。简单价差我基本不用——除非是ETF和它的成分股这种特殊情况。

4.2 标准化价差:Z-Score 到底在做什么?

价差序列建好了,但它的数值本身没有意义。你需要知道:当前价差偏离均值有多远?这个偏离是正常的还是异常的?

Z-Score 就是干这个的。它的公式很简单:

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

说白了,Z-Score 告诉你当前价差距离均值有几个标准差。Z=2 意味着价差偏离均值两个标准差,这在正态分布下发生的概率只有约5%。

代码实现:

def calculate_zscore(spread, window=20):
    """
    计算滚动Z-Score
    window: 滚动窗口,我一般用20天
    """
    mean = spread.rolling(window=window).mean()
    std = spread.rolling(window=window).std()
    zscore = (spread - mean) / std
    return zscore

这里有个关键点:窗口大小怎么选?

我踩过这个坑。有一次我用60天窗口,结果Z-Score反应特别迟钝,价差都回归了,信号还没出来。后来改成20天,效果好很多。

窗口大小 特点 适用场景
10-15天 敏感,信号多,但假信号也多 高频配对、日内交易
20-30天 平衡,我最常用 日线级别配对交易
40-60天 稳定,信号少 长周期、低换手策略
注意: 窗口太短,Z-Score会频繁穿越阈值,交易成本吃不消。窗口太长,你可能错过最佳入场点。我建议先用20天做回测,再根据结果调整。

4.3 交易阈值:到底设多少合适?

Z-Score 算出来了,接下来就是设定阈值。阈值决定了你什么时候开仓、什么时候平仓。

经典设置是这样的:

  • 开仓阈值: Z-Score 达到 ±2 时开仓
  • 平仓阈值: Z-Score 回归到 0 附近时平仓
  • 止损阈值: Z-Score 达到 ±3 时止损

为什么是2和3?因为正态分布下,Z=2的概率约5%,Z=3的概率约0.3%。这意味着你捕捉的是小概率事件,赌它回归均值。

但实际做起来,你会发现市场并不完全服从正态分布。价差序列往往有尖峰厚尾的特征。所以阈值需要调整。

# 完整的信号生成逻辑
def generate_signals(zscore, entry_threshold=2.0, exit_threshold=0.5, stop_threshold=3.0):
    """
    生成交易信号
    """
    signals = pd.Series(index=zscore.index, data=0)
    
    # 开仓信号
    signals[zscore >= entry_threshold] = -1  # 做空价差
    signals[zscore <= -entry_threshold] = 1  # 做多价差
    
    # 平仓信号:回归到均值附近
    signals[abs(zscore) <= exit_threshold] = 0
    
    # 止损信号
    signals[abs(zscore) >= stop_threshold] = 0
    
    return signals
核心思路: 阈值不是拍脑袋定的。我建议你先用历史数据做优化,找到夏普比率最高的阈值组合。但注意,别过度拟合——样本外测试才是王道。

4.4 避坑指南:我踩过的三个坑

做价差标准化,有几个坑我反复踩过。分享出来,你们别走弯路。

坑一:忘记更新均值和标准差

价差的均值和标准差不是一成不变的。随着时间推移,股票的基本面会变,价差的统计特征也会漂移。我建议用滚动窗口,而不是固定全样本。

# 错误做法:用全样本均值
full_mean = spread.mean()  # 别这么干!

# 正确做法:滚动均值
rolling_mean = spread.rolling(20).mean()

坑二:阈值设得太死板

我曾经在某个品种上设了±2的阈值,回测效果很好。结果换到另一个品种,完全失效。为什么?因为不同品种的价差波动性不同。我后来改用动态阈值——根据近期波动率调整。

# 动态阈值示例
def dynamic_threshold(zscore, lookback=60):
    recent_vol = zscore.rolling(lookback).std()
    threshold = 2.0 * (recent_vol / recent_vol.mean())
    return threshold

坑三:忽略交易成本

Z-Score 在±1.8附近来回震荡时,你会频繁开平仓。每次交易都有成本。我建议加一个"信号确认"机制——连续两天Z-Score超过阈值才开仓。

4.5 知识体系总览

下面这张图,把价差构建到阈值设定的整个流程串起来了。建议保存下来,做策略时对照着看。

价差序列构建与标准化流程 价格序列 A & B 价差构建方式 ① 简单价差:A - B ② 对数价差:ln(A) - ln(B) ← 推荐 Z-Score 标准化 Z = (价差 - 均值) / 标准差 交易阈值设定 开仓:|Z| ≥ 2.0 | 平仓:|Z| ≤ 0.5 | 止损:|Z| ≥ 3.0 ⚠ 建议使用滚动窗口 + 动态阈值 关键参数 • 滚动窗口:20天 • 开仓阈值:±2.0 • 平仓阈值:±0.5 • 止损阈值:±3.0 • 动态调整! 图:价差构建到阈值设定的完整流程

嗯,到这里,价差序列的构建和标准化就讲完了。记住一句话:Z-Score 是你的信号灯,阈值是你的红绿灯。灯亮了不一定就要冲,还得看看路况——也就是市场环境。

一个小技巧: 我每次上线新策略,都会先用Z-Score画个图。看看历史上有多少次突破±2,回归速度怎么样。如果突破次数太少(一年不到5次),说明阈值设高了,或者这对品种不适合做均值回归。

下一节,我们会把这些信号变成真正的交易指令。到时候你就知道,信号生成只是开始,资金管理才是重头戏。

专注资料整理