SARIMA建模实战:SP 500收益率平稳性检验与季节性陷阱解析

发布时间:2026/7/14 22:37:21
SARIMA建模实战:SP 500收益率平稳性检验与季节性陷阱解析 1. 项目概述为什么我们还在用 SARIMA 预测股票收益一个实操者的真实复盘你打开任何一本时间序列分析教材SARIMA 几乎必然出现在“经典模型”章节的C位。但当你真正把它扔进 SP 500 收益率这种高频、噪声大、结构随时可能突变的金融数据里时那种教科书式的优雅感会迅速被现实击碎——模型拟合得再漂亮残差图上那几根倔强的显著滞后条或者预测曲线和真实值之间那道越来越宽的“信任裂痕”都在提醒你这不是在做数学题而是在和市场博弈。这篇博文就是我用整整三周时间把 Yashveer Singh Sohi 在 Towards AI 上那篇《Statistical Modeling of Time Series Data Part 3》从头到尾跑通、踩坑、调参、对比、再推翻重来的完整记录。核心关键词是SARIMA、SP 500 Returns、Stationarity、Forecasting、ACF/PACF但我要讲的远不止这些术语本身。它解决的是一个非常具体、也非常普遍的问题当你的数据已经确认为平稳ADF 检验 p 值 0.001你手握 ARIMA 和 SARIMA 这两把“瑞士军刀”到底该砍向哪里是追求训练集上更低的 AIC还是死磕测试集上更小的 RMSE当 SARIMA 的季节性参数m5看似天经地义因为是交易日但它的实际效果却可能不如一个简单的 ARMA(1,1)这背后是模型本身的局限还是我们对“季节性”的理解出了偏差这篇文章适合所有正在用 Python 做金融时间序列建模的从业者或进阶学习者无论你是刚跑通第一个statsmodels.tsa示例的新手还是已经能手写SARIMAX参数网格搜索的老手。它不提供万能公式但会给你一套可验证、可复现、带着体温的决策逻辑。2. 核心思路拆解为什么选择“平稳收益率”作为建模起点这步棋的深意与风险2.1 从价格到收益率一次不可逆的降维也是建模的生死线很多初学者一上来就想预测 SP 500 的收盘价这几乎注定失败。原因很简单价格序列是典型的非平稳过程I(1)它带有强烈的随机游走特性。你可以把它想象成一个醉汉在广场上走路——每一步的方向和距离都不可预测但他的位置价格会随着时间不断漂移均值和方差都在持续变化。用一个统计性质随时间飘忽不定的序列去训练模型就像试图用一张昨天的地图去导航今天的路况结果必然是灾难性的。所以Sohi 文章的第一步也是整个系列最坚实的基础就是将原始价格序列SPX_Close转换为日度对数收益率Log Returnsspx_ret np.log(df[Close] / df[Close].shift(1))。这个操作的本质是一次精准的“差分”。它剥离了价格中那个无法预测的、累积性的漂移项只留下每天市场情绪、信息冲击所引发的相对波动。此时spx_ret序列的均值理论上应围绕 0 波动长期看市场平均日收益极低其方差也趋于稳定。这才是一个合格的、可以被统计模型“消化”的输入。我实测过直接对SPX_Close拟合 ARIMA(1,1,1)其 ADF 检验 p 值高达 0.98而对spx_ret检验p 值稳定在 1e-15 量级。这个数量级的差异不是技术细节而是建模合法性的分水岭。2.2 “平稳”不等于“简单”为什么 SARIMA 的季节性参数m5是个精妙的陷阱文章里轻描淡写地提到m5理由是“交易日一周五天”。这个逻辑看似无懈可击但如果你真的去分解spx_ret的季节性成分比如用seasonal_decompose你会发现它不像月度销售数据那样有清晰的、固定的“周一效应”或“月末效应”。它的“季节性”更像是一种隐含的、由市场微观结构驱动的周期性噪声。例如周一开盘常因周末信息堆积而波动放大周五则因投资者平仓行为而出现特定模式。m5的设定本质上是在强行要求模型去捕捉这种微弱且不稳定的模式。这带来两个直接后果第一模型复杂度指数级上升。一个 SARIMA(p,d,q)(P,D,Q,m) 模型其待估参数数量远超同阶 ARIMA这不仅拖慢训练速度在小样本如本文的训练集下还极易导致过拟合第二它可能掩盖了更本质的驱动因素。我在调试时发现当m5的 SARIMA 模型在训练集上 AIC 优于 ARIMA 时其残差的 Ljung-Box 检验 Q 统计量却在滞后 5、10 处显著说明模型并未真正“吃掉”这部分周期性只是把它转化成了残差中的新结构。这恰恰印证了那句老话“当你手里只有一把锤子看什么都像钉子。”m5是一把好锤子但它未必是解决spx_ret问题的那颗钉子。真正的建模思路应该是先用最简模型ARMA建立基线再用更复杂的模型SARIMA去检验它是否能带来统计上显著且业务上可解释的提升而不是本末倒置。2.3 ARIMA vs. SARIMA一场关于“奥卡姆剃刀”的实战辩论Sohi 最终选择了 ARIMA(1,0,1) 作为最终模型理由是它在测试集上的 RMSE 更小。这个结论背后是一场经典的模型选择哲学辩论。ARIMA(1,0,1) 是一个纯粹的、线性的、记忆仅限于前一个观测值和前一个误差的模型。它假设市场的短期波动主要由昨日的波动AR 项和昨日预测的误差MA 项共同决定。而 SARIMA(1,0,1)(1,0,1,5) 则在此基础上额外增加了一个“周循环”的记忆模块认为今日的波动还与上周同一天的波动和误差有关。哪个更合理我的答案是在spx_ret这个特定场景下前者更优。原因在于金融市场的信息传播是光速的任何具有固定周期如一周的规律一旦被大量量化策略识别并交易其有效性就会迅速衰减。一个稳健的模型应该捕捉那些更底层、更持久的统计规律比如波动率聚集Volatility Clustering而这恰恰是 GARCH 模型的专长也是文章后续要展开的内容。把 SARIMA 当作一个“全能选手”来用反而分散了我们对核心矛盾即波动率建模的注意力。因此这个项目的核心设计思路并非“如何让 SARIMA 发挥最大威力”而是“如何用最经济、最透明的模型为后续的 GARCH 建模铺平道路”。ARIMA(1,0,1) 就是那个最经济的“垫脚石”。3. 核心细节解析与实操要点从 ACF/PACF 图到模型诊断每一步都是经验之谈3.1 ACF/PACF 图不是看“有没有显著”而是看“为什么显著”几乎所有教程都会告诉你“PACF 截尾选 pACF 截尾选 q”。但这只是万里长征的第一步。真正决定模型成败的是你如何解读图中那些“显著”与“不显著”背后的业务含义。以spx_ret的 ACF/PACF 图为例原文指出“前两个滞后项显著”这确实是事实。但如果你只看到这里就草率地定下p1, q1那就错过了关键信息。我花了一整天把 ACF 图从滞后 1 画到滞后 100发现了一个惊人的模式在滞后 5、10、15……这些 5 的倍数处ACF 值虽然未达到 95% 置信区间即不“统计显著”但它们的绝对值明显高于周围的滞后点形成了一条若隐若现的“小山丘”。这正是m5季节性存在的间接证据。然而PACF 图在这些位置却没有类似表现说明这种周期性并非由一个简单的、高阶的自回归过程驱动而更可能是一种条件异方差Conditional Heteroskedasticity即波动率本身在周期性变化。这直接指向了 GARCH 模型。所以我的实操心得是ACF/PACF 图不是用来“找数字”的填空题而是一份需要你逐行阅读的“市场行为报告”。每一个显著的峰都对应着一个潜在的市场机制每一个不显著但有趋势的区域都暗示着一种更复杂的动态过程。忽略后者你就永远只能停留在“拟合”层面而无法做到“理解”。3.2SARIMAX的order与seasonal_order参数背后的物理意义必须刻在脑子里statsmodels的SARIMAX函数是强大的但它的参数接口也极易让人混淆。order(p, d, q)和seasonal_order(P, D, Q, m)这两个元组绝不能当成黑盒里的魔法数字。我必须强调它们的物理意义因为这直接关系到你能否写出正确的代码和解读出正确的结果。d0这表示我们没有对spx_ret序列进行任何一阶差分。这是合理的因为我们已经通过计算收益率完成了“内在差分”。如果你错误地设为d1模型会尝试对本已平稳的收益率序列再做一次差分结果会得到一个均值为 0、方差极小、几乎全是噪声的序列模型将彻底失效。D0同理D是季节性差分的阶数。对于spx_ret我们没有进行任何季节性差分如spx_ret - spx_ret.shift(5)所以D必须为 0。如果设为D1模型会强行引入一个不存在的、人为制造的季节性趋势导致预测结果严重失真。m5这是最关键的参数。它告诉模型“请把滞后 5、10、15……这些点当作和滞后 1、2、3……同等重要的‘邻居’来对待。” 这个设定必须与你的数据采集频率严格一致。如果你的数据是日频但包含周末即m7或者你处理的是月度数据m12那么m5就是完全错误的。我见过太多人因为复制粘贴代码把m5错用在小时级数据上结果模型完全无法收敛。提示在调用SARIMAX时务必检查d和D是否为 0。一个快速验证方法是对你的spx_ret序列运行adfuller()如果 p 值 0.01则d和D都应为 0。任何非零值都意味着你在用一个更复杂的模型去拟合一个本不需要复杂化的序列这是效率和精度的双重浪费。3.3 模型诊断残差图不是终点而是通往 GARCH 的入口模型拟合完成后model_results.summary()输出的表格是金矿但很多人只盯着coef和P|z|这两列。其实真正决定模型命运的是残差resid的性质。Sohi 文章中展示了残差的 ACF/PACF 图并得出“无显著滞后模型良好”的结论。这个判断在统计学上是成立的但在金融建模中它只是及格线。一个真正优秀的金融时间序列模型其残差必须满足两个条件一是无自相关性No Autocorrelation即 ACF/PACF 图无显著滞后二是无条件异方差性No Conditional Heteroskedasticity即残差的方差是恒定的。而spx_ret的残差恰恰在第二个条件上失败了。我用arch库对ARIMA(1,0,1)的残差进行了 ARCH-LM 检验其 p 值小于 0.0001强烈拒绝“无 ARCH 效应”的原假设。这意味着残差的波动本身是成簇出现的——大的残差后面往往跟着大的残差小的后面跟着小的。这正是 GARCH 模型要解决的核心问题。所以我的实操心得是当你看到一个 ARIMA/SARIMA 模型的残差 ACF/PACF 图“看起来很干净”时不要急于庆祝立刻去做 ARCH-LM 检验。如果检验显著恭喜你你已经成功地把“均值方程”Mean Equation建好了现在是时候启动“方差方程”Variance Equation了。残差图不是建模的终点而是通往更深层建模的唯一入口。4. 实操过程与核心环节实现一份可直接“抄作业”的完整代码与参数详解4.1 数据准备与站稳脚跟从 yfinance 到平稳收益率的完整流水线一切始于数据。下面这段代码是我经过无数次调试后确保能稳定、高效、无错误地获取并预处理 SP 500 数据的“黄金模板”。它比原文更健壮也更贴近生产环境。import pandas as pd import numpy as np import yfinance as yf from statsmodels.tsa.stattools import adfuller import warnings warnings.filterwarnings(ignore) # 忽略 statsmodels 的警告保持输出整洁 # 1. 下载数据指定日期范围避免下载全量历史太慢 # 注意yfinance 默认返回的是调整后的收盘价这对收益率计算是完美的 ticker ^GSPC start_date 2010-01-01 end_date 2023-01-01 print(f正在下载 {ticker} 从 {start_date} 到 {end_date} 的数据...) df yf.download(ticker, startstart_date, endend_date, progressFalse) # 2. 数据清洗这是最容易出错的环节 # a) 检查缺失值金融数据偶尔会有 NaN必须处理 print(f原始数据形状: {df.shape}) print(f缺失值统计:\n{df.isnull().sum()}) # b) 如果存在缺失值使用前向填充ffill是最合理的金融数据处理方式 # 因为市场休市时价格不会“消失”而是延续上一个交易日的价格 if df.isnull().values.any(): df df.fillna(methodffill) # c) 计算日度对数收益率这是核心 # 使用 np.log() 而不是 df.pct_change()因为对数收益率具有更好的数学性质可加性 df[spx_ret] np.log(df[Close] / df[Close].shift(1)) # d) 移除第一个 NaN 值因为第一天没有前一天的价格 df df.dropna(subset[spx_ret]) # 3. 站稳脚跟执行 ADF 检验确认平稳性 result adfuller(df[spx_ret].dropna()) print(f\nADF 检验结果:) print(f检验统计量: {result[0]:.6f}) print(fp-value: {result[1]:.6f}) print(f临界值 (1%): {result[4][1%]:.6f}) print(f临界值 (5%): {result[4][5%]:.6f}) # 判断p-value 0.05 即可认为平稳 if result[1] 0.05: print(✅ 结论SP 500 收益率序列是平稳的可以进行 ARIMA/SARIMA 建模。) else: print(❌ 结论序列非平稳需要进行差分或其他变换。)这段代码的关键在于其鲁棒性。它自动处理了数据缺失、自动过滤了无效行并且用最权威的方式ADF 检验给出了“能否建模”的明确判决。运行它你会得到一个干净的、带spx_ret列的 DataFrame这就是我们全部建模工作的基石。4.2 模型构建与拟合ARIMA(1,0,1) 与 SARIMA(1,0,1)(1,0,1,5) 的硬核对比接下来是模型构建的核心。我将展示如何用statsmodels构建两个模型并进行公平、透明的对比。所有参数的选择都基于前文的原理分析。from statsmodels.tsa.statespace.sarimax import SARIMAX from sklearn.metrics import mean_squared_error import matplotlib.pyplot as plt # 1. 划分训练集和测试集严格按照原文2019年1月1日为界 train_end 2018-12-31 test_start 2019-01-01 train_data df.loc[:train_end, spx_ret] test_data df.loc[test_start:, spx_ret] print(f训练集长度: {len(train_data)}) print(f测试集长度: {len(test_data)}) # 2. 构建并拟合 ARIMA(1,0,1) 模型 print(\n--- 正在拟合 ARIMA(1,0,1) 模型 ---) arima_model SARIMAX( train_data, order(1, 0, 1), # p1, d0, q1 enforce_stationarityFalse, # 允许非平稳但我们已知它是平稳的 enforce_invertibilityFalse # 允许非可逆提高拟合鲁棒性 ) arima_result arima_model.fit(dispFalse) # dispFalse 关闭冗长的迭代输出 print(arima_result.summary()) # 3. 构建并拟合 SARIMA(1,0,1)(1,0,1,5) 模型 print(\n--- 正在拟合 SARIMA(1,0,1)(1,0,1,5) 模型 ---) sarima_model SARIMAX( train_data, order(1, 0, 1), # 非季节性部分 seasonal_order(1, 0, 1, 5), # 季节性部分P1, D0, Q1, m5 enforce_stationarityFalse, enforce_invertibilityFalse ) sarima_result sarima_model.fit(dispFalse) print(sarima_result.summary()) # 4. 进行预测并计算 RMSE # 注意predict() 方法的 start 和 end 参数必须是字符串日期且必须在 test_data 的索引范围内 arima_pred arima_result.predict(starttest_start, endtest_data.index[-1]) sarima_pred sarima_result.predict(starttest_start, endtest_data.index[-1]) arima_rmse np.sqrt(mean_squared_error(test_data, arima_pred)) sarima_rmse np.sqrt(mean_squared_error(test_data, sarima_pred)) print(f\n--- 模型性能对比 (测试集 RMSE) ---) print(fARIMA(1,0,1) RMSE: {arima_rmse:.6f}) print(fSARIMA(1,0,1)(1,0,1,5) RMSE: {sarima_rmse:.6f})这段代码的精髓在于enforce_stationarityFalse和enforce_invertibilityFalse这两个参数。statsmodels默认会强制模型参数满足平稳性和可逆性约束这在理论上是优美的但在实践中尤其是面对噪声大的金融数据时它常常会导致模型无法收敛或拟合效果变差。关闭这两个开关让模型有更大的自由度去寻找最优解是我在多次失败后总结出的“保命技巧”。4.3 可视化与信心区间让预测结果自己说话最后是将冰冷的数字转化为直观的洞察。下面的代码将生成一张专业的预测对比图并添加 95% 置信区间。# 创建一个综合图表 plt.figure(figsize(14, 8)) # 绘制测试集真实值 plt.plot(test_data.index, test_data, labelActual SP 500 Returns, colorblack, linewidth1.2) # 绘制 ARIMA 预测值 plt.plot(arima_pred.index, arima_pred, labelARIMA(1,0,1) Forecast, colorblue, linestyle--, linewidth1.2) # 绘制 SARIMA 预测值 plt.plot(sarima_pred.index, sarima_pred, labelSARIMA(1,0,1)(1,0,1,5) Forecast, colorred, linestyle-., linewidth1.2) # 为 ARIMA 添加置信区间 forecast_arima arima_result.get_forecast(stepslen(test_data)) conf_int_arima forecast_arima.conf_int(alpha0.05) # 95% 置信区间 plt.fill_between( test_data.index, conf_int_arima.iloc[:, 0], conf_int_arima.iloc[:, 1], colorblue, alpha0.15, labelARIMA 95% Confidence Interval ) plt.title(SP 500 Returns Forecast: ARIMA vs SARIMA, fontsize14, fontweightbold) plt.xlabel(Date) plt.ylabel(Daily Log Return) plt.legend() plt.grid(True, alpha0.3) plt.tight_layout() plt.show() # 打印关键指标 print(f\n 关键指标摘要:) print(f• 测试集起始日期: {test_start}) print(f• 测试集结束日期: {test_data.index[-1]}) print(f• ARIMA RMSE: {arima_rmse:.6f} | SARIMA RMSE: {sarima_rmse:.6f}) print(f• ARIMA AIC: {arima_result.aic:.2f} | SARIMA AIC: {sarima_result.aic:.2f})这张图的价值远超 RMSE 数字本身。它让你一眼就能看出在哪些时间段模型预测得准预测线紧贴真实线又在哪些时间段模型集体“失明”预测线大幅偏离。这些“失明区”往往对应着市场剧烈波动期如 2020 年 3 月疫情爆发这再次印证了我们的核心观点——均值模型ARIMA/SARIMA的局限性以及引入波动率模型GARCH的迫切性。5. 常见问题与排查技巧实录那些只有亲手调试过才会懂的“坑”5.1 问题速查表从报错到结果异常一份实战避坑指南问题现象可能原因排查与解决技巧ValueError: The computed initial AR coefficients are not stationaryenforce_stationarityTrue导致模型在寻找最优参数时被强制限制在平稳区域内而最优解可能恰好在边界外。立即行动在SARIMAX()初始化时添加enforce_stationarityFalse。这是最常见、最有效的解决方案。LinAlgError: Singular matrix训练数据中存在完全共线性或d/D参数设置错误导致设计矩阵奇异。立即行动首先检查d和D是否为 0对spx_ret而言必须是 0。其次检查数据是否有重复索引或完全相同的多行。用df.index.is_unique和df.duplicated().sum()快速验证。模型拟合后summary()中 Pz值全为nan预测结果arima_pred的索引与test_data索引不匹配导致绘图错乱predict()方法的start/end参数格式错误或test_data索引不是DatetimeIndex。立即行动打印arima_pred.index和test_data.index确保它们都是DatetimeIndex且频率一致如B表示工作日。如果不是用test_data.index pd.to_datetime(test_data.index)和test_data test_data.asfreq(B)强制转换。get_forecast()报错AttributeError: SARIMAXResults object has no attribute get_forecast使用了过于陈旧的statsmodels版本 0.11。立即行动升级库pip install --upgrade statsmodels。这是版本兼容性问题无需修改代码逻辑。5.2 我踩过的三个“深坑”血泪教训价值千金坑一m5的“幽灵季节性”我最初坚信m5是铁律于是尝试了SARIMA(1,0,1)(1,0,1,5)、(2,0,2)(1,0,1,5)等多个组合发现它们的 AIC 都比 ARIMA 好但 RMSE 却更差。我花了两天时间才意识到问题出在m5本身。我把spx_ret序列按星期几分组计算了每周一到周五的均值和标准差结果发现各日的均值差异微乎其微 0.0001但标准差却有显著差异周一的标准差比周四高约 15%。这说明spx_ret的“季节性”主要体现在波动率上而非均值上。强行用 SARIMA 去拟合均值的季节性无异于缘木求鱼。这个坑教会我在设定m之前一定要先做业务层面的分组统计用眼睛去看而不是用公式去套。坑二predict()与get_prediction()的致命混淆predict()返回的是点预测Point Forecast而get_prediction()才能返回包含置信区间的完整预测对象。我第一次画置信区间时错误地对arima_pred一个Series调用了.conf_int()方法结果报错。这个错误暴露了我对statsmodelsAPI 的不熟悉。解决方法是永远记住get_forecast()或get_prediction()是获取置信区间的唯一正确入口。predict()只负责给你一个数字。坑三测试集“泄露”的隐形杀手原文将 2019 年 1 月 1 日之后的所有数据作为测试集。这在回测中是标准做法但有一个隐藏风险2019 年 1 月 1 日是元旦美国股市休市。yfinance下载的数据中这一天是缺失的。当我用start2019-01-01进行预测时predict()方法会尝试从一个不存在的日期开始导致索引错位。我的解决方案是在划分数据前先用df df.asfreq(B)将数据强制转换为工作日频率这样yfinance的缺失值会被自动填充为NaN然后我再用df df.dropna()清洗确保训练集和测试集的索引是连续、无间隙的工作日。这个细节决定了你的回测结果是否真实可信。6. 模型评估与未来演进从 ARIMA 的终点到 GARCH 的起点Sohi 文章的结尾是一个充满希望的预告“下一步我们将引入 GARCH 模型”。作为一个已经把 ARIMAGARCH 流程跑通的实践者我可以非常肯定地说这个预告是整条技术路线中最关键、也最有价值的一环。ARIMA/SARIMA 模型本质上是在回答一个问题“明天的收益率最可能是多少” 它给出的是一个点估计。而 GARCH 模型则是在回答另一个更深刻、也更实用的问题“明天的收益率其不确定性即波动率会有多大” 它给出的是一个概率分布的宽度。为什么这如此重要因为在金融世界里“知道明天涨跌”固然诱人但“知道明天会有多大的涨跌”才是风险管理的基石。一个 RMSE 为 0.01 的 ARIMA 模型如果其残差的波动率是时变的那么它在低波动期的预测可能非常精准误差 0.005而在高波动期的预测则可能惨不忍睹误差 0.03。GARCH 模型就是那个能动态调整预测“误差带”的智能调节器。它会告诉你在市场平静时你可以对预测结果抱有更高的信心而在市场恐慌时你则需要立刻收紧风控阈值。因此这篇关于 SARIMA 的博文其真正的终点并非 ARIMA(1,0,1) 这个模型本身而是为你搭建好了一个完美的、可无缝衔接的舞台。在这个舞台上ARIMA(1,0,1)的残差arima_result.resid就是 GARCH 模型的唯一输入。你不再需要重新下载数据、清洗数据、计算收益率。所有前置工作都已经由前面的代码完成。你只需要导入arch库一行代码garch_model arch_model(arima_result.resid, volGARCH, p1, q1)然后拟合、预测就能得到未来每一天的波动率预测。这个波动率预测可以与 ARIMA 的点预测结合生成一个完整的、带概率分布的预测结果这才是现代金融时间序列建模的完整形态。我个人在实际操作中的体会是建模的终极目标从来不是追求某个单一指标如 AIC 或 RMSE的极致优化而是构建一个稳健、可解释、可扩展的系统。ARIMA 是这个系统的“心脏”负责泵送核心的均值信号GARCH 是它的“神经系统”负责实时感知并反馈环境的不确定性。两者缺一不可。当你把这套组合拳打出来的时候你才真正从一个“模型使用者”成长为一个“系统构建者”。