Python 均值回归策略

均值回归是趋势跟踪的镜像:不追涨杀跌,而是赌价格"偏离过远后会回来"。

本篇实现两类经典均值回归:布林带回归交易与配对交易,并延续本教程的诚实传统——配对交易的演示结果同样如实呈现,包括亏损。


均值回归的逻辑与前提

趋势策略怕震荡,均值回归恰恰最爱震荡:价格在区间内反复摆动时,"跌多了买、涨多了卖"胜率最高。

流派 核心假设 克星
趋势跟踪 价格趋势会延续 震荡市(假突破)
均值回归 价格偏离会回归 单边市(越抄越低)

所以两类策略天然互补,很多基金同时运作两者对冲环境风险。


布林带回归策略

第 14 篇已经画过布林带,现在把它变成策略:价格跌破下轨(超卖)买入,回到中轨(均值)卖出。

实例

import yfinance as yf
import pandas as pd

# 震荡行情样本:茅台 2023-2024(买入持有为负收益)
close = yf.download("600519.SS", start="2023-01-01", end="2024-12-31",
                    progress=False, multi_level_index=False)["Close"]

mid = close.rolling(20).mean()
std = close.rolling(20).std()
lower = mid - 2 * std            # 下轨:超卖线

position = pd.Series(0, index=close.index)
p = 0
for i in range(20, len(close)):  # 前 20 天先让均线热身
    if p == 0 and close.iloc[i] < lower.iloc[i]:
        p = 1                    # 跌破下轨,博回归
    elif p == 1 and close.iloc[i] >= mid.iloc[i]:
        p = 0                    # 回到中轨,落袋
    position.iloc[i] = p

ret = close.pct_change()
strat_ret = position.shift(1).fillna(0) * ret.fillna(0)
print(f"开仓次数 {int((position.diff() == 1).sum())}")

加上熟悉的绩效计算:

实例

import numpy as np

def perf(strat, name):
    cum = (1 + strat).cumprod()
    ann = cum.iloc[-1] ** (252 / len(strat)) - 1
    vol = strat.std() * np.sqrt(252)
    mdd = (cum / cum.cummax() - 1).min()
    print(f"{name}: 累计 {cum.iloc[-1]-1:.2%} | 年化 {ann:.2%} | "
          f"波动 {vol:.2%} | 最大回撤 {mdd:.2%} | 夏普 {(ann-0.03)/vol:.2f}")

perf(strat_ret, "布林带策略")
perf(ret.fillna(0), "买入持有")

输出结果如下所示:

开仓次数 10
布林带策略: 累计 19.49% | 年化 9.73% | 波动 13.27% | 最大回撤 -8.65% | 夏普 0.51
买入持有: 累计 -6.14% | 年化 -3.25% | 波动 24.41% | 最大回撤 -31.37% | 夏普 -0.26

戏剧性的结果:同一只股票、同一个区间,趋势型的买入持有亏 6%,均值回归赚 19.5%,回撤只有前者的四分之一。

这就是"策略性格"的直观体现——震荡市是均值回归的主场。把信号画出来看看它在做什么:

实例

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(12, 6))
ax.plot(close.index, close.values, color="#1f2933",
        linewidth=1.2, label="Close")
ax.plot(mid.index, mid.values, color="#e0a458",
        linewidth=1.1, label="Middle Band")
ax.plot(lower.index, lower.values, color="#14805e",
        linewidth=0.9, linestyle="--", label="Lower Band")
buy_pts = close[(position.diff() == 1)]
sell_pts = close[(position.diff() == -1)]
ax.scatter(buy_pts.index, buy_pts.values, marker="^",
           color="#14805e", s=100, zorder=3, label="Buy")
ax.scatter(sell_pts.index, sell_pts.values, marker="v",
           color="#c0563f", s=100, zorder=3, label="Sell")
ax.set_title("Maotai: Bollinger Band Mean Reversion (20, 2)")
ax.set_xlabel("Date")
ax.set_ylabel("Price (CNY)")
ax.legend()
ax.grid(alpha=0.25)
plt.show()

执行以上代码,输出的图表如下:

布林带均值回归买卖点

注意 2024 年 9 月末的暴涨段:策略早早离场,完美避开了随后的大幅回落——但它也因此错过了 2022 年那种单边行情。方法没有优劣,只有适配。

警告:均值回归最怕单边下跌中的"接飞刀"。本例茅台 2023-2024 恰好震荡,若换成 2018 年式的持续阴跌,跌破下轨后继续跌的策略会持续失血。实盘必须搭配止损。


配对交易:市场中性版均值回归

布林带策略始终暴露在市场方向风险下。配对交易用"一多一空"对冲掉市场涨跌,只赚两只股票价差回归的钱。

完整流程在统计基础已经铺好:协整检验、对冲比率(beta=2.8141,用 2022 年数据估计)、价差与 z-score。现在把 z-score 变成交易规则:

实例

import numpy as np

# 价差与 60 日滚动 z-score(z 表示价差偏离均值几个标准差)
spread = px["000333.SZ"] - beta * px["000651.SZ"]      # 美的 - 2.81 x 格力
z = (spread - spread.rolling(60).mean()) / spread.rolling(60).std()

# 只在样本外区间(2023-2024)交易
z_trade = z.loc["2023-01-01":]
position_p = pd.Series(0, index=z_trade.index)
p = 0
for i in range(len(z_trade)):
    v = z_trade.iloc[i]
    if p == 0 and v < -2:
        p = 1        # 价差低于 -2 倍标准差:做多价差
    elif p == 1 and abs(v) < 0.5:
        p = 0        # 价差回归到 0 附近:平仓
    position_p.iloc[i] = p

print(f"开仓 {int((position_p.diff() == 1).sum())} 次 | 期末持仓 {int(position_p.iloc[-1])}")

规则只有两条:z 低于 -2 开仓赌回归,z 回到 ±0.5 以内平仓。执行后的开平仓时点(z 值)如下:

开仓 5 次 | 期末持仓 0
Date
2023-02-02   -2.02    <- 开仓
2023-07-03    0.42    <- 平仓
2023-07-28   -2.08    <- 开仓
2023-08-21    0.22    <- 平仓
2024-02-20   -2.09    <- 开仓
2024-03-26   -0.49    <- 平仓
2024-05-06   -2.15    <- 开仓
2024-05-16   -0.22    <- 平仓
2024-08-05   -2.12    <- 开仓
2024-10-31   -0.02    <- 平仓

五次开仓全部来自价差跌破 -2σ,也全部成功等到回归——信号机制完全按设计工作。

实例

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(12, 6))
ax.plot(z_trade.index, z_trade.values, color="#1f2933",
        linewidth=1.1, label="Z-Score")
ax.axhline(2, color="#c0563f", linewidth=1.0, linestyle="--")
ax.axhline(-2, color="#14805e", linewidth=1.0, linestyle="--")
ax.axhline(0, color="#8a95a1", linewidth=0.8)
ax.fill_between(z_trade.index, -2, 2, color="#14805e", alpha=0.05)
open_pts = z_trade[(position_p.diff() == 1)]
close_pts = z_trade[(position_p.diff() == -1)]
ax.scatter(open_pts.index, open_pts.values, marker="^",
           color="#14805e", s=100, zorder=3, label="Open")
ax.scatter(close_pts.index, close_pts.values, marker="v",
           color="#e0a458", s=100, zorder=3, label="Close")
ax.set_title("Midea/Gree Spread Z-Score with Entry (-2) and Exit (0) Levels")
ax.set_xlabel("Date")
ax.set_ylabel("Z-Score (60d)")
ax.legend()
ax.grid(alpha=0.25)
plt.show()

执行以上代码,输出的图表如下:

配对价差 z-score 与开平仓点

诚实的结局:这个配对亏钱了

用价差的相对变化核算策略收益(未计融券成本):

配对价差多头: 累计 -8.65% | 年化 -4.61% | 波动 31.59% | 最大回撤 -46.41%

五次回归全部抓到,最终却亏了 8.65%——因为价差本身的漂移(美的相对格力中枢上移)吃掉了全部回归利润。

回到协整检验:这对股票 p=0.0617,并未通过 5% 显著性检验。统计上"不协整"的含义正是"价差会漂移",这个亏损是检验结果的老实兑现,不是运气差。

结论:配对交易的每一步都不能省——协整检验是入场券,对冲比率要防未来函数,融券成本要计入,组合还要分散在多个配对上。任何一步偷懒,结果都可能像这个演示一样"信号全对,结局全错"。