14、时间序列分解:趋势、季节、残差,信号提取
做高频交易的人,每天面对的就是一堆价格数据。说白了,价格波动里藏着三种东西:趋势、季节、残差。嗯,这就像做菜,你得把食材、调料、火候分开来看,才能做出好菜。
我个人习惯,拿到一个高频序列,第一件事就是做分解。为什么?因为你不拆开看,就不知道哪些是真正的交易信号,哪些只是市场噪音。我见过太多人,拿着一个带趋势的价格序列直接做回归,结果全是伪相关——这坑我踩过。
14.1 什么是时间序列分解?
时间序列分解,就是把一个序列拆成三部分:
- 趋势(Trend):长期的方向性变化。比如股价从年初到年末整体上涨。
- 季节(Seasonal):周期性重复的模式。比如每天开盘后的前30分钟成交量特别大。
- 残差(Residual):去掉趋势和季节后剩下的东西。这才是我们真正要找的「信号」。
数学上可以写成:
Y(t) = T(t) + S(t) + R(t)
或者乘法模型:
Y(t) = T(t) * S(t) * R(t)
高频数据里,我一般用加法模型。因为价格波动幅度相对稳定,乘法模型更适合那些波动率随时间变化的情况。
14.2 高频数据中的「季节」是什么?
你可能会问:股票价格哪有季节?又不是卖冰淇淋。
其实高频数据里的「季节」,指的是日内模式。比如:
- 开盘后15分钟,波动率特别大
- 午盘收盘前,成交量萎缩
- 下午开盘后,又有一波活跃期
- 尾盘30分钟,机构调仓导致价格异动
这些就是高频数据的「季节成分」。我做过一个统计,A股市场里,每天上午10:00-10:30的波动率,是下午14:00-14:30的1.8倍。你想想看,如果不把这个季节成分去掉,你提取出来的「信号」其实只是时间效应。
14.3 如何做分解?三种常用方法
方法一:移动平均法(最简单)
用滑动窗口的平均值作为趋势。比如用20期移动平均:
import pandas as pd
import numpy as np
# 假设 price 是高频价格序列
price = pd.Series([...]) # 你的数据
# 计算趋势:20期移动平均
trend = price.rolling(window=20).mean()
# 计算季节:用原始值减去趋势
detrended = price - trend
# 如果知道周期长度(比如日内周期为240个1分钟bar)
period = 240
seasonal = np.zeros_like(price)
for i in range(period):
idx = range(i, len(price), period)
seasonal[idx] = detrended.iloc[idx].mean()
# 残差 = 原始值 - 趋势 - 季节
residual = price - trend - seasonal
这个方法简单,但有个问题:移动平均会丢失头尾的数据。我刚开始做的时候,没注意这个,结果回测时前20个bar全是NaN,白白浪费了数据。
方法二:STL分解(更稳健)
STL(Seasonal-Trend decomposition using LOESS)是更专业的做法。它用局部加权回归来拟合趋势和季节,对异常值不敏感。
from statsmodels.tsa.seasonal import STL
# 高频数据,周期设为240(假设是1分钟数据,一天240分钟)
stl = STL(price, period=240, robust=True)
result = stl.fit()
trend = result.trend
seasonal = result.seasonal
residual = result.resid
STL的好处是:
- 可以处理非线性趋势
- 季节成分可以随时间缓慢变化
- 对异常值有鲁棒性
我在处理股指期货的高频数据时,STL的效果明显好于移动平均。尤其是遇到盘中突然的「闪崩」或「拉升」,STL不会把异常值算进趋势里。
方法三:小波分解(适合非平稳信号)
高频数据往往是非平稳的——波动率会变,趋势会变。小波分解可以同时提取不同时间尺度上的成分。
import pywt
# 用db4小波做3层分解
coeffs = pywt.wavedec(price, 'db4', level=3)
# 近似系数(趋势)
cA3 = coeffs[0]
# 细节系数(不同频率的波动)
cD3, cD2, cD1 = coeffs[1:]
# 重构趋势
trend = pywt.waverec([cA3, None, None, None], 'db4')
# 重构高频噪声(残差)
noise = pywt.waverec([None, None, None, cD1], 'db4')
小波分解的好处是:你可以选择保留哪些频率成分。比如我只想要1分钟级别的信号,那就把更高频的细节去掉。
14.4 信号提取:从残差中找机会
分解完成后,残差就是我们要的信号。但注意,残差不等于交易信号——它只是「异常波动」。
怎么用?我一般做两件事:
- 标准化残差:把残差除以它的滚动标准差,得到Z-score。
- 设定阈值:当Z-score超过±2时,认为出现了显著的异常信号。
# 标准化残差
z_score = (residual - residual.rolling(100).mean()) / residual.rolling(100).std()
# 生成信号
signal = pd.Series(0, index=price.index)
signal[z_score > 2] = 1 # 买入信号
signal[z_score < -2] = -1 # 卖出信号
这里有个坑:残差的分布往往不是正态的,有厚尾特征。所以±2的阈值可能太宽松,也可能太严格。我建议用历史分位数来定阈值,比如95%分位数。
14.5 一张图看懂整个流程
下面这张SVG图,展示了从原始价格到交易信号的完整流程:
14.6 避坑指南
做时间序列分解,有几个坑我替你们踩过了:
- 周期选择错误:高频数据的周期不是固定的。比如A股上午和下午的交易时间不一样长,你不能简单用240期。我一般用实际交易分钟数,上午120分钟,下午120分钟。
- 过度分解:分解层数太多,会把有用的信号也分解到趋势或季节里。记住,我们想要的是残差里的「异常」,不是把一切都拆成碎片。
- 忽略数据质量:高频数据经常有缺失值、异常值。如果直接做分解,结果会一塌糊涂。我习惯先做数据清洗,把明显的错误值剔除或插值。
好了,时间序列分解就聊到这里。记住一句话:趋势和季节是已知的,残差才是未知的机会。把已知的去掉,剩下的就是你要找的信号。