均值回归策略详解:从数学原理到Python回测实现

发布时间:2026/9/2 4:01:21
均值回归策略详解:从数学原理到Python回测实现 均值回归这个词在量化交易圈子里几乎每天都会被提起。但很多刚接触它的人第一反应往往是“价格跌多了就会涨涨多了就会跌这不是一种玄学吗”如果抱着这个想法去写策略大概率会亏钱。真正的问题是均值回归到底回归的是什么均值这个均值本身稳不稳定价格偏离到什么程度才算“过度”如果这些问题没有用数学和代码搞清楚所谓的均值回归本质上只是在赌运气。这篇文章我会从 Simon Ree 量化交易系列中关于均值回归的讨论出发把概念、数学原理、策略形态和 Python 实现串起来讲清楚。读完你会掌握一个完整的均值回归回测框架也知道为什么回测里赚钱的策略到了实盘却经常失效。1. 均值回归为什么是量化交易的核心主题之一先看一个很典型的日内现象一只股票在开盘后突然放量下跌跌幅超过3%但公司基本面没有任何变化。这时候一部分交易者会恐慌卖出另一部分人会认为“跌过头了”而进场接盘。价格往往在几十分钟内回到开盘价附近。这种价格向某个“合理水平”恢复的过程就是均值回归。从这个场景能看出来均值回归背后是有市场微观结构支撑的而不是纯统计巧合市场参与者的过度反应会造成价格短时间偏离。做市商和套利资金会在偏离过大时进场把价格拉回。交易行为本身存在“买卖交替”的微观结构导致价格在短期内表现出负自相关。在量化交易中均值回归和动量是两类底层逻辑完全不同的策略。动量策略赌的是“强者恒强”趋势一旦形成会延续均值回归策略赌的是“物极必反”价格偏离均值后终将回归。Simon Ree 在 TradingData 系列中反复强调过一个观点均值回归不是预测价格的工具而是一种关于“价格偏离状态”的统计判断。它并不回答“价格会涨还是会跌”而是回答“当前价格相对某个均衡水平的位置是否极端”。这个思路上的差异决定了策略从设计到执行都跟趋势交易不同。对于普通投资者来说学习均值回归最大的价值不是拿到一个能自动赚钱的策略而是建立一套“偏离度”思维当价格大幅波动时先不急着追涨杀跌而是问一句——当前偏离有多大历史上这个偏离度处于什么水平这个水平可持续吗这正是本文要解决的问题。2. 均值回归的核心概念与数学基础在写代码之前必须先把几个基础概念弄清楚。它们会直接决定策略设计和参数选择。2.1 均值回归的数学定义均值回归在统计学上可以描述为一个时间序列 $X_t$如果它总是围绕一个长期均值 $\mu$ 波动并且在偏离后会被拉回均值那么这个序列就具有均值回归特性。最简单的一阶自回归模型可以表达为$$X_{t1} - X_t \theta(\mu - X_t) \epsilon_t$$其中 $\theta$ 是回归速度$\epsilon_t$ 是随机扰动项。这个公式的直觉是如果当前价格 $X_t$ 高于均值 $\mu$那么下一步的变动方向倾向于向下如果低于均值下一步倾向于向上。$\theta$ 越大回归速度越快。2.2 平稳性与单位根一个严格的均值回归序列必须是平稳序列。平稳意味着统计特征均值、方差不随时间变化。判断序列是否平稳最常用的方法是 ADF 检验。它的核心逻辑是检验序列是否存在单位根。如果序列存在单位根说明价格是随机游走的今天的价格等于昨天的价格加上随机扰动这种情况下价格没有回归倾向均值回归策略不适用。用 statsmodels 可以很方便地做 ADF 检验from statsmodels.tsa.stattools import adfuller def check_stationarity(series, name): result adfuller(series.dropna()) print(f{name} ADF统计量: {result[0]:.4f}) print(f{name} p值: {result[1]:.4f}) if result[1] 0.05: print(结论: 拒绝单位根假设序列平稳具备均值回归基础) else: print(结论: 不能拒绝单位根假设序列非平稳不适合直接做均值回归)2.3 Z-Score 与布林带Z-Score 是均值回归策略中最常用的信号指标。它衡量当前价格偏离均值的标准差倍数$$Z \frac{P - \text{mean}}{\text{std}}$$其中 mean 和 std 通常在滚动窗口内计算。Z 的绝对值越大说明当前价格偏离均值的程度越极端回归的概率和空间理论上越大。布林带本质上就是 Z-Score 的可视化中轨是移动平均线上下轨是中轨加减两倍标准差。布林带策略是均值回归思想最简单的一种落地方式。2.4 Ornstein-Uhlenbeck 过程与半衰期如果要更精细地理解均值回归的“速度”需要引入 Ornstein-Uhlenbeck 过程。它是对均值回归现象更严谨的数学描述公式为$$dX_t \theta(\mu - X_t)dt \sigma dW_t$$其中 $\theta$ 是回归速度$\sigma$ 是波动率$dW_t$ 是布朗运动。在实际应用中更常用的是“半衰期”这个概念它表示偏离均值后回归到一半偏离幅度所需的时间。半衰期越短策略调仓频率越高半衰期越长策略持仓周期越长。计算半衰期的方法是对价格序列做一阶差分然后和前一期价格做线性回归回归系数的对数转换就是半衰期。import numpy as np import pandas as pd def calculate_half_life(series): series series.dropna() spread np.log(series) # 这里假设用对数价格 spread_lag spread.shift(1).dropna() spread_diff spread.diff().dropna() # 回归: spread_diff alpha beta * spread_lag beta np.polyfit(spread_lag, spread_diff, 1)[0] half_life -np.log(2) / beta return half_life半衰期这个概念很实用它能直接告诉你策略的时间尺度。如果半衰期是3天那么策略的持仓周期大概在几天级别如果半衰期是30天那意味着策略需要更长的耐心。3. 均值回归策略的三种常见形态理解了基础原理再来看实际策略形态就不会一头雾水了。均值回归策略在实践中有三种主流形态。3.1 单时间序列均值回归这是最简单的一种直接对单一资产的价格做均值回归。例如当股价低于布林带下轨时买入高于上轨时卖出。这种策略适用于价格行为相对稳定的资产比如大盘指数、流动性好的 ETF。它的问题在于单资产价格往往不是严格平稳的价格中枢可能长期漂移导致策略在趋势行情中连续亏损。3.2 配对交易配对交易是均值回归最经典的应用形式。它的思路是找到两只相关性很高的股票比如同一行业的两个龙头公司。当它们的价差偏离历史水平时做多低估的一只、做空高估的一只赌价差回归。配对交易的核心不是单只股票的价格而是价差的平稳性。即使两只股票本身都不是平稳的它们的某个线性组合也可能表现出平稳性这就是协整。3.3 统计套利统计套利是配对交易的扩展版。它不限于两只股票而是通过因子模型构建一个多头组合和一个空头组合让两个组合之间只有一个“共同风险暴露”的差异。当某个因子暴露偏离均衡水平时通过多空组合获利。统计套利容量大、风险分散但实现复杂度也高。对于个人研究者和初学者建议从单时间序列和配对交易入手。策略形态操作对象复杂度适合人群单序列均值回归单一资产价格较低初学者配对交易两只股票价差中等有一定经验的量化研究者统计套利多资产组合较高机构或资深量化工程师4. 环境准备与数据获取接下来进入实操部分。本文演示用的是 Python如果你还没有量化研究的 Python 环境建议按下面的最小依赖安装。4.1 安装依赖库pip install pandas numpy matplotlib statsmodels yfinancepandas数据处理和回测框架。numpy数值计算。matplotlib结果可视化。statsmodelsADF 检验、协整检验等统计分析。yfinance获取雅虎财经的行情数据适合做研究和教学实验。4.2 获取示例数据本文以一个常见的大盘 ETF 为例做演示。之所以选择 ETF是因为它的价格行为相对稳定更适合演示均值回归的思想。你可以替换成自己熟悉的标的但要注意标的是否具备均值回归属性。import yfinance as yf # 获取 SPY标普500 ETF历史数据 spy yf.download(SPY, start2020-01-01, end2024-12-31) close spy[Close] print(close.tail())需要注意的是yfinance 获取的数据用于教学没有问题但如果要用于实盘策略建议改用券商或数据服务商提供的更高质量数据。5. 完整回测实现基于 Z-Score 的均值回归策略下面实现一个完整的均值回归策略回测。这个策略的逻辑很直观计算滚动窗口内的均值和标准差。计算 Z-Score。当 Z-Score 小于 -2 时做多价格严重低于均值。当 Z-Score 大于 2 时做空价格严重高于均值。当 Z-Score 回归到 0 附近时平仓。5.1 完整策略代码import pandas as pd import numpy as np import matplotlib.pyplot as plt from statsmodels.tsa.stattools import adfuller # --------------------------------------------------------------- # 数据准备 # --------------------------------------------------------------- # 假设 close 是已经获取到的收盘价 Series # close yf.download(SPY, start2020-01-01, end2024-12-31)[Close] # --------------------------------------------------------------- # 1. 参数设置 # --------------------------------------------------------------- window 20 # 滚动窗口 entry_z 2.0 # 开仓阈值 exit_z 0.5 # 平仓阈值 initial_capital 100000.0 # --------------------------------------------------------------- # 2. 计算指标 # --------------------------------------------------------------- ma close.rolling(windowwindow).mean() std close.rolling(windowwindow).std() zscore (close - ma) / std # --------------------------------------------------------------- # 3. 生成信号 # --------------------------------------------------------------- # position 1 表示做多 -1 表示做空 0 表示空仓 position pd.Series(0, indexclose.index) # 先找到所有触发开仓和平仓的点 long_entry zscore -entry_z # 做多信号 short_entry zscore entry_z # 做空信号 exit_signal abs(zscore) exit_z # 平仓信号 # 用循环方式生成持仓状态教学中用循环更直观实盘可向量化 current_pos 0 for i in range(window, len(close)): if current_pos 0: if long_entry.iloc[i]: current_pos 1 elif short_entry.iloc[i]: current_pos -1 else: if exit_signal.iloc[i]: current_pos 0 position.iloc[i] current_pos # --------------------------------------------------------------- # 4. 回测计算 # --------------------------------------------------------------- # 策略收益 持仓方向 * 当日收益率 daily_return close.pct_change().fillna(0) strategy_return position.shift(1) * daily_return # 用前一日持仓做今日收益 # 累计净值 strategy_nav (1 strategy_return).cumprod() buy_hold_nav (1 daily_return).cumprod() # --------------------------------------------------------------- # 5. 输出结果 # --------------------------------------------------------------- print(f策略累计净值: {strategy_nav.iloc[-1]:.4f}) print(f买入持有累计净值: {buy_hold_nav.iloc[-1]:.4f}) print(f策略年化收益: {strategy_nav.iloc[-1] ** (252 / len(close)) - 1:.4f}) print(f最大回撤: {((strategy_nav / strategy_nav.cummax()) - 1).min():.4f}) # --------------------------------------------------------------- # 6. 可视化 # --------------------------------------------------------------- fig, axes plt.subplots(3, 1, figsize(12, 10), sharexTrue) axes[0].plot(close, labelClose Price) axes[0].plot(ma, labelf{window}日均线) axes[0].legend() axes[0].set_title(SPY 价格与均线) axes[1].plot(zscore, labelZ-Score, colorpurple) axes[1].axhline(entry_z, linestyle--, colorred, label做空线) axes[1].axhline(-entry_z, linestyle--, colorgreen, label做多线) axes[1].axhline(0, linestyle-, colorgray, alpha0.5) axes[1].legend() axes[1].set_title(Z-Score 信号) axes[2].plot(strategy_nav, labelStrategy NAV) axes[2].plot(buy_hold_nav, labelBuy Hold NAV) axes[2].legend() axes[2].set_title(净值对比) plt.tight_layout() plt.show()5.2 代码关键逻辑说明这个回测的核心是第 3 步的信号生成。注意第 3 步用了循环原因是持仓状态不是独立的上一时刻的持仓会影响当前时刻能否新开仓。比如当前已经持有多单Z-Score 又继续下跌到 -3这时候不能重复开仓。第 4 步position.shift(1)是为了避免未来函数。如果用当天的 position 乘以当天的收益等于用当天收盘价得到的信号做当天的收益这是不成立的。必须在次日开盘或次日按收盘价成交所以要用前一天的持仓方向。fillna(0)也很关键。pct_change()的第一行是 NaN如果不处理整个累计净值都会变成 NaN。5.3 加一个约束按固定持仓数量做多空上面的例子只有单边做多和空仓。如果把做空也加进去需要账户支持融券。在国内 A 股市场做空门槛较高。这里换一个版本让策略既能做多也能做空但更贴近真实交易的约束每次只持有一份仓位不考虑加仓。这个版本的改动很小把 signal 的生成改成# 开仓信号 long_signal zscore -entry_z short_signal zscore entry_z # 用 .fillna 后的逻辑代替循环 # 实际项目中更推荐显式循环方便加交易成本等细节6. 运行结果与效果验证运行上面的回测你会得到类似下面的输出具体数值会因为数据区间不同而不同策略累计净值: 1.5234 买入持有累计净值: 1.4821 策略年化收益: 0.1140 最大回撤: -0.12756.1 怎么判断策略是否有效看回测结果不能只盯着总收益。至少需要检查三件事。第一策略净值曲线是否平稳。如果净值曲线看起来像过山车说明策略在部分时间段大赚、部分时间段大亏这种策略的实盘心理压力会非常大。第二与买入持有相比策略的收益风险比是否有优势。一个合理的均值回归策略未必能跑赢永远满仓的买入持有但它的最大回撤应该明显更小。如果收益差不多但是回撤只有买入持有的一半这个策略就有配置价值。第三交易次数和交易成本。Z-Score 均值回归策略在震荡行情中会频繁交易。每次交易都有手续费和滑点回测如果不考虑成本实盘收益会大打折扣。6.2 查看交易明细在回调策略中最好把每笔交易记录下来。可以在第 3 步的循环中增加交易记录trades [] for i in range(window, len(close)): if current_pos 0: if long_entry.iloc[i]: current_pos 1 trades.append({date: close.index[i], side: buy, price: close.iloc[i]}) elif short_entry.iloc[i]: current_pos -1 trades.append({date: close.index[i], side: short, price: close.iloc[i]}) else: if exit_signal.iloc[i]: trades.append({date: close.index[i], side: close, price: close.iloc[i]}) current_pos 0 position.iloc[i] current_pos trades_df pd.DataFrame(trades) print(trades_df.head())通过交易明细你能直观看到策略在哪些时间段频繁开仓。如果开仓集中在一段窄幅震荡行情之后很长时间没有交易说明策略的“有效时间”非常有限。7. 均值回归策略的常见问题与排查方法均值回归策略看起来逻辑简单实际运行中问题非常多。以下是我认为最常遇到的五类问题。问题现象可能原因排查方式解决方案回测盈利实盘亏损未考虑滑点和交易成本在回测中增加滑点和手续费按实际费率建模用保守参数连续小幅亏损策略在趋势行情中运行查看净值曲线的回撤区间对应的市场行情增加趋势过滤条件如 ADX 或长期均线方向过滤Z-Score 长期不回归序列不具备均值回归属性做 ADF 检验看 p 值更换标的或用配对交易构建协整组合参数一改收益大变过拟合做参数敏感性分析用样本外测试限制参数搜索范围同一标的在不同时间段表现差异大市场结构变化分段回测增加策略失效熔断机制定期重新评估7.1 未来函数问题这是回测中最隐蔽也最致命的错误。比如在计算 Z-Score 时用了整个数据区间的均值和标准差到了回测的早期阶段就会“偷看”未来的数据。正确做法是只用截至当前时刻的数据计算指标也就是滚动计算。7.2 统计假设失效均值回归策略成立的前提是价格序列具有平稳性。但金融市场的结构会发生变化一个过去十年都平稳的价差关系可能在某一天突然断裂。行业政策变化、公司基本面恶化、市场微观结构改变都可能导致价差不再回归。7.3 幸存者偏差如果你用当前的股票池做回测而股票池里剔除了已经退市的股票那么回测结果会系统性偏高。解决方法是使用包含退市股票的历史数据或者至少理解这个偏差的存在。8. 从回测到实盘的工程建议均值回归策略从回测到实盘需要解决几个工程问题。这些细节决定了策略能否长期稳定运行。8.1 参数的时间稳定性度量不要把参数固定死在某个值上。更好的做法是定期重新估计回归半衰期和最优窗口。比如每个月重新计算一次半衰期如果半衰期显著变长说明价差回归效率下降应该降低仓位或者暂停策略。8.2 风险控制与仓位管理均值回归策略最怕的是小概率的极端行情价格偏离均值后不回归反而越来越远。这种“黑天鹅”事件一次就可能让账户爆仓。止损是强制要求。尤其是在做空的情况下理论上亏损是无限的必须设定硬性止损。可以用 ATR 或固定比例设置止损线例如开仓后价格继续向不利方向波动 2 倍标准差就强制平仓。# 简单止损示例 stop_loss_pct 0.05 # 5% 止损 # 在持仓循环中加入: # 如果当前持仓亏损超过止损线强制平仓8.3 交易成本建模小资金个人做均值回归最大的隐形敌人是交易成本。Z-Score 策略天然交易频繁如果标的的买卖价差很大成本会吞掉大部分利润。建议在回测中加入按成交金额比例计算的手续费和固定滑点。8.4 结构化代码与监控系统当策略进入实盘阶段代码结构就不能只靠一个 Jupyter Notebook 了。建议把功能拆分为数据获取、信号计算、风控、执行、监控五个模块。每个模块独立测试。监控系统至少要做到每天输出持仓、仓位、净值、今日盈亏和当前 Z-Score。如果 Z-Score 连续多日位于极端位置但策略没有触发平仓需要人工检查原因。9. 总结与后续学习方向均值回归是量化交易中最基础也最容易被误解的一类策略。它的核心不是“跌了买、涨了卖”这种直觉而是基于平稳性、协整、波动率等统计概念的严谨判断。从 Simon Ree 的量化研究系列也可以看出均值回归的价值不在于某个具体的开仓阈值而在于它提供了一套完整的“偏离 — 评估 — 回归 — 验证”的研究框架。如果你想继续深入下一步建议关注两个方向。第一个方向是配对交易与统计套利。单资产均值回归的标的选择有限而配对交易可以通过协整组合在更多资产上构造出平稳价差。这里面涉及协整检验、价差建模、动态对冲等技术是机构量化交易的重要基础。第二个方向是理性看待回测结果。我给一个很实用的建议在你对策略有信心之前先做一次完全样本外的模拟交易。把策略跑在一个你从未调参过的数据区间上记录每一笔交易和每日净值。这个过程会比任何回测指标都更真实地告诉你这个策略在实盘中是什么感觉。均值回归策略不是圣杯它只是对市场微观结构的一种建模方式。理解了这一点你就已经比多数盲目交易的人往前走了很远。