4、价差计算与数据预处理:价差公式、对数价差、标准化处理、异常值处理、数据对齐
做跨品种套利,说白了就一件事——找价差。价差算不对,后面全白费。我刚开始做量化那会儿,就吃过这个亏。当时盯着两个品种的K线图,觉得价差走势挺漂亮,结果一跑回测,亏得底裤都不剩。后来才发现,是数据预处理没做好,价差公式选错了。
嗯,这节课咱们就把这块硬骨头啃下来。我会把价差计算的几种方式、数据怎么清洗、怎么对齐,都掰开揉碎了讲清楚。
4.1 价差公式:选对路子才能赚钱
价差不是简单的一减了事。不同品种的价格量级可能差很多,比如螺纹钢3000多一吨,铜却要60000多一吨。直接相减,铜的波动会把螺纹钢的波动完全吃掉。
我个人习惯把价差公式分成三类,你根据品种特性来选:
| 价差类型 | 公式 | 适用场景 | 我踩过的坑 |
|---|---|---|---|
| 简单价差 | S = P₁ - P₂ | 同品种不同合约、价格量级相近 | 螺纹钢和热卷看似相近,但价差会漂移 |
| 比例价差 | S = P₁ / P₂ | 价格量级差异大、有固定比价关系 | 豆粕和豆油,比值会随季节变化 |
| 对数价差 | S = ln(P₁) - ln(P₂) | 价格呈指数增长、波动率随时间变化 | 加密货币套利时特别好用 |
重点来了:对数价差有个隐藏好处——它天然做了标准化。ln(P₁) - ln(P₂) = ln(P₁/P₂),你想想看,这不就是比例价差的对数版本吗?而且它的波动率更稳定,做统计套利时,均值回归特性更明显。
我在项目中遇到过最典型的案例是螺纹钢和热卷的套利。这两个品种价格都在3000-5000之间,量级接近,用简单价差就行。但如果你做的是铜和铝的套利,铜6万,铝1万8,直接相减的话,价差走势基本就是铜的走势,铝的贡献微乎其微。这时候用对数价差或者比例价差更合适。
4.2 对数价差:为什么我偏爱它
说实话,我80%的跨品种套利策略都用对数价差。原因有三:
- 波动率稳定——价格涨到2倍和跌到一半,对数价差的变动幅度是对称的。简单价差做不到这点。
- 统计性质好——很多金融时间序列取对数后更接近正态分布,做假设检验时更靠谱。
- 协整关系更清晰——两个品种的价格序列可能非平稳,但它们的对数价差往往是平稳的。这就是协整套利的理论基础。
代码实现也很简单:
import numpy as np
import pandas as pd
# 假设df有两列:price1, price2
df['log_price1'] = np.log(df['price1'])
df['log_price2'] = np.log(df['price2'])
df['log_spread'] = df['log_price1'] - df['log_price2']
# 你也可以一步到位
df['log_spread'] = np.log(df['price1'] / df['price2'])
小技巧:如果价格出现0或负数(比如某些期货合约有跌停价),取对数会报错。我一般先加一个极小值,比如1e-10,或者直接用np.log1p()处理。
4.3 标准化处理:让价差可比较
算完价差,你会发现不同时间段的价差均值可能不一样。比如螺纹钢和热卷的价差,2018年均值是50,2020年均值变成了150。这时候直接拿原始价差做交易,阈值根本没法设。
标准化就是解决这个问题的。我常用的方法有两种:
| 方法 | 公式 | 特点 |
|---|---|---|
| Z-score | z = (x - μ) / σ | 假设数据正态分布,均值0,标准差1 |
| Min-Max | z = (x - min) / (max - min) | 映射到[0,1]区间,对异常值敏感 |
我个人习惯用Z-score。为什么?因为套利策略的核心是均值回归,Z-score天然告诉你当前价差偏离均值多少个标准差。当z=2时,意味着价差偏离了2个标准差,这时候开仓做回归,胜率很高。
# Z-score标准化
mean_spread = df['log_spread'].rolling(window=60).mean()
std_spread = df['log_spread'].rolling(window=60).std()
df['z_score'] = (df['log_spread'] - mean_spread) / std_spread
# 注意:滚动窗口选多大?
# 我一般用60期(日线就是60个交易日,约3个月)
# 太短了噪声大,太长了反应迟钝
注意:标准化一定要用滚动窗口,不能用全量数据。否则你会引入未来信息,回测结果全是假的。我曾经犯过这个错,回测年化30%,实盘直接亏了3个月才反应过来。
4.4 异常值处理:别让一颗老鼠屎坏了一锅粥
数据里总会有一些「惊喜」——交易所数据推送错误、网络延迟导致的跳空、节假日前后的大幅波动。这些异常值如果不处理,价差序列会被严重扭曲。
我常用的异常值检测方法:
- 3σ原则——超过均值±3个标准差的,直接剔除或替换
- IQR方法——低于Q1-1.5IQR或高于Q3+1.5IQR的,视为异常
- MAD方法——中位数绝对偏差,对极端值更鲁棒
处理方式我建议用「截断」而不是「删除」。删除会导致数据不连续,影响后续的滚动计算。截断就是把异常值拉到边界值:
def winsorize_series(series, limits=(0.01, 0.99)):
"""截断处理:把极端值拉到分位数边界"""
lower = series.quantile(limits[0])
upper = series.quantile(limits[1])
return series.clip(lower, upper)
# 应用
df['z_score_clean'] = winsorize_series(df['z_score'])
避坑指南:我曾经用3σ原则处理异常值,结果发现某些品种的价差天生就是厚尾分布,3σ会误删很多正常数据。后来我改用MAD方法,效果好了很多。记住,没有万能的方法,多试试。
4.5 数据对齐:时间戳对不上,一切都是白搭
跨品种套利最头疼的问题之一,就是两个品种的交易时间不一致。比如螺纹钢夜盘到23:00,铜夜盘到次日01:00。如果你不做对齐,价差在23:00到01:00这段时间里,只有一个品种有数据,算出来的价差全是错的。
数据对齐的核心原则:只保留两个品种都有数据的时刻。
具体做法:
- 把两个品种的数据都按相同频率重采样(比如1分钟、5分钟、日线)
- 用inner join合并,只保留共同的时间戳
- 检查是否有缺失值,如果有,用前向填充或插值
# 假设df1和df2分别是两个品种的1分钟数据
# 索引都是datetime类型
# 方法1:直接合并,只保留共同时间
df_combined = pd.merge(df1, df2, left_index=True, right_index=True, how='inner')
# 方法2:先重采样再合并
df1_resampled = df1.resample('5T').last() # 5分钟K线
df2_resampled = df2.resample('5T').last()
df_combined = pd.merge(df1_resampled, df2_resampled,
left_index=True, right_index=True, how='inner')
# 检查缺失值
print(df_combined.isnull().sum())
# 前向填充(用上一个有效值填充)
df_combined.fillna(method='ffill', inplace=True)
警告:千万别用outer join!否则你会得到大量只有单边数据的时刻,价差计算会完全失真。我见过有人这么干,回测曲线漂亮得不行,实盘一跑就崩——因为那些「漂亮」的价差全是虚构的。
4.6 本章知识体系总览
下面这张图,是我自己梳理的价差计算与数据预处理的完整流程。你照着这个顺序做,基本不会出大问题。
这张图里,我特意把「价差计算」这一步做了分支。你看到了吗?三个价差公式是并列的,选哪个取决于你的品种特性。但不管选哪个,后面的异常值处理和标准化都是必经之路。
好了,价差计算和数据预处理这块,核心内容就这些。记住一句话:数据预处理花的时间,会在策略表现上十倍百倍地还回来。别急着跑策略,先把数据搞干净。