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不会把异常值算进趋势里。

注意: STL的计算量比较大。如果你处理的是tick级数据(每秒几百笔),建议先降采样到1分钟或5分钟bar,再做分解。我曾经用tick数据直接跑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 信号提取:从残差中找机会

分解完成后,残差就是我们要的信号。但注意,残差不等于交易信号——它只是「异常波动」。

怎么用?我一般做两件事:

  1. 标准化残差:把残差除以它的滚动标准差,得到Z-score。
  2. 设定阈值:当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%分位数。

实战技巧: 不要只看单一时间尺度的残差。我习惯同时看1分钟、5分钟、15分钟三个尺度的残差。如果三个尺度同时出现异常信号,那这个信号的可靠性就高很多。

14.5 一张图看懂整个流程

下面这张SVG图,展示了从原始价格到交易信号的完整流程:

时间序列分解与信号提取流程 原始价格序列 时间序列分解 STL / 移动平均 / 小波 趋势 T(t) 季节 S(t) 残差 R(t) 信号提取 残差 = 原始值 - 趋势 - 季节 信号 = Z-score(残差) 超过阈值

14.6 避坑指南

做时间序列分解,有几个坑我替你们踩过了:

  • 周期选择错误:高频数据的周期不是固定的。比如A股上午和下午的交易时间不一样长,你不能简单用240期。我一般用实际交易分钟数,上午120分钟,下午120分钟。
  • 过度分解:分解层数太多,会把有用的信号也分解到趋势或季节里。记住,我们想要的是残差里的「异常」,不是把一切都拆成碎片。
  • 忽略数据质量:高频数据经常有缺失值、异常值。如果直接做分解,结果会一塌糊涂。我习惯先做数据清洗,把明显的错误值剔除或插值。
一个小技巧: 做分解前,先画个图看看数据。如果趋势明显是线性的,用移动平均就够了。如果趋势很复杂,用STL。如果数据有突变,用小波。没有万能的方法,只有适合的方法。

好了,时间序列分解就聊到这里。记住一句话:趋势和季节是已知的,残差才是未知的机会。把已知的去掉,剩下的就是你要找的信号。


交易系统化学习资料 微信Strategy888888