菜市场的鸡蛋平时卖 6 块一斤,某天涨到 10 块,你会觉得「太贵了,等等再买」;哪天跌到 4 块,你会觉得「便宜,囤一点」。价格像一根皮筋:拉得太远,总会往中间弹回来。这个生活直觉,就是本篇的主角——均值回归策略。
上一篇动量策略相信「强者恒强」,本篇讲它的镜像:均值回归(Mean Reversion)——跌多了会涨、涨多了会跌。两者性格相反、擅长的行情相反、死法也相反,放在一起学才能真正理解「策略要匹配市场状态」这句话。本篇讲清均值回归的直觉与适用条件,用 z-score(标准分)写出完整的入场、出场、止损规则,说明它与布林带(Bollinger Bands)的等价关系,用一张对比表与动量策略正面对照,最后跑一个教学性回测。
本篇目标
- 能说清均值回归的直觉,以及它成立的前提条件(震荡市 / 宽基指数 vs 趋势市)。
- 能用 z-score 度量价格偏离程度,写出「z 低于 −1.5 入场、回到 0 出场、跌破 −3 止损」的完整规则。
- 能理解布林带与 z-score 的等价关系,并任选其一实现回归目标(滚动均值锚)。
- 能用一张对比表说清动量与均值回归的性格差异,按行情状态选工具。
- 能跑通一个带冷却期与止损的教学性均值回归回测,并诚实标注其局限。
核心概念
均值回归的直觉与适用条件
均值回归(Mean Reversion):价格围绕某个「锚」波动,偏离过远后有向锚回归的统计倾向。锚通常取一段时间的滚动均值。注意两个措辞:是「统计倾向」不是物理定律;锚本身也在移动,回归未必发生在你爆仓之前。
适用条件是本篇最重要的知识点。均值回归赚钱的前提是价格确实在「震荡」(Range-bound Market,区间震荡市),锚相对稳定。典型友好环境:宽基指数的横盘阶段、波动率低、没有强趋势的区间市;以及本身有强约束关系的资产(比如第 38 篇配对交易里的价差)。典型危险环境:单边趋势市(Trending Market)。趋势里「跌多了」只会「跌更多」,每次反弹都是出逃机会,策略会一路接飞刀(Catching a Falling Knife,指在下跌途中不断抄底)。
怎么判断当前更像震荡还是趋势?入门三招:肉眼看图,价格与 250 日均线是缠绕还是单边发散;统计检验,第 37 篇会讲平稳性(Stationarity)的正规做法;实操折中,给均值回归策略配硬止损与冷却期,用纪律兜底判断错误。本篇选第三招,并提前声明局限。
划重点:均值回归的亏损模式是「小赚多次 + 偶尔巨亏」,动量恰好相反;它死于趋势,动量死于反转。先问行情性格,再选策略工具。
z-score:把「偏离多远」变成一个数
z 分数(z-score,标准分)的定义:z =(当前价 − 均值)÷ 标准差(Standard Deviation),其中均值与标准差都用过去 N 日滚动窗口(第 5 篇讲过滚动计算)。白话:当前价偏离均值「几个标准差」。z = 0 表示正好在均线上;z = −2 表示比均值低了两个标准差。
用 z 而不是用价差的绝对值,好处是自带波动率归一:波动大的品种偏离 5 块钱不算稀奇,波动小的品种偏离 1 块钱已经很罕见,z 把它们拉到同一把尺子上。一套完整的教学规则(数字是示例,不是最优参数):
- 入场:此前空仓,且 z ≤ −1.5(跌得足够便宜)→ 买入;
- 出场:z ≥ 0(回到均值,回归兑现)→ 卖出;
- 止损:z ≤ −3,或自入场亏损超 8% → 认错离场,并进入冷却——直到 z 修复回 0 之前不再入场,避免在持续崩塌中反复抄底。
出场设在 z = 0 而不是 +1.5:回归的「兑现区」就在锚附近,非要等到 +1.5 再卖,多数时候会坐着过山车把利润送回去。这是新手最常见的贪心错误之一。
布林带:z-score 的图形化亲戚
布林带(Bollinger Bands,第 13 篇讲过)的定义:均线 ± k × 标准差。对照一下就会发现:z =(价 − 均线)÷ 标准差 ≤ −2 等价于 价 ≤ 均线 − 2 × 标准差,也就是触及下轨(k=2)。所以「z-score 策略」和「布林带触轨策略」数学上是同一件事的两种写法:布林带适合看图找直觉,z-score 适合写代码、调参数。两者的回归目标(锚)相同,都是滚动均值;你也可以换锚——滚动中位数更抗极端值,EMA 锚反应更快(第 10 篇)——但换了锚就换了策略性格,必须重新回测验证。
与动量策略的性格对比
把第 27 篇和本篇放在一起看,两种策略几乎是镜像:
| 维度 | 动量策略 | 均值回归策略 |
|---|---|---|
| 核心信仰 | 强者恒强,趋势延续 | 价格像皮筋,偏离会回归 |
| 赚什么钱 | 价格向新信息充分调整的过程 | 过度偏离后的修复 |
| 适合行情 | 单边趋势、板块轮动清晰 | 震荡市、锚稳定的宽基或篮子 |
| 典型持有期 | 数周到数月 | 数日到数周 |
| 胜率与盈亏比 | 低胜率、高盈亏比,靠大赢养小亏 | 高胜率、低盈亏比,怕一次巨亏 |
| 最大的敌人 | V 型反转(动量崩溃) | 单边趋势(接飞刀) |
| 常用工具 | N 日收益率排序、相对强弱 | z-score、布林带、RSI 超买超卖 |
| 止损的意义 | 锁住崩溃,保住本金 | 承认锚失效,防止扛单 |
经验提醒:正因为两者性格互补,成熟的做法常常是把它们组合起来——震荡期让均值回归干活,趋势确认后切换动量(第 33 篇市场状态识别会正式讲)。单独任何一个,都熬不过所有行情。
教学回测的边界
与第 27 篇同样的诚实声明:单标的、5 年、教学参数。特别地,为了在断网时也能演示「回归」这个机制,fallback 造的是一条带均值回归特征的模拟数据——它天然适合本策略,真实市场不会这么配合;接上真实行情(本篇用沪深300,2020–2024 包含明显的单边下跌段)后,策略可能持续止损,那正是「趋势市不适合均值回归」的现场教学,而不是代码写错了。
完整代码
依赖安装:uv pip install akshare pandas numpy。代码分三块:数据获取(含 fallback)、z-score 回测主循环、与买入持有对比。
# 块 1:获取宽基指数日收盘价(含本地造数据 fallback)
# uv pip install akshare pandas numpy
import numpy as np
import pandas as pd
def load_price() -> pd.Series:
"""拉沪深300日收盘价;akshare 不可用时造一条带均值回归特征的数据。"""
try:
import akshare as ak
df = ak.index_zh_a_hist(symbol="000300", period="daily",
start_date="20200101", end_date="20241231")
s = pd.Series(df["收盘"].values, index=pd.to_datetime(df["日期"]))
return s.astype(float)
except Exception:
# fallback:AR(1) 对数价格——围绕锚缠绕,天然震荡(教学专用,非真实行情)
rng = np.random.default_rng(11)
idx = pd.bdate_range("2020-01-01", "2024-12-31")
logp = np.zeros(len(idx))
for t in range(1, len(idx)):
logp[t] = 0.96 * logp[t - 1] + rng.normal(0, 0.013) # 偏离会被拉回
return pd.Series(100 * np.exp(logp), index=idx)
price = load_price()
print(f"共 {len(price)} 个交易日,"
f"起止:{price.index[0].date()} ~ {price.index[-1].date()}")
# 顺手预览最新一天的 z-score(块 2 会正式使用这套逻辑)
z_now = (price.iloc[-1] - price.rolling(20).mean().iloc[-1]) / price.rolling(20).std().iloc[-1]
print(f"最新 20 日窗口 z-score:{z_now:.2f}")
下面是本篇核心:完整的 z-score 均值回归回测,包含入场、出场、止损与冷却期。
# 块 2:z-score 均值回归回测
import numpy as np
import pandas as pd
N = 20 # 滚动窗口:20 个交易日的均值与标准差
Z_IN, Z_OUT = -1.5, 0.0 # 入场:z ≤ -1.5;出场:z 回到 0
STOP_Z, STOP_PCT = -3.0, 0.08 # 止损:z ≤ -3,或自入场亏损超 8%
FEE = 0.0005 # 单边手续费 0.05%,双边合计 0.1%(万 10)
def zscore_backtest(price: pd.Series) -> tuple[pd.Series, pd.DataFrame]:
"""z-score 均值回归。约定与第 27 篇相同:信号用截至昨收的数据,
按昨收价成交,当日损益归属昨收形成的仓位,无未来函数。"""
ma = price.rolling(N).mean() # 回归锚:20 日滚动均值
sd = price.rolling(N).std()
z = (price - ma) / sd # 偏离几个标准差
ret = price.pct_change().fillna(0)
nav, trades = {}, []
pos, entry, cooldown = 0, np.nan, False # pos:0 空仓 / 1 持有
for i in range(N, len(price)):
date, z_prev = price.index[i], z.iloc[i - 1] # 昨收可知的 z
fee_today = 0.0
if pos == 1 and (z_prev >= Z_OUT or z_prev <= STOP_Z
or price.iloc[i - 1] / entry - 1 <= -STOP_PCT):
hit_stop = (z_prev <= STOP_Z
or price.iloc[i - 1] / entry - 1 <= -STOP_PCT)
trades.append((price.index[i - 1],
"止损离场" if hit_stop else "回归离场",
round(price.iloc[i - 1], 2)))
pos, fee_today, cooldown = 0, FEE, hit_stop # 止损离场才进冷却
elif pos == 0 and not cooldown and z_prev <= Z_IN:
trades.append((price.index[i - 1], "入场",
round(price.iloc[i - 1], 2)))
pos, entry, fee_today = 1, price.iloc[i - 1], FEE
elif pos == 0 and cooldown and z_prev >= Z_OUT:
cooldown = False # 冷却解除:z 修复回均值再观察
nav[date] = (ret.iloc[i] * pos) - fee_today
nav = pd.Series(nav, name="均值回归").add(1).cumprod()
return nav, pd.DataFrame(trades, columns=["日期", "动作", "成交参考价"])
最后运行回测,与买入持有对比:
# 块 3:与买入持有基准对比,并查看交易明细
import pandas as pd
price = load_price()
nav, trades = zscore_backtest(price)
bench = price / price.iloc[0] # 买入持有基准
comp = pd.concat([nav, bench], axis=1).dropna()
comp = comp / comp.iloc[0] # 从首个共同交易日(窗口填满后)归一
comp.columns = ["均值回归", "买入持有"]
for name in comp.columns:
s = comp[name]
mdd = (s / s.cummax() - 1).min()
ann = s.iloc[-1] ** (252 / len(s)) - 1
print(f"{name}: 累计 {s.iloc[-1] - 1:.1%} | 年化 {ann:.1%} | 最大回撤 {mdd:.1%}")
print(f"共 {len(trades)} 笔交易,动作分布:")
print(trades["动作"].value_counts())
print("前 5 笔明细:")
print(trades.head().to_string(index=False))
代码逐段解释
1. 数据与 z 预览(块 1)。load_price() 优先用 akshare 拉沪深300(000300)2020-01-01 至 2024-12-31 的日收盘价——本系列统一回测区间;失败则造一条 AR(1) 对数价格:每一天的对数价格有 96% 向前一天「收回」,再用噪声推一把,于是价格围绕锚反复缠绕,天然适合演示回归机制。最后顺手算一个最新 z-score 让你对数字有感觉。输出形如:
共 1217 个交易日,起止:2020-01-02 ~ 2024-12-31
最新 20 日窗口 z-score:-0.87 # 数值仅为示意
2. 回测主循环(块 2)。滚动窗口先算出锚 ma、波动 sd 与 z 序列;循环从第 21 个交易日开始(窗口填满之后)。每天只看 z.iloc[i - 1]——昨日收盘时已知的 z,以及「昨收价相对入场价的浮亏」,这是本系列统一的防未来函数约定:信号在 t 日收盘产生,按 t 日收盘价成交,t+1 日起损益归属新仓位。三个分支:持有中且(z 修复 / z 极端 / 浮亏超 8%)→ 离场,止损离场额外进入冷却;空仓且未冷却且 z ≤ −1.5 → 入场;冷却中且 z 回到 0 → 解除冷却。所有成交都按单边扣 0.05% 手续费。三道纪律与第 27 篇一一对应:止损兜底判断错误,冷却防止反复抄底,出场规则本身(z 回 0 就走)就是持有期的体现。
3. 对比与交易明细(块 3)。基准是买入持有;两条净值从首个共同交易日(第 21 个交易日,窗口填满后)归一对比。输出形如:
均值回归: 累计 21.4% | 年化 4.0% | 最大回撤 -6.3%
买入持有: 累计 9.7% | 年化 1.9% | 最大回撤 -15.1% # 数值均为示意
共 14 笔交易,动作分布:
入场 7
回归离场 4
止损离场 3
前 5 笔明细:
日期 动作 成交参考价
2020-03-09 入场 3851.53
2020-03-23 止损离场 3518.20
2020-05-29 入场 3715.66
2020-07-06 回归离场 3892.10
2020-09-30 入场 3985.42
读明细表的方法比看累计收益更有价值:数一数「止损离场」的占比。fallback 数据天然震荡,止损应该很少;换成真实行情,若止损占比过半,说明这段行情是趋势市,本策略本就不该开机——这正是「先判断行情性格,再选策略」的量化表达。
常见坑与小练习
常见坑:
- 在趋势市里把回调当回归。现象:策略持续亏损、一买就套。原因:锚本身在快速下移,z 长期为负不代表便宜,代表趋势向下。避免:加 250 日均线的方向过滤(如只在价格位于年线附近或上方时做回归),并保留硬止损与冷却期。
- 出场太贪。现象:−1.5 入场,非要等 +1.5 再卖,结果来回坐过山车。原因:把「对称」错当成了「纪律」。避免:回归交易的兑现区就在锚附近,z 回 0 就走,把趋势里的利润留给动量策略。
- 滚动窗口敏感。现象:N=20 赚钱,N=15 或 N=30 就亏。原因:窗口决定锚的松紧,参数孤证不可信。避免:多窗口测试看结论是否稳定,按第 25 篇的 Walk-Forward 思路做样本外验证。
- 手续费磨掉薄利。现象:信号很勤快,扣完费全白干。原因:均值回归是高胜率、低盈亏比结构,每笔薄利最怕固定成本。避免:统计平均每笔毛利,若不足双边手续费的两倍,这套规则直接不可用。
- 用含当日收盘的 z 决定当日成交。现象:回测虚高。原因:当日收盘的 z 要到收盘才存在,盘中不可能用它下单。避免:信号统一取
iloc[i - 1](本篇代码的做法),并在注释里写清成交假设。
小练习:
- 把入场阈值改成 −2.0 与 −1.0 各跑一遍,对比交易次数、累计收益与止损占比。
思路提示:只改
Z_IN;阈值越极端交易越少、单笔「便宜程度」越高,但错过好价格的概率也越大。 - 把回归锚换成 250 日均线(长锚)再跑一遍,观察交易频率骤降后的结果。
思路提示:
ma = price.rolling(250).mean(),同步把回测起点推迟到第 251 个交易日,避开 NaN。 - 给入场加「年线过滤」:仅当价格高于 250 日均线 × 0.95 时允许做多回归。
思路提示:这是最朴素的「震荡才做」近似;对比加过滤前后的最大回撤与止损次数变化。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。