上一篇我们学会了把多个低相关策略组合起来,让净值曲线更平滑——那是策略篇的收官。从这一篇起,我们进入进阶篇:不再问「怎么做」,开始问「凭什么」。凭什么均值回归策略敢在跌了的时候买?凭什么你回测里的夏普不是运气?回答这两个问题,需要两件统计学武器:平稳性与假设检验。
先说一个真实场景:你发现某 ETF 价格「跌多了总会回来」,兴冲冲写了个均值回归策略。但它有时灵有时不灵——原因很可能是你从没验证过这个序列到底「回不回来」。平稳性检验就是干这个的;而当你看到某个参数组合回测显著跑赢大盘时,假设检验会告诉你:这也可能纯属巧合。
本篇我们做四件事:用图形和 ADF 检验理解平稳性;对比价格序列与收益率序列的平稳性;用 JB 检验看清金融收益的「胖尾」;最后亲手复现一次 p-hacking,看看毫无规律的随机数据是怎么「显著」的。
本篇目标
- 能用白话向别人解释平稳性,并从走势图上分辨「平稳」与「不平稳」的长相
- 能用 statsmodels 的
adfuller做 ADF 检验,并正确解读 p 值(知道原假设是什么) - 能动手验证「价格不平稳、收益率近似平稳」,并说出这对建模意味着什么
- 能用 JB 检验判断收益是否服从正态分布,理解胖尾为什么危险
- 能解释 p-hacking 在量化中的三种典型表现,并在自己的回测里设防
核心概念
平稳性:序列的「性格稳定」
平稳性(Stationarity)白话版:一条序列的平均水平和波动幅度不随时间漂移。它在任何时候看起来都像它自己——均值在某个水平附近晃,不会越走越远。
图形直觉:往一杯水里滴墨水再搅匀,水色深浅始终在同一个范围内波动——这是平稳(像白噪声);而往杯子里持续倒墨水,颜色越来越深——这是不平稳(像价格随机游走)。均值回归策略的本质赌注就是「这条序列是平稳的、偏离了会回来」;如果序列本身不平稳,那不叫「偏离」,那叫「新常态」,你越抄底越深。
ADF 检验:机器替你判断性格
肉眼判断容易自欺,统计学给了标准工具:ADF 检验(Augmented Dickey-Fuller Test)。它的原假设(H0,Null Hypothesis)是「序列存在单位根,即不平稳」;备择假设是「序列平稳」。
关键在 p 值(p-value)的解读方向:p < 0.05 时拒绝原假设,认为序列平稳。注意这里和很多检验相反——对 ADF 来说,p 值小是好消息(平稳),p 值大说明你没能证明它平稳。第一次用最容易在这里搞反,务必记牢。
为什么建模都用收益率而不是价格
把价格序列和它的日收益率序列各做一次 ADF,你会得到一个经典结论:价格通常不平稳(p 值很大),收益率近似平稳(p 值极小)。原因很朴素:价格会随经济增长、通货膨胀一路漂移,均值根本「定」不下来;而收益率每天围绕 0 附近波动,看不出明显的漂移趋势。
这就是为什么前面所有篇目里,指标、回测、相关性全都建立在收益率上——不是习惯,是统计上只有平稳序列才谈得上「用历史估计未来」。对不平稳的序列算均值、算相关,得到的数字会随样本区间大幅变化,基本没有意义。
正态性与胖尾:钟形曲线骗了你
很多教科书公式(包括夏普比率的显著性检验)默认收益率服从正态分布(Normal Distribution,钟形曲线)。现实呢?金融收益通常尖峰胖尾(Fat Tails):中间比正态更高、两端的极端值比正态多得多。用 JB 检验(Jarque-Bera Test,原假设「数据来自正态分布」)一测,日收益几乎必然拒绝正态——这不是你的数据有问题,是正态假设有问题。
胖尾为什么危险:它意味着「百年一遇」的暴跌在金融市场里可能几年就来一次。第 35 篇风控规则之所以必须存在,就是因为胖尾让极端亏损的概率远超钟形曲线的承诺。
p-hacking:显著的假象
假设检验还有个不太光彩的亲戚:p-hacking。白话:测的东西足够多,总会有「显著」的。你想啊,一个毫无胜率的随机策略,跑出「显著」结果的概率约 5%(这正是 0.05 这个阈值的含义);那你测 100 个参数组合,期望就有约 5 个「显著」——它们全是运气。
量化里的典型表现有三种:把几百个参数都试一遍挑最好的;把回测区间切成几十段挑表现好的那段讲;一次测几十个因子挑 IC 高的。防法不是不测,而是清醒地数:你总共试了多少次,并把没挑中的那些也如实摆出来(第 25 篇的 Walk-Forward 就是结构性解法之一)。
划重点:p < 0.05 的意思是「如果真没效果,出现这种结果的概率不足 5%」,而不是「有效果的概率是 95%」。这两个说法差之毫厘,谬以千里。
完整代码
依赖安装:uv pip install numpy pandas matplotlib statsmodels akshare。真实数据拉沪深300 指数,接口不可用时自动降级为造数据。
# —— 第一块:白噪声 vs 随机游走,图形直觉 + ADF 对比 ——
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.stattools import adfuller
rng = np.random.default_rng(7)
n = 500
white_noise = rng.normal(0, 1, n) # 白噪声:平稳的教科书样本
random_walk = np.cumsum(rng.normal(0, 1, n)) # 随机游走:每步在上一步基础上累加
fig, axes = plt.subplots(1, 2, figsize=(10, 3))
axes[0].plot(white_noise, color="seagreen")
axes[0].set_title("白噪声(平稳):在 0 附近晃")
axes[1].plot(random_walk, color="indianred")
axes[1].set_title("随机游走(不平稳):越走越远")
plt.tight_layout()
plt.show()
def adf_report(name: str, x: np.ndarray) -> None:
"""打印一条序列的 ADF 检验结论"""
stat, pvalue, *_ = adfuller(x) # 返回值里第二个就是 p 值
verdict = "平稳" if pvalue < 0.05 else "不平稳"
print(f"{name}:ADF统计量={stat:.3f},p值={pvalue:.4f} → 判定:{verdict}")
adf_report("白噪声", white_noise)
adf_report("随机游走", random_walk)
# —— 第二块:真实数据的价格 vs 收益率平稳性对比 ——
import akshare as ak
try: # 优先真实数据
df = ak.stock_zh_index_daily(symbol="sh000300")
df = df.rename(columns={"date": "日期", "close": "收盘"})[["日期", "收盘"]]
df["日期"] = pd.to_datetime(df["日期"])
df = df.set_index("日期").loc["2020-01-01":"2024-12-31"]
data_source = "akshare 沪深300 指数"
except Exception: # 接口不可用时的降级方案
idx = pd.bdate_range("2020-01-01", periods=1218)
close = 4000 * np.exp(np.cumsum(rng.normal(0.0001, 0.011, 1218)))
df = pd.DataFrame({"收盘": close}, index=idx)
data_source = "本地造数据(几何随机游走)"
close = df["收盘"]
log_ret = np.log(close / close.shift(1)).dropna() # 对数收益率
print(f"数据源:{data_source},共 {len(close)} 个交易日")
adf_report("价格序列", close.values)
adf_report("对数收益率序列", log_ret.values)
# —— 第三块:收益率的胖尾画像 + JB 检验 ——
from statsmodels.stats.stattools import jarque_bera
jb_stat, jb_p, skew, kurt = jarque_bera(log_ret) # 返回:统计量、p值、偏度、峰度
print(f"偏度={skew:.3f}(负值代表左尾更肥,暴跌更猛)")
print(f"峰度={kurt:.3f}(正态分布为 0,超过 0 说明尾部更肥)")
print(f"JB统计量={jb_stat:.1f},p值={jb_p:.4g}")
print("→ p<0.05,拒绝正态分布假设:这条收益是胖尾的" if jb_p < 0.05
else "→ 无法拒绝正态假设")
# 直方图叠一条正态曲线,胖尾肉眼可见
mu, sigma = log_ret.mean(), log_ret.std()
xs = np.linspace(log_ret.min(), log_ret.max(), 300)
normal_pdf = np.exp(-((xs - mu) ** 2) / (2 * sigma**2)) / (sigma * np.sqrt(2 * np.pi))
plt.figure(figsize=(8, 3.5))
plt.hist(log_ret, bins=60, density=True, alpha=0.6, label="日收益分布")
plt.plot(xs, normal_pdf, color="crimson", lw=2, label="同均值方差的正态曲线")
plt.legend()
plt.title("收益率直方图 vs 正态曲线:两头翘起来就是胖尾")
plt.tight_layout()
plt.show()
# —— 第四块:p-hacking 现场演示:随机数据里的「显著策略」 ——
trials, n_days, hits = 1000, 1000, 0 # 测 1000 个「策略」
for _ in range(trials):
r = rng.normal(0, 0.01, n_days) # 纯随机收益:期望 0,毫无 edge
t_stat = r.mean() / (r.std() / np.sqrt(n_days)) # 均值的 t 统计量
if abs(t_stat) > 1.96: # |t|>1.96 约等于 p<0.05
hits += 1
print(f"{trials} 个纯随机「策略」中,有 {hits} 个通过了显著性检验"
f"(占比 {hits / trials:.1%},理论值约 5%)")
代码逐段解释
- 第一块图形直觉:白噪声是
np.random.normal直接生成、每一步与过去无关;随机游走是np.cumsum把随机步长累加起来——注意它俩用的是同一批随机数,差别只在于「累不累加」。跑完 ADF 你会看到教科书式的对照。 示例输出(示意):
白噪声:ADF统计量=-18.452,p值=0.0000 → 判定:平稳
随机游走:ADF统计量=-1.528,p值=0.5201 → 判定:不平稳
注意随机游走的 p 值是 0.52——远大于 0.05,在 ADF 里意味着「没证据说它平稳」,方向千万别记反。
- 第二块真实数据对比:
try/except先走 akshare,接口失联就降级为造数据并明确告知数据源,保证代码总能跑完。对数收益率用log(close / close.shift(1))一行算出。无论哪个数据源,结论方向一致:价格不平稳、收益率平稳。 示例输出(示意,akshare 版本):
数据源:akshare 沪深300 指数,共 1213 个交易日
价格序列:ADF统计量=-1.214,p值=0.6684 → 判定:不平稳
对数收益率序列:ADF统计量=-17.014,p值=0.0000 → 判定:平稳
- 第三块胖尾画像:
jarque_bera一次返回四个值。偏度为负说明左尾更肥(暴跌比暴涨猛);峰度远超 0 说明两头翘;JB 的 p 值小到接近 0,正态假设被干脆利落地拒绝。直方图上你会看到:中间比红线上凸、两端比红线厚——那就是胖尾本尾。 示例输出(示意):
偏度=-0.417(负值代表左尾更肥,暴跌更猛)
峰度=5.832(正态分布为 0,超过 0 说明尾部更肥)
JB统计量=903.4,p值=4.1e-197
→ p<0.05,拒绝正态分布假设:这条收益是胖尾的
划重点:看到「百年一遇」级别的极端行情别慌张地以为数据错了——在胖尾世界里,极端事件比正态分布承诺的更常见,这正是第 35 篇风控规则存在的统计学理由。
- 第四块 p-hacking 演示:每个「策略」都是期望为 0 的纯随机收益,理论上长期没有任何赚钱能力。但 1000 个里总会有几十个的均值 t 统计量越过了 1.96 这条线。如果你只展示这几十个,就能「证明」随机数据里藏着圣杯。 示例输出(示意):
1000 个纯随机「策略」中,有 47 个通过了显著性检验(占比 4.7%,理论值约 5%)
常见坑与小练习
常见坑
- 把 ADF 的 p 值方向记反。现象:看到 p=0.52 高兴地宣布「序列平稳」。原因:多数检验里 p 小才显著,但 ADF 的原假设是「不平稳」,p 小是拒绝它。避免:每次写 ADF 结论前先默念一遍原假设,或者直接封装成
adf_report这种带判定的函数。 - 对不平稳的价格序列直接算统计量。现象:同一个指标换个时间窗口就面目全非。原因:价格均值随趋势漂移,样本均值没有意义。避免:所有统计刻画与建模都在收益率(或价差)这类平稳对象上做。
- 以为 JB 检验没过就是数据坏了。现象:反复清洗数据试图让收益「变正态」。原因:胖尾是金融数据的固有属性,不是脏数据。避免:接受非正态,改用对胖尾稳健的指标(如把最大回撤、VaR 一并看),而不是假装钟形曲线成立。
- 只报告跑赢的那次回测。现象:朋友圈里每个策略都年化跑赢大盘。原因:试错次数被藏起来了,显著的门槛被「试得够多」攻破。避免:记录完整实验日志(测了多少组、每组结果如何),报告时附上「总共尝试次数」。
小练习
- 把第一块的随机游走改成「带漂移的随机游走」:
np.cumsum(rng.normal(0.001, 1, n)),先看图再跑 ADF,观察 p 值变化。提示:漂移让序列更有方向感,ADF 更没机会拒绝原假设,p 值只会更大。
- 对第二块的收益率再算一次「5 年滚动 250 日窗口的均值」,画出来看它是否大致围绕 0 波动;再对价格做同样的事,对比两者的稳定程度。
提示:收益率的滚动均值在 0 附近小幅抖动,价格的滚动均值一路爬升——这就是平稳与不平稳的量化版长相。
- 把第四块的
trials改成 10000,观察「显著」占比是否仍然约 5%;再想一想:如果你真实测了 200 个参数组合、只挑最好的 3 个展示,别人该如何识破?提示:占比会稳定在 5% 附近——试得越多、假显著越多;识破的方法就是让对方交代完整的尝试清单。
风险提示:本文策略仅用于教学,不构成投资建议,实盘前请充分测试。