本篇位置 第一部分「地基」的最后一篇。后面每一个指标、每一份回测报告里的数字,都建立在这一篇的统计量上
用到的数据 BTCUSDT 现货日线(2017-08 至 2026-08);SPY 日线、AAPL 日线和分红(2016-09 至 2026-09)
动手 talab.stats 模块:收益率、年化、方差比、尾部统计、自相关、打乱检验,附 12 个测试
读完你能 选对收益率的算法;正确地年化波动率;用数据说明正态分布在哪里失效、失效多少;判断一个「显著」的自相关是真规律还是少数几天造成的;知道为什么指标都建立在收益率而不是价格上

一、先做一个决定

现在是 2022 年 6 月 12 日,BTC 日线刚刚收盘,收在 26,574.53

BTCUSDT 日线,2022-04-01 至 2022-06-12

过去两个月很难熬。5 月初 Terra/LUNA 崩盘,BTC 从 4 万跌到 3 万附近,横了三周。最近四天又连续收阴,今天收在两个月以来的最低点。

你持有一笔 BTC 多单,用了 5 倍杠杆。5 倍杠杆意味着价格下跌大约 20% 就会被强制平仓(第 24 篇会精确计算强平价)。

你打开自己的风控表格。表格里有一个模型:用过去 365 天的日收益率,算出均值和标准差,假设收益率服从正态分布,然后计算「单日下跌 15% 以上」的概率。

过去 365 天的日收益率分布和正态分布

表格给出的结果是:

过去 365 天:日均 -0.0161%,日波动率 3.5648%
z = -4.20,正态概率 1.315e-05,约 208 年一次

208 年一次。 按这个模型,一天跌 15% 的风险可以忽略,今晚可以安心睡觉。

你会怎么做?

  • A. 模型说 208 年一次,维持 5 倍杠杆不变
  • B. 把杠杆降到 2 倍,但不平仓
  • C. 不相信这个数字,平掉杠杆,只留现货

先把选择写下来,再往下读。 第四节会揭晓第二天发生了什么。

不过在揭晓之前,请注意一件事:你手里其实有一个比模型更直接的证据。在 2022 年 6 月 12 日之前,BTC 单日跌幅超过 15% 的日子

2017-09-14   -19.16%
2018-01-16   -19.50%
2018-02-05   -15.21%
2020-03-12   -39.50%

五年里四次。一个说「208 年一次」,一个说「五年四次」。两者相差大约 170 倍,总有一个是错的。

这一篇就要搞清楚:错在哪里,错了多少,为什么会错。 为此我们要从最基础的问题开始:涨跌到底该怎么量。


二、收益率:用什么尺子量涨跌

打个比方:一条河

这一篇会反复用到一个比方:一条河

  • 河的水位,就是价格。今天 26,574,明天 22,487。
  • 水位每天涨落多少,就是收益率。
  • 这条河平常每天涨落的幅度,就是波动率。
  • 偶尔一次的洪水,就是收益率分布的尾部。
  • 水文站说的「百年一遇」,就是决策点里那个「208 年一次」。

水文工程师几乎从不直接研究「水位」本身。水位受河床、季节、上游水库等太多因素影响,今年的平均水位和十年前可能完全不同。他们研究的是水位的变化:每天涨了多少、落了多少、多大的涨幅多久出现一次。

交易也一样。我们要研究的,是价格的变化。

为什么不直接用价格差

最直接的量法是价格差:今天收盘减昨天收盘。

2022 年 6 月 13 日,BTC 从 26,574.53 跌到 22,487.41,跌了 4,087.12 美元。2020 年 3 月 12 日,BTC 从 7,934.52 跌到 4,800.00,跌了 3,134.52 美元。

按价格差,2022 年那天跌得更多。但你如果在 2020 年 3 月 11 日收盘时买入 1 万美元的 BTC,第二天剩下 6,050 美元;在 2022 年 6 月 12 日收盘时买入 1 万美元,第二天剩下 8,462 美元。2020 年那天对你的伤害大得多。

这正是第 4 篇讲过的问题:价格差受价格高低的影响,不同时期、不同标的之间没法比较。我们需要一个按比例衡量涨跌的量。它有两种算法。

简单收益率

简单收益率是最常见的算法:

r = 今天收盘 ÷ 昨天收盘 − 1

2022 年 6 月 13 日:

r = 22,487.41 ÷ 26,574.53 − 1 = 0.846202 − 1 = −15.3798%

它的含义非常直接:你持有 1 美元,一天后变成 1 + r 美元。

对数收益率

对数收益率是另一种算法:

R = ln(今天收盘 ÷ 昨天收盘)

同一天:

R = ln(0.846202) = −16.6998%

同一天的涨跌,两种算法给出了 −15.38% 和 −16.70% 两个不同的数字。它们的关系是:

R = ln(1 + r),反过来 r = e^R − 1

下表是两者在不同涨跌幅下的差距:

简单收益率 r 对数收益率 R = ln(1 + r) 差距
+1% +0.995% 很小
−1% −1.005% 很小
+5% +4.879% 0.12 个百分点
−5% −5.129% 0.13 个百分点
+15% +13.976% 1.02 个百分点
−15% −16.252% 1.25 个百分点
+100% +69.315% 30.7 个百分点
−50% −69.315% 19.3 个百分点

两条规律:

  1. 涨跌幅小时,两者几乎一样。 SPY 绝大多数日子涨跌在 1% 以内,用哪种都差不多。
  2. 涨跌幅大时,差距迅速拉开,而且对数收益率总是比简单收益率小。 上涨时对数收益率的绝对值更小,下跌时对数收益率的绝对值更大。

最后两行值得多看一眼:涨 100% 和跌 50%,对数收益率是 +69.3% 和 −69.3%,正好相反。 这符合直觉:从 100 涨到 200,再从 200 跌回 100,你回到了原点。简单收益率却说「涨了 100%、跌了 50%」,看起来涨的比跌的多。

既然有两种算法,它们各有什么用?

对数收益率的长处:时间上可以相加

来看 BTC 历史上最剧烈的两天:

2020-03-11 收盘    7,934.52
2020-03-12 收盘    4,800.00
2020-03-13 收盘    5,578.60

两天的简单收益率是 −39.50% 和 +16.22%。把它们加起来是 −23.28%。 但两天实际的总收益率是:

5,578.60 ÷ 7,934.52 − 1 = −29.69%

相加的结果和实际差了 6 个百分点。简单收益率不能直接相加,必须连乘

(1 − 0.3950) × (1 + 0.1622) − 1 = 0.6050 × 1.1622 − 1 = −29.69%

对数收益率是 −50.26% 和 +15.03%。把它们加起来是 −35.23%,再换回简单收益率:

e^(−0.3523) − 1 = −29.69%

完全正确。为什么?因为对数把乘法变成了加法:

ln(P₂ ÷ P₀) = ln(P₂ ÷ P₁ × P₁ ÷ P₀) = ln(P₂ ÷ P₁) + ln(P₁ ÷ P₀)

所以一段时间的对数收益率,等于其中每一天的对数收益率之和。 这个性质非常有用:

  • 算 5 天、20 天的收益率,只要把日对数收益率加起来(滚动求和)
  • 算一段时间的平均增长速度,只要对日对数收益率求平均
  • 下一节讲波动率「按时间的平方根放大」,推导也依赖「多天的收益率 = 每天收益率之和」

简单收益率的长处:资产之间可以加权

现在换一个方向:同一天,多个资产。

2025 年 4 月 9 日,美国宣布暂缓部分新关税,美股大涨。当天的收盘价:

4 月 8 日收盘 4 月 9 日收盘 简单收益率 对数收益率
SPY 496.48 548.62 +10.5019% +9.9863%
AAPL 172.42 198.85 +15.3288% +14.2617%

假设你在 4 月 8 日收盘时把 1 万美元分成两半,5,000 买 SPY,5,000 买 AAPL。第二天收盘时:

5,000 × 1.105019 + 5,000 × 1.153288 = 5,525.10 + 5,766.44 = 11,291.54

组合收益率 = +12.9154%

用简单收益率加权平均:0.5 × 10.5019% + 0.5 × 15.3288% = +12.9154%。完全一致。

用对数收益率加权平均:0.5 × 9.9863% + 0.5 × 14.2617% = 12.1240%,换回简单收益率是 e^0.121240 − 1 = +12.8896%。差了 0.026 个百分点。

原因很简单:组合的价值是各个持仓价值的,而简单收益率正好是「价值按比例变化」,所以可以直接按权重加。对数收益率没有这个性质。

什么时候用哪一种

场景 用哪种 原因
报告一笔交易赚了多少 简单收益率 就是你账户实际的变化
计算组合一天的收益 简单收益率 可以按权重相加
把日收益率累积成周、月、年 对数收益率(或简单收益率连乘) 可以直接相加
计算波动率、做统计检验 两者都常用;日线上差别很小 对数收益率在推导上更方便
回测引擎每天更新账户价值 简单收益率 第 27 篇

实践中最容易犯的错误,是把简单收益率直接相加来算多天的总收益。涨跌幅小的时候误差不明显,所以很难被发现;遇到剧烈行情,就会像 2020 年 3 月那样差出 6 个百分点。

算术平均会骗人

还有一个更隐蔽、影响更大的问题。

假设一个资产每天交替涨 10%、跌 10%。它的日均简单收益率是 (10% − 10%) ÷ 2 = 0。看起来不赚不亏。

实际上,两天之后:1.10 × 0.90 = 0.99,亏了 1%。一百天(五十轮)之后:0.99^50 = 0.605,亏了 39.5%

平均收益率是 0,钱却在持续减少。在真实数据上看看这个差距有多大:

for k, r in rets.items():
    n = len(r)
    naive = (1 + r.mean()) ** n
    actual = 1 + S.total_return(r)
    print(f"{k:4s} 日均 {r.mean():.4%}  按日均复利 {naive:7.2f} 倍  实际 {actual:6.2f} 倍  "
          f"对数日均 {logs[k].mean():.4%}  日均 − 方差/2 {r.mean() - r.var() / 2:.4%}")

retslogs 是三个标的的简单收益率和对数收益率,第七节会给出准备数据的完整代码。)

SPY  日均 0.0566%  按日均复利    4.14 倍  实际   3.52 倍  对数日均 0.0501%  日均 − 方差/2 0.0501%
AAPL 日均 0.1175%  按日均复利   19.11 倍  实际  12.54 倍  对数日均 0.1007%  日均 − 方差/2 0.1007%
BTC  日均 0.1504%  按日均复利  142.84 倍  实际  18.34 倍  对数日均 0.0881%  日均 − 方差/2 0.0889%

BTC 在 3,301 天里,日均简单收益率是 0.1504%。如果每天都稳稳地涨 0.1504%,九年下来是 142.84 倍。实际只有 18.34 倍,相差将近 8 倍。

差距从哪里来?看最后两列。真正决定长期增长的,是对数收益率的均值,它等于每天的「复利增长速度」。而对数收益率的均值,近似等于:

对数收益率均值 ≈ 简单收益率均值 − 方差 ÷ 2

BTC:0.1504% − (3.5082%)² ÷ 2 = 0.1504% − 0.0615% = 0.0889%,和实际的 0.0881% 非常接近。

这个公式为什么成立?回到每天涨跌 10% 的例子。(1 + a)(1 − a) = 1 − a²,每两天损失 a²,平均每天损失约 a² ÷ 2。而这串收益率的方差正好是 a²。波动本身在侵蚀复利增长,波动越大,侵蚀越多。这个现象叫波动率拖累(volatility drag)。

SPY 的日波动率只有 1.13%,拖累很小:方差的一半是 0.0064%,4.14 倍变成 3.52 倍。BTC 的日波动率是 3.51%,方差的一半是 0.0615%,吃掉了日均收益的 41%。

这件事对交易有两个直接的影响:

  1. 看回测报告时,要看复合增长率,不要看平均收益率。 第 29 篇会专门讲。
  2. 加杠杆会成倍放大波动率拖累。 2 倍杠杆让日均收益变成 2 倍,方差却变成 4 倍,拖累也变成 4 倍。第 24 篇会用这个公式算出杠杆的上限。

✋ 小检查 1

一个资产第一天涨 20%,第二天跌 20%。

  • (a) 两天的简单收益率之和是多少?实际总收益率是多少?
  • (b) 用对数收益率验证你的答案。

答案在文末。

⚠️ 坦白一个容易混淆的地方。 这一节说「对数收益率可以相加」,又说「组合收益率要用简单收益率」。很多人第一次接触会觉得矛盾:到底能不能加?记住一句话就够了:沿着时间加,用对数;在资产之间加,用简单。 实在记不住,就永远用最笨的办法:直接算账户价值的变化。


三、波动率:这条河平常每天涨落多少

手算一个标准差

波动率,就是收益率的标准差。它衡量的是:每天的收益率,通常偏离平均值多远。

用决策点之后的 5 天来手算一遍。这是 2022 年 6 月 13 日到 17 日 BTC 的日收益率(单位:%):

日期 收益率
6-13 −15.3798
6-14 −1.5609
6-15 +2.0207
6-16 −9.6636
6-17 +0.3309

第一步:求均值。

(−15.3798 − 1.5609 + 2.0207 − 9.6636 + 0.3309) ÷ 5 = −24.2527 ÷ 5 = −4.8506

第二步:每天减去均值,得到偏差。

日期 收益率 偏差 d 偏差的平方 d²
6-13 −15.3798 −10.5293 110.8657
6-14 −1.5609 +3.2897 10.8220
6-15 +2.0207 +6.8713 47.2142
6-16 −9.6636 −4.8131 23.1658
6-17 +0.3309 +5.1814 26.8471

偏差有正有负,直接相加总是 0,所以要先平方。

第三步:平方和除以 (n − 1),得到方差。

平方和 = 218.9148

方差 = 218.9148 ÷ (5 − 1) = 54.7287

为什么除以 4 而不是 5?因为均值本身是用这 5 个数算出来的,偏差被「拉向」了 0,平方和会系统性地偏小。除以 n − 1 正好修正这个偏差。样本很大时(比如 3,000 天),除以 n 还是 n − 1 几乎没有区别。

第四步:开平方,得到标准差。

√54.7287 = 7.3979%

这 5 天,BTC 的日波动率是 7.40%。而决策点那天,过去 365 天的日波动率是 3.56%。波动率在几天之内翻了一倍。 记住这一点,本节最后会用到。

用代码验证:

five = rets["BTC"].loc["2022-06-13":"2022-06-17"]
print((five * 100).round(4))
print("均值", round(five.mean() * 100, 4), "标准差", round(five.std() * 100, 4))
time
2022-06-13 00:00:00+00:00   -15.3798
2022-06-14 00:00:00+00:00    -1.5609
2022-06-15 00:00:00+00:00     2.0207
2022-06-16 00:00:00+00:00    -9.6636
2022-06-17 00:00:00+00:00     0.3309
Name: close, dtype: float64
均值 -4.8506 标准差 7.3979

pandas 的 std() 默认就是除以 n − 1。

年化:为什么乘以时间的平方根

日波动率 3.5%,一年的波动率是多少?

直觉的答案是乘以 365。这是错的。 正确答案是乘以 √365 ≈ 19.1。

先看一个最简单的模型:每天等可能地涨 1% 或跌 1%,每天互相独立(为了简单,这里把涨跌直接相加)。

  • 1 天:结果是 +1% 或 −1%,标准差 1%。
  • 2 天:可能的结果是 +2%、0、0、−2%,各占 1/4。方差 = (4 + 0 + 0 + 4) ÷ 4 = 2,标准差 √2 ≈ 1.41%,不是 2%。
  • 4 天:标准差 2%
  • 16 天:标准差 4%
  • 64 天:标准差 8%

每天等可能涨 1% 或跌 1%,累计涨跌的分布

天数变成 4 倍,分布的宽度只变成 2 倍。

为什么不是按天数线性增长?因为涨和跌会互相抵消。2 天里有一半的可能是一涨一跌,结果是 0。天数越多,抵消得越多。要连续 64 天全部上涨,才能涨到 64%,这种可能性是 2^64 分之一。

严格的推导只需要一个统计性质:互相独立的随机变量,和的方差等于方差的和。 设每天收益率的方差是 σ²,n 天的收益率是 n 个日收益率之和(对数收益率正好满足),所以:

n 天收益率的方差 = n × σ²

n 天收益率的标准差 = √n × σ

所以年化波动率 = 日波动率 × √(一年的交易日数)

注意「一年的交易日数」:

  • 美股:一年约 252 个交易日(去掉周末和约 9 个假日)
  • 加密:全年无休,365 天
for k, r in rets.items():
    wrong = 365 if ppy[k] == 252 else 252               # 故意用错的天数,看看差多少
    print(f"{k:4s} 日波动率 {r.std():.4%}  ×√{ppy[k]} = {S.annualize_vol(r.std(), ppy[k]):.1%}   "
          f"(错用 √{wrong}: {S.annualize_vol(r.std(), wrong):.1%})")
SPY  日波动率 1.1347%  ×√252 = 18.0%   (错用 √365: 21.7%)
AAPL 日波动率 1.8346%  ×√252 = 29.1%   (错用 √365: 35.1%)
BTC  日波动率 3.5082%  ×√365 = 67.0%   (错用 √252: 55.7%)

BTC 的年化波动率是 67.0%,SPY 是 18.0%,约 3.7 倍。 如果你拿 BTC 的日数据乘以 √252,会得到 55.7%,把 BTC 的波动率低估了 17%。反过来把美股乘以 √365,会高估 21%。比较美股和加密的波动率时,这是最常见的错误。

年化波动率的实际用法,是反过来算短期:SPY 2026 年以来的年化波动率是 13.4%,那么日波动率约 13.4% ÷ √252 = 0.844%。SPY 在 2026 年 9 月 15 日收盘价 757.39,一个标准差的日内涨跌幅约 6.39 美元。第 15 篇的 ATR 和第 23 篇的止损距离,都会用到这种换算。

✋ 小检查 2

一个美股标的和一个加密货币的日波动率都是 2%。

  • (a) 它们的年化波动率分别是多少?
  • (b) 如果你只知道某个资产的波动率是 5%,它的年化波动率大约是多少?

平方根法则成立的前提

回头看推导,里面有一个关键假设:每天的收益率互相独立

如果今天涨,明天倾向于继续涨(涨跌会延续),那么多天的收益率会比独立时走得更远,n 天的波动率大于 √n × σ。如果今天涨,明天倾向于跌回来(涨跌会反转),多天的波动率就小于 √n × σ。

所以可以反过来用它做检验。定义方差比

方差比 VR(k) = k 天收益率的方差 ÷ (k × 1 天收益率的方差)

  • VR = 1:符合独立假设,平方根法则成立
  • VR > 1:涨跌倾向于延续
  • VR < 1:涨跌倾向于反转
horizons = {"SPY": [5, 21, 63], "AAPL": [5, 21, 63], "BTC": [7, 30, 90]}
for k in rets:
    row = {h: round(S.variance_ratio(logs[k], h), 2) for h in horizons[k]}
    print(k, row)
SPY {5: 0.85, 21: 0.82, 63: 0.6}
AAPL {5: 0.9, 21: 0.89, 63: 0.85}
BTC {7: 1.01, 30: 1.14, 90: 1.36}

(美股取 1 周、1 个月、1 个季度,分别是 5、21、63 个交易日;BTC 取 7、30、90 天。)

  • SPY:方差比明显小于 1。季度收益率的实际波动,只有「按平方根法则推算」的 √0.60 ≈ 77%。
  • AAPL:略小于 1。
  • BTC:周线接近 1,时间越长越大于 1。季度收益率的实际波动,是推算值的 √1.36 ≈ 117%。

看起来很有规律:SPY 倾向于反转,BTC 倾向于延续。先别急着下结论。 第五节会用同一批数据告诉你,这种看起来很清楚的规律,有多大一部分可能来自少数几个月。现在只需要记住:平方根法则是一个近似,它的准确程度取决于收益率是否独立。 用它年化,总是可以;但要知道长周期上可能有 20% 到 30% 的误差。

波动率本身在变

还有一个更重要的事实。前面算的 3.51%、1.13%,是整段样本的波动率。把它按年拆开:

table = pd.DataFrame({k: rets[k].groupby(rets[k].index.year).std() * math.sqrt(ppy[k]) for k in rets})
print((table * 100).round(1))
       SPY  AAPL    BTC
2016   9.2  15.4    NaN
2017   6.8  17.6  110.6
2018  17.1  28.7   83.0
2019  12.5  26.1   68.6
2020  33.5  46.6   75.5
2021  13.1  25.1   81.1
2022  24.3  35.7   63.7
2023  13.2  20.3   44.1
2024  12.6  22.7   53.1
2025  19.5  32.5   41.7
2026  13.4  27.2   46.5

(2016 年只有 9 月中旬以后的数据,2017 年的 BTC 只有 8 月中旬以后的数据,2026 年到 8、9 月为止。)

SPY 的年化波动率,2017 年是 6.8%,2020 年是 33.5%,相差将近 5 倍。BTC 从 2017 年的 110.6% 降到 2025 年的 41.7%。

用滚动窗口看得更清楚:

for k in rets:
    w = 30 if k == "BTC" else 21
    rv = S.realized_vol(rets[k], w, ppy[k])
    print(k, f"{w} 天滚动年化波动率:最低 {rv.min():.1%}{rv.idxmin().date()}),"
             f"最高 {rv.max():.1%}{rv.idxmax().date()}),中位数 {rv.median():.1%}")
SPY 21 天滚动年化波动率:最低 3.5%(2017-10-20),最高 93.3%(2020-04-06),中位数 12.5%
AAPL 21 天滚动年化波动率:最低 7.8%(2017-01-13),最高 106.7%(2020-03-26),中位数 23.6%
BTC 30 天滚动年化波动率:最低 16.7%(2023-08-13),最高 183.6%(2020-04-06),中位数 55.5%

滚动年化波动率

SPY 在最平静的一个月里,年化波动率只有 3.5%;在最剧烈的一个月里是 93.3%,相差 27 倍

而且平静和剧烈都是成片出现的:图上的波动率不是随机地上下跳,而是一段时间持续很低,然后突然升高,持续一段时间再慢慢回落。这个现象叫波动率聚集(volatility clustering)。

回到河的比方:一条河有旱季和汛期。旱季里水位每天几乎不动,汛期里每天涨落很大,而且汛期一旦开始,通常会持续一段时间。用全年的平均涨落幅度,既描述不了旱季,也描述不了汛期。

现在回头看决策点的模型,它用的是过去 365 天的波动率 3.56%。这 365 天里,既有 2021 年下半年的高波动,也有 2022 年春天的相对平静。而 6 月 13 日开始的 5 天,波动率是 7.40%。模型用一个平均数,去描述一个正在进入汛期的市场。 这是它出错的第一个原因。第二个原因更根本,在下一节。


四、肥尾:百年一遇的洪水为什么总在发生

正态分布的承诺

正态分布(钟形曲线)有一组非常具体的承诺。如果收益率服从正态分布:

偏离均值超过 发生的比例 每 2,500 天大约有
1 个标准差 31.7% 793 天
2 个标准差 4.55% 114 天
3 个标准差 0.27% 6.7 天
4 个标准差 0.0063% 0.16 天
5 个标准差 0.000057% 0.0014 天
6 个标准差 0.0000002% 0.000005 天

越往外,概率下降得越快。按双侧计算,超过 4 个标准差大约 1.6 万天一次(按美股每年 252 个交易日算是 63 年),5 个标准差大约 175 万天一次,6 个标准差要 5 亿天一次。

决策点的 −15% 是 4.2 个标准差,所以模型说 208 年一次。

实际的分布长什么样

先看 BTC 全部 3,301 天的收益率,换算成「距离均值几个标准差」之后,和标准正态分布画在一起:

BTC 日收益率的分布和标准正态分布

三个特征:

  1. 中间更尖:大量的日子挤在 0 附近,比正态分布多得多
  2. 两肩更瘦:1 到 2 个标准差之间的日子,比正态分布少
  3. 两端更长:图上几乎看不见,但在 ±4、±6 甚至更远的地方,零零星星地散落着一些日子

第 3 点在普通坐标上看不清。用 tail_table 数一数:

for k, r in rets.items():
    print(k)
    print(S.tail_table(r).round({"正态预期天数": 2, "实际 ÷ 预期": 1}))
SPY
   实际天数  正态预期天数    实际 ÷ 预期
k                         
1   492  796.77        0.6
2   113  114.25        1.0
3    33    6.78        4.9
4    20    0.16      125.7
5    10    0.00     6946.5
6     6    0.00  1210985.3
AAPL
   实际天数  正态预期天数   实际 ÷ 预期
k                        
1   561  797.08       0.7
2   127  114.30       1.1
3    36    6.78       5.3
4    16    0.16     100.6
5     9    0.00    6249.4
6     3    0.00  605251.6
BTC
   实际天数   正态预期天数   实际 ÷ 预期
k                         
1   710  1047.44       0.7
2   198   150.20       1.3
3    56     8.91       6.3
4    19     0.21      90.9
5     5     0.00    2642.0
6     2     0.00  307056.9

读这张表,注意「实际 ÷ 预期」这一列是怎么变化的:

  • 超过 1 个标准差:实际只有正态预期的 0.6 到 0.7 倍。这就是「中间更尖」:大部分日子比正态分布更平静。
  • 超过 2 个标准差:实际和正态预期差不多(1.0 到 1.3 倍)。在这个位置,正态分布表现得还不错。
  • 超过 3 个标准差:实际是预期的 5 到 6 倍。
  • 超过 4 个标准差:100 倍左右。
  • 超过 5 个标准差:几千倍。
  • 超过 6 个标准差:几十万到上百万倍。SPY 在十年里有 6 天超过 6 个标准差,正态分布预期是 0.000005 天。

用对数纵轴把尾部画出来,正态分布(虚线)在 3 个标准差以后几乎是垂直下坠,而三个标的的实际曲线平缓得多:

单日涨跌幅超过 k 个标准差的比例

这就是肥尾(fat tails):极端事件发生的频率,比正态分布预测的高出几个数量级。

正态分布在 2 个标准差以内是一个不错的近似,越往外错得越离谱。 而风险管理最关心的,恰恰是 3 个标准差以外的地方。

还有一个出人意料的发现:以自身的标准差为单位,SPY 的尾巴比 BTC 还肥。 SPY 超过 5 个标准差的有 10 天,BTC 只有 5 天。BTC 的绝对波动大得多,但它的「大」比较均匀;SPY 平时很平静,所以少数几次暴跌(比如 2020 年 3 月)在自身标准差的尺度上就显得格外极端。

百年一遇到底是几年一遇

把「几个标准差」换成交易者更熟悉的说法:跌幅超过多少,多久发生一次。

th = {"SPY": [-0.03, -0.05, -0.07], "AAPL": [-0.05, -0.08, -0.10], "BTC": [-0.10, -0.15, -0.20]}
for k, r in rets.items():
    print(k)
    print(S.threshold_table(r, th[k], ppy[k]).round(2))
SPY
       实际次数  实际每年次数      正态下几年一次
阈值                              
-0.03    32    3.21         1.12
-0.05     6    0.60       951.06
-0.07     3    0.30  15827525.42
AAPL
       实际次数  实际每年次数    正态下几年一次
阈值                            
-0.05    22    2.21       1.50
-0.08     5    0.50     821.43
-0.10     1    0.10  227219.99
BTC
       实际次数  实际每年次数    正态下几年一次
阈值                            
-0.10    30    3.32       1.44
-0.15     5    0.55     348.87
-0.20     1    0.11  591782.52
正态分布说 实际
SPY 单日跌 5% 以上 951 年一次 十年 6 次
SPY 单日跌 7% 以上 1,583 万年一次 十年 3 次
AAPL 单日跌 8% 以上 821 年一次 十年 5 次
BTC 单日跌 15% 以上 349 年一次 九年 5 次
BTC 单日跌 20% 以上 59 万年一次 九年 1 次

这张表里的正态分布,用的是整段样本的均值和标准差,已经把所有暴跌日都算进了标准差里。即便如此,它仍然把尾部风险低估了几百倍。

回到河的比方。水文站根据过去几十年的水位记录,拟合出一条曲线,算出某个水位是「百年一遇」。如果那条曲线用的是钟形分布,而真实洪水的分布是肥尾的,那么「百年一遇」的洪水可能十年就来一次。「百年一遇」不是一个观测结果,而是一个模型的输出。模型错了,输出就错了。

揭晓:2022 年 6 月 13 日

BTCUSDT 日线,2022-04-01 至 2022-07-15

6 月 12 日(美国时间周日晚上),加密借贷平台 Celsius 宣布暂停用户提款。

6 月 13 日,BTC 开盘 26,574.53,最低 21,925.77,收盘 22,487.41,单日跌 15.38%。从开盘到最低点,跌幅达到 17.5%。

接下来几天没有止跌。6 月 16 日又跌 9.66%,6 月 18 日最低到 17,622,比决策点的收盘价低了 33.7%。

回到你的三个选择:

  • A(维持 5 倍杠杆):5 倍杠杆大约在下跌 20% 时被强平。6 月 13 日盘中最多跌了 17.5%,已经非常接近;即使侥幸没被强平,6 月 18 日相对决策点跌了 33.7%,早已超过强平线。这笔仓位不会活过这一周。
  • B(降到 2 倍):2 倍杠杆大约在下跌 50% 时被强平,这一周活了下来。但 6 月 18 日的账面亏损约是 2 × 33.7% = 67%。
  • C(只留现货):账面亏损 33.7%,没有被强平的风险。

这个结果并不是要说明「C 是对的」。如果 6 月 13 日 BTC 反弹了,A 会赚得最多。一个例子什么也证明不了。

真正能从这个决策点学到的,是那个「208 年一次」的来源:

  1. 它用了一个平均的波动率,而市场正在进入波动率更高的时期(第三节)
  2. 它假设收益率服从正态分布,而实际分布在 4 个标准差处,尾部概率大约是正态分布的 100 倍(本节)

而你手里那个更朴素的证据,「过去五年跌超 15% 的日子有 4 天」,恰恰是对的。在尾部风险上,直接数历史次数,比拟合一个正态分布可靠得多。

偏度和峰度:用两个数字描述分布的形状

描述分布的形状,除了均值和标准差,还有两个常用的统计量。

偏度(skewness)衡量分布是否对称

偏度 = 平均的 (偏差 ÷ 标准差)³

立方保留了正负号。如果大的偏差主要在左边(暴跌比暴涨多),偏度为负;主要在右边,偏度为正;完全对称,偏度为 0。

峰度(kurtosis)衡量尾部有多肥

峰度 = 平均的 (偏差 ÷ 标准差)⁴

四次方让大的偏差被极度放大:一个 5 个标准差的日子,贡献 5⁴ = 625;一个 1 个标准差的日子,只贡献 1。正态分布的峰度正好是 3,所以通常用超额峰度 = 峰度 − 3。超额峰度大于 0,尾部就比正态分布肥。pandas 的 kurt() 返回的就是超额峰度。

for k, r in rets.items():
    top = r.abs().idxmax()
    print(f"{k:4s} 偏度 {r.skew():.2f}  超额峰度 {r.kurt():.2f}  "
          f"去掉绝对值最大的一天({top.date()}{r[top]:.2%})后 {r.drop(top).kurt():.2f}")
SPY  偏度 -0.31  超额峰度 14.73  去掉绝对值最大的一天(2020-03-16,-10.94%)后 12.33
AAPL 偏度 0.12  超额峰度 6.85  去掉绝对值最大的一天(2025-04-09,15.33%)后 5.41
BTC  偏度 -0.22  超额峰度 8.74  去掉绝对值最大的一天(2020-03-12,-39.50%)后 4.34
  • SPY 偏度 −0.31:暴跌比暴涨更极端。这是股指的典型特征。
  • 三个标的的超额峰度都远大于 0,SPY 最高(14.73),和上面 tail_table 的结论一致。

最后一列非常重要:BTC 的超额峰度是 8.74,去掉 2020 年 3 月 12 日这一天,立刻变成 4.34,减少了一半。 3,301 天里的 1 天,决定了峰度的一半。

这是因为四次方:那一天是 −11.3 个标准差,11.3⁴ ≈ 16,300,而全部 3,301 天四次方的平均值(也就是峰度)才 11.7。

⚠️ 所以峰度是一个非常不稳定的统计量。 它告诉你「尾巴很肥」这个定性结论是可靠的,但具体的数字(8.74 还是 4.34)几乎完全取决于样本里有没有那一两天。看到别人报告「某策略收益率的峰度是 3.2」,不要把这个数字当成精确的事实。

肥尾从哪里来:一个算例

为什么收益率会有肥尾?一个重要的原因,你在第三节已经见过了:波动率在变

做一个思想实验。假设市场只有两种状态,各占一半的日子:

  • 旱季:收益率服从正态分布,标准差 1%
  • 汛期:收益率服从正态分布,标准差 3%

每一天,收益率都是正态分布的。但如果你把所有日子混在一起统计,会看到什么?

整体标准差:方差是两种状态方差的平均,(1² + 3²) ÷ 2 = 5,标准差 = √5 = 2.236%

超过 3 个整体标准差(6.71%)的比例:

  • 旱季的日子:6.71% 是 6.71 个标准差,概率几乎为 0
  • 汛期的日子:6.71% 是 6.71 ÷ 3 = 2.236 个标准差,双侧概率 = 2.54%
  • 混合:0.5 × 0 + 0.5 × 2.54% = 1.27%

而正态分布预测的是 0.27%。混合后是正态分布的 4.7 倍。

超过 4 个整体标准差(8.94%)的比例:

  • 汛期:8.94 ÷ 3 = 2.98 个标准差,双侧概率 0.287%
  • 混合:0.5 × 0.287% = 0.144%,正态分布预测 0.0063%,是 22.6 倍

在 1 个整体标准差(2.236%)以内的比例:

  • 旱季:2.236 个标准差以内,97.46%
  • 汛期:0.745 个标准差以内,54.39%
  • 混合:(97.46% + 54.39%) ÷ 2 = 75.9%,正态分布是 68.3%

中间更尖、尾巴更肥,和真实数据的特征一模一样。这个混合分布的超额峰度是 1.92(计算:四阶矩 = 0.5 × 3 × 1⁴ + 0.5 × 3 × 3⁴ = 123,除以 √5 的四次方 25,得 4.92,减去 3)。

两个正态分布混在一起,就产生了肥尾。 旱季的日子把中间堆高,汛期的日子把尾巴拉长。

✋ 小检查 3

把上面的算例改成:80% 的日子标准差是 1%,20% 的日子标准差是 4%。

  • (a) 整体标准差是多少?
  • (b) 超过 3 个整体标准差的比例大约是多少?是正态分布的几倍?(提示:标准正态分布超过 ±1.5 的双侧概率约为 13.4%。)

在真实数据上验证:肥尾有多少来自波动率变化

如果肥尾完全来自波动率变化,那么把每天的收益率除以当时的波动率,得到的数字就应该接近正态分布。

「当时的波动率」怎么定?用这一天之前 20 天的标准差。注意必须是「之前」:如果用包含当天的 20 天,当天的暴跌会把自己的标尺撑大,结果就不公平了。第 28 篇会把这种错误叫作前视偏差

for k, r in rets.items():
    z = S.standardize(r, 20)
    full = S.tail_table(r, ks=[3, 4])["实际天数"].tolist()
    st = S.tail_table(z, ks=[3, 4])["实际天数"].tolist()
    print(f"{k:4s} 超额峰度 {r.kurt():5.2f}{z.kurt():5.2f}   超过 3σ/4σ 的天数 {full}{st}")
    for day in z.abs().nlargest(3).index:
        print(f"     {day.date()}  收益率 {r[day]:7.2%}  前 20 天波动率 {r[:day].iloc[-21:-1].std():.2%}  倍数 {z[day]:6.1f}")
SPY  超额峰度 14.73 →  4.67   超过 3σ/4σ 的天数 [33, 20] → [32, 15]
     2018-10-10  收益率  -3.17%  前 20 天波动率 0.41%  倍数   -7.7
     2024-12-18  收益率  -2.98%  前 20 天波动率 0.40%  倍数   -7.4
     2025-10-10  收益率  -2.70%  前 20 天波动率 0.37%  倍数   -7.3
AAPL 超额峰度  6.85 →  7.20   超过 3σ/4σ 的天数 [36, 16] → [36, 14]
     2017-02-01  收益率   6.10%  前 20 天波动率 0.52%  倍数   11.6
     2024-06-11  收益率   7.26%  前 20 天波动率 1.02%  倍数    7.1
     2018-08-01  收益率   5.89%  前 20 天波动率 0.96%  倍数    6.1
BTC  超额峰度  8.74 → 20.19   超过 3σ/4σ 的天数 [56, 19] → [55, 20]
     2019-04-02  收益率  17.20%  前 20 天波动率 1.02%  倍数   16.8
     2020-03-12  收益率 -39.50%  前 20 天波动率 3.16%  倍数  -12.5
     2018-11-14  收益率  -8.29%  前 20 天波动率 0.86%  倍数   -9.6

结果比「肥尾完全来自波动率变化」复杂得多:

SPY:超额峰度从 14.73 降到 4.67,下降了 68%。波动率变化确实解释了很大一部分。2020 年 3 月 16 日原本是 −9.7 个标准差,按之前 20 天的波动率(当时已经是汛期)来衡量,只有 −2.5 倍。

但超过 4 个标准差的日子只从 20 天减少到 15 天。 标准化之后最极端的三天,全都不是 2020 年的日子:

  • 2018-10-10:美股在利率上升的担忧中大跌,SPY 跌 3.17%
  • 2024-12-18:美联储降息,但预测 2025 年的降息次数减少,SPY 跌 2.98%
  • 2025-10-10:美国威胁对中国大幅加征关税,SPY 跌 2.70%(也是第 2 篇里 BTC 暴跌的同一天)

这三天的跌幅放在 2020 年 3 月毫不起眼,但它们都发生在极度平静的时期:之前 20 天的日波动率只有 0.4% 左右。旱季里突然来的一场暴雨。

AAPL:超额峰度几乎没有变化(6.85 → 7.20)。最极端的三天:

  • 2017-02-01:前一天盘后公布季度财报,涨 6.10%,是之前 20 天波动率的 11.6 倍
  • 2024-06-11:前一天 WWDC 发布 Apple Intelligence,涨 7.26%
  • 2018-08-01:前一天盘后公布季度财报,涨 5.89%

个股的肥尾,很大一部分来自事先就知道日期的事件:财报、发布会。第 20 篇会专门讲怎么处理这些日子。

BTC:超额峰度反而从 8.74 升到 20.19。最极端的是:

  • 2019-04-02:在长期平静之后,几小时内从 4,100 多美元涨到 4,800 以上,当天涨 17.20%,是之前 20 天波动率的 16.8 倍。当时的报道对原因众说纷纭,没有公认的解释。
  • 2020-03-12:即使按之前 20 天已经升高的波动率衡量,仍然是 −12.5 倍
  • 2018-11-14:比特币现金(BCH)硬分叉前一天,BTC 在几个月的窄幅震荡后跌破 6,000 美元,跌 8.29%

所以,肥尾有两个来源

  1. 波动率聚集:汛期和旱季混在一起。这部分可以通过「用近期波动率衡量」来部分消除。SPY 的肥尾主要来自这里。
  2. 跳跃:在平静中突然出现的巨大变动,事先的波动率完全没有预兆。它有时有明确的原因(财报、政策),有时没有(2019 年 4 月 2 日)。BTC 和个股的肥尾,这部分很重。

第一种来源可以通过及时更新波动率来应对;第二种没有办法从价格数据中提前看到。 你能做的,只有让仓位在跳跃发生时活下来。第 23、24、26 篇都会回到这件事。

从业者怎么用:直接数历史的尾部

既然正态分布在尾部不可靠,最朴素的替代方法是:直接看历史上最差的那些日子

  • 历史 VaR(Value at Risk):把所有日子的收益率从小到大排序,最差的 1% 的分界线在哪里
  • 历史 ES(Expected Shortfall):在这最差的 1% 的日子里,平均亏多少
for k, r in rets.items():
    q, es = S.tail_loss(r, 0.01)
    nq, nes = S.normal_tail_loss(r, 0.01)
    q5, _ = S.tail_loss(r, 0.05)
    nq5, _ = S.normal_tail_loss(r, 0.05)
    print(f"{k:4s} 1% 分界线 实际 {q:.2%} 正态 {nq:.2%} | 尾部平均 实际 {es:.2%} 正态 {nes:.2%} | "
          f"5% 分界线 实际 {q5:.2%} 正态 {nq5:.2%}")
SPY  1% 分界线 实际 -3.24% 正态 -2.58% | 尾部平均 实际 -4.76% 正态 -2.97% | 5% 分界线 实际 -1.67% 正态 -1.81%
AAPL 1% 分界线 实际 -4.84% 正态 -4.15% | 尾部平均 实际 -6.69% 正态 -4.77% | 5% 分界线 实际 -2.78% 正态 -2.90%
BTC  1% 分界线 实际 -9.70% 正态 -8.01% | 尾部平均 实际 -13.14% 正态 -9.20% | 5% 分界线 实际 -5.28% 正态 -5.62%

读这张表:

  • 最差 1% 的分界线:实际比正态分布更深。BTC 实际是 −9.70%,正态说 −8.01%。
  • 最差 1% 的日子平均亏多少:差距更大。SPY 实际平均 −4.76%,正态说 −2.97%,低估了 38%。
  • 最差 5% 的分界线:实际反而比正态分布。这和「中间更尖、两肩更瘦」一致。

所以正态分布的错误不是「一律低估风险」:它高估了中等的坏日子,低估了极端的坏日子。越往尾部走,低估得越厉害。

实际怎么用:设定仓位时,问自己的是「如果明天是历史上最差 1% 的日子,我会亏多少」,用的是历史 ES,而不是「正态分布下 99% 的置信区间」。第 26 篇会把它变成具体的仓位公式。

⚠️ 历史方法也会低估。 历史 ES 只能反映样本里已经发生过的事情。2020 年 3 月 11 日之前,BTC 历史上最差的一天是 −19.50%;第二天就出现了 −39.50%。历史给出的是风险的下限,不是上限。 实践中通常会在历史数字上再留出余量,或者用压力情景(比如「如果再来一次 2020 年 3 月 12 日」)来补充。


五、自相关:今天的涨跌能不能告诉你明天

趋势和均值回归,在统计上是什么

交易里有两个最基本的想法:

  • 趋势:涨了还会继续涨,跌了还会继续跌
  • 均值回归:涨多了会跌回来,跌多了会涨回来

这两个想法,在统计上对应的是同一个量的正负:自相关(autocorrelation)。

回到河的比方。如果今天水位上涨,明天更可能继续上涨(比如上游在持续降雨),水位变化就有正的自相关。如果今天涨了、明天更可能回落(比如水库在调节),就是负的自相关。如果今天涨不涨,对明天毫无影响,自相关就是 0

定义和手算

滞后 k 天的自相关系数

ρ_k = Σ (今天的偏差 × k 天前的偏差) ÷ Σ (偏差的平方)

偏差是指减去均值之后的值。分子衡量「今天和 k 天前是否同方向」:同方向时乘积为正,反方向时为负。分母用来把结果缩放到 −1 到 +1 之间。

继续用第三节那 5 天,手算滞后 1 天的自相关。偏差已经算好了:

日期 偏差 d 前一天的偏差 乘积
6-13 −10.5293
6-14 +3.2897 −10.5293 −34.6380
6-15 +6.8713 +3.2897 +22.6043
6-16 −4.8131 +6.8713 −33.0719
6-17 +5.1814 −4.8131 −24.9386

分子 = −34.6380 + 22.6043 − 33.0719 − 24.9386 = −70.0443

分母 = 偏差平方和 = 218.9148(第三节算过)

ρ₁ = −70.0443 ÷ 218.9148 = −0.3200

这 5 天里,大跌之后倾向于反弹,反弹之后倾向于再跌,自相关是 −0.32。

但是,5 天的样本能说明什么吗? 什么也说明不了。就算收益率完全随机,5 个数算出的自相关也经常是 ±0.3 甚至更大。这引出了下一个问题:多大的自相关才算「真的有」?

多大才算数

如果收益率完全没有自相关,用 n 天数据算出来的样本自相关系数,大约服从均值为 0、标准差为 1 ÷ √n 的正态分布。所以:

没有自相关时,样本自相关系数约 95% 落在 ±1.96 ÷ √n 之内

  • 5 天:±0.877。−0.32 完全在范围内,不能说明任何事。
  • SPY 2,511 天:±0.039
  • BTC 3,301 天:±0.034

用这个界限看全部数据:

for k, r in rets.items():
    print(k, "±", round(S.autocorr_band(len(r)), 3))
    print("  收益率    ", S.autocorr(r, [1, 2, 3, 4, 5]).round(3).tolist())
    print("  收益率绝对值", S.autocorr(r.abs(), [1, 5, 20, 60, 120]).round(3).tolist())
SPY ± 0.039
  收益率     [-0.131, 0.077, -0.03, -0.061, 0.042]
  收益率绝对值 [0.359, 0.326, 0.19, 0.019, 0.022]
AAPL ± 0.039
  收益率     [-0.061, 0.017, -0.029, -0.019, 0.01]
  收益率绝对值 [0.229, 0.201, 0.137, 0.013, 0.035]
BTC ± 0.034
  收益率     [-0.048, 0.042, 0.002, 0.008, 0.025]
  收益率绝对值 [0.178, 0.15, 0.102, 0.064, 0.023]

(「收益率绝对值」一行的五个数,分别是滞后 1、5、20、60、120 天。)

三个标的收益率和收益率绝对值的自相关

先看收益率本身:三个标的的滞后 1 天自相关都是负的,而且都超出了界限:

  • SPY:−0.131,超出界限 3 倍多
  • AAPL:−0.061
  • BTC:−0.048

看起来,三个标的都有显著的日内均值回归:今天跌,明天倾向于涨。第三节的方差比(SPY 小于 1)似乎也在印证这一点。

这是一个可以交易的规律吗?

把 2020 年拿掉

按年拆开看:

by_year = pd.DataFrame({k: r.groupby(r.index.year).apply(lambda s: S.autocorr(s, [1]).loc[1])
                        for k, r in rets.items()})
print(by_year.round(2))
for k, r in rets.items():
    no2020 = r[r.index.year != 2020]
    print(f"{k:4s} 全部 {S.autocorr(r, [1]).loc[1]:.3f}  去掉 2020 年 {S.autocorr(no2020, [1]).loc[1]:.3f}  "
          f"界限 ±{S.autocorr_band(len(no2020)):.3f}")
       SPY  AAPL   BTC
2016 -0.05 -0.04   NaN
2017 -0.15  0.02  0.05
2018  0.01  0.05 -0.06
2019 -0.10 -0.09 -0.04
2020 -0.31 -0.27 -0.19
2021 -0.06 -0.04 -0.06
2022  0.01 -0.03 -0.02
2023  0.02  0.04  0.01
2024  0.04  0.06 -0.05
2025 -0.16  0.04 -0.08
2026 -0.04  0.03 -0.02
SPY  全部 -0.131  去掉 2020 年 -0.033  界限 ±0.041
AAPL 全部 -0.061  去掉 2020 年 0.008  界限 ±0.041
BTC  全部 -0.048  去掉 2020 年 -0.026  界限 ±0.036

2020 年,三个标的的自相关分别是 −0.31、−0.27、−0.19,远比其他年份强。把 2020 年这一年去掉:

  • SPY:−0.131 → −0.033,落进了界限之内
  • AAPL:−0.061 → +0.008,符号都变了
  • BTC:−0.048 → −0.026,落进了界限之内

十年的「显著」负自相关,几乎全部来自一年。

为什么 2020 年会这样?回忆 2020 年 3 月:SPY 在 3 月 9 日跌 7.8%,12 日跌 9.6%,13 日涨 8.5%,16 日跌 10.9%,24 日涨 9.1%。暴跌和暴涨交替出现,每一对的乘积都非常大。

这又回到了四次方和平方的问题:自相关的分子是偏差的乘积,暴跌暴涨日的偏差是普通日子的十倍,乘积就是一百倍。少数几个剧烈的交替,就能主导十年的自相关系数。

打乱检验

「±1.96 ÷ √n」这个界限,推导时假设了收益率是正态的、没有波动率聚集。我们已经知道这两个假设都不成立。有没有一种不依赖这些假设的检验方法?

有,而且思路非常直观:把日子的顺序随机打乱。

打乱之后,每一天的收益率还是原来那些数字,肥尾一点没变,但先后顺序被彻底破坏了,任何「今天影响明天」的关系都不存在了。打乱 1,000 次,每次算一个自相关系数,就得到了「没有自相关时,这批数字的自相关系数通常落在什么范围」。

lag1 = lambda s: S.autocorr(s, [1]).loc[1]
for k, r in rets.items():
    for label, x in [("全部", r), ("去掉 2020", r[r.index.year != 2020])]:
        res = S.shuffle_test(x, lag1, n=1000, seed=0)
        print(f"{k:4s} {label:6s} 实际 {res['实际值']:6.3f}  "
              f"打乱后 95% 范围 [{res['打乱后 2.5% 分位']:.3f}, {res['打乱后 97.5% 分位']:.3f}]  比例 {res['比例']:.1%}")
SPY  全部     实际 -0.131  打乱后 95% 范围 [-0.037, 0.039]  比例 0.0%
SPY  去掉 2020 实际 -0.033  打乱后 95% 范围 [-0.041, 0.042]  比例 11.3%
AAPL 全部     实际 -0.061  打乱后 95% 范围 [-0.039, 0.041]  比例 0.4%
AAPL 去掉 2020 实际  0.008  打乱后 95% 范围 [-0.042, 0.041]  比例 72.2%
BTC  全部     实际 -0.048  打乱后 95% 范围 [-0.036, 0.035]  比例 0.8%
BTC  去掉 2020 实际 -0.026  打乱后 95% 范围 [-0.036, 0.036]  比例 17.2%

「比例」是指打乱后的 1,000 次里,自相关的绝对值不小于实际值的次数占多少。比例越小,实际值越不像是随机打乱能产生的。

  • 全部数据:三个标的的比例都在 1% 以下,打乱几乎不可能产生这么强的负自相关。「显著」的结论成立。
  • 去掉 2020 年:比例变成 11.3%、72.2%、17.2%。随便打乱一下,就有相当大的机会得到同样强的自相关。

这两个结论并不矛盾。它们一起说的是:负自相关在统计上是真的存在的,但它集中在极少数的危机时期,不是一个在平常日子里持续起作用的规律。

对交易来说,后面这半句话才是关键。一个只在 2020 年 3 月那样的时期起作用的规律,一年里可能一次也用不上;而在它起作用的时候,每天的波动是平常的十倍,任何基于它的交易都要承受巨大的风险。

⚠️ 这是这门课里第一次遇到「统计显著但不一定能交易」的情况,以后还会反复遇到。 看到任何「显著」的规律,先问三个问题:去掉最极端的一段时间还在吗?按年拆开,是每年都有还是集中在某几年?打乱检验的结果是什么?第 30 篇会把这三个问题发展成完整的过拟合检验方法。

真正稳定的自相关:波动率

回到上面那张自相关图的下面一行收益率绝对值的自相关。

它和收益率本身完全不同:

  • 三个标的在滞后 1 天都是明显的正值:SPY 0.359、AAPL 0.229、BTC 0.178,是界限的 5 到 10 倍
  • 而且衰减得很慢:SPY 在滞后 20 天仍有 0.19;BTC 在滞后 60 天仍有 0.064

收益率的绝对值,衡量的是「今天动了多少」,不管方向。它有很强的正自相关,意思是:今天波动大,明天很可能也波动大;今天平静,明天很可能也平静。

这就是第三节「波动率聚集」在自相关上的样子。

所以对日线收益率来说:

  • 方向几乎无法从昨天预测(自相关接近 0,而且不稳定)
  • 幅度可以相当程度地从近期预测(自相关明显为正,而且持续几周)

这个区别贯穿整门课。预测涨跌方向很难,预测波动大小相对容易。 ATR 止损(第 15 篇)、按波动率定仓位(第 26 篇),都建立在「波动率可以预测」这个事实上。

自相关和方差比的关系

第三节的方差比和这一节的自相关,其实是同一件事的两个角度。可以证明:

VR(k) ≈ 1 + 2 × [(1 − 1/k)ρ₁ + (1 − 2/k)ρ₂ + … + (1/k)ρ_(k−1)]

方差比等于 1 加上各阶自相关的加权和。SPY 的 ρ₁ = −0.131,是它的方差比小于 1 的主要原因。

也就是说,第三节里「SPY 倾向于反转」的方差比证据,和这一节的滞后 1 天自相关,有很大一部分是同一批 2020 年的日子。 两个看起来独立的证据,其实并不独立。

BTC 的 90 天方差比 1.36,来自许多个很小的正自相关累积(图上 BTC 收益率在滞后 2、10、17 天等处有小的正值)。一个个单独看都不显著,累积起来在长周期上形成了「涨跌延续」的倾向。它是否稳定、是否能交易,要到第 31 篇讲趋势跟随时再检验。

⚠️ 长周期的样本比你以为的少得多。 BTC 有 3,301 天的数据,但不重叠的 90 天只有 36 个。用 36 个数算出的统计量,误差非常大。「九年数据」听起来很多,在季度级别上其实只是 36 个观测值。


六、平稳:为什么指标都在算「变化」

价格不平稳

回到河的比方的开头:水文工程师研究的是水位的变化,而不是水位本身。为什么?

平稳(stationary),粗略地说,是指一个序列的统计性质不随时间改变:它的均值、波动幅度,今年和十年前差不多。

看 BTC 的价格和日收益率:

BTC 价格和日收益率

按年统计:

b = btc["close"]
yearly = pd.DataFrame({
    "价格均值": b.groupby(b.index.year).mean(),
    "价格标准差": b.groupby(b.index.year).std(),
    "收益率均值%": rets["BTC"].groupby(rets["BTC"].index.year).mean() * 100,
    "收益率标准差%": rets["BTC"].groupby(rets["BTC"].index.year).std() * 100,
})
print(yearly.round(2))
           价格均值     价格标准差  收益率均值%  收益率标准差%
time                                      
2017    7622.66   4324.68    1.02     5.79
2018    7538.11   2404.42   -0.26     4.35
2019    7364.36   2646.57    0.25     3.59
2020   11110.55   4309.47    0.47     3.95
2021   47400.00   9770.99    0.22     4.24
2022   28185.22  10188.51   -0.22     3.34
2023   28849.59   5901.64    0.28     2.31
2024   65963.59  14679.33    0.26     2.78
2025  101635.67  11728.44    0.01     2.18
2026   72219.28   9134.17   -0.02     2.44
  • 价格的均值:从 7,364 到 101,636,相差将近 14 倍。「BTC 的平均价格」这个概念没有意义,取决于你选哪一段时间。
  • 价格的标准差:从 2,404 到 14,679,同样没有稳定的值。
  • 收益率的均值:始终在 0 附近,每天 −0.26% 到 +1.02%。
  • 收益率的标准差:在 2.18% 到 5.79% 之间。

价格是不平稳的。收益率要稳定得多,但也不是完全平稳:它的标准差从 2017 年的 5.79% 降到 2025 年的 2.18%,有明显的长期下降趋势,还有第三节讲的波动率聚集。所以准确的说法是:收益率近似平稳

不平稳会带来什么问题:伪相关

不平稳最危险的后果,是让两个毫无关系的东西看起来高度相关。

计算 SPY 和 BTC 的相关系数:

btc_day = b.copy()
btc_day.index = btc_day.index.tz_localize(None)            # 用 BTC 在 UTC 零点的收盘价,对齐到美股日期
both = pd.DataFrame({"SPY": spy["close"], "BTC": btc_day}).dropna()
print("共同日期", len(both))
print("价格相关系数", round(both["SPY"].corr(both["BTC"]), 2),
      " 收益率相关系数", round(both.pct_change()["SPY"].corr(both.pct_change()["BTC"]), 2))
for y in [2025, 2026]:
    part = both[both.index.year == y]
    print(y, "价格", round(part["SPY"].corr(part["BTC"]), 2),
          "收益率", round(part.pct_change()["SPY"].corr(part.pct_change()["BTC"]), 2))
共同日期 2270
价格相关系数 0.9  收益率相关系数 0.28
2025 价格 0.43 收益率 0.42
2026 价格 -0.27 收益率 0.44

(BTC 周末也交易,对齐到美股日期后,周一的 BTC 收益率包含了周末三天的变化。这会让相关系数略有偏差,但不影响下面的结论。)

  • 价格的相关系数是 0.90。看起来,SPY 和 BTC 几乎是同涨同跌的。
  • 收益率的相关系数只有 0.28

哪个是对的?看 2026 年:价格的相关系数是 −0.27,好像两者变成了反向走势;但收益率的相关系数是 +0.44,比全样本还高,同一天里两者同向变动的倾向更强了。

2026 年 BTC 从高位下跌、SPY 在上涨,两条价格曲线的大方向相反,所以价格相关为负。但在每一天的层面上,美股涨的日子 BTC 也倾向于涨。价格的相关系数,衡量的主要是「两条曲线的大方向是否一致」,而不是「它们每天是否一起动」。

更极端的证明:两个完全独立的随机序列,价格也可以高度相关。

rng = np.random.default_rng(42)
level_corr, ret_corr = [], []
for _ in range(1000):
    a, bb = rng.normal(0, 0.01, (2, 2500))                 # 两串互相独立的日收益率
    pa, pb = 100 * np.exp(np.cumsum(a)), 100 * np.exp(np.cumsum(bb))
    level_corr.append(np.corrcoef(pa, pb)[0, 1])
    ret_corr.append(np.corrcoef(a, bb)[0, 1])
level_corr, ret_corr = np.abs(level_corr), np.abs(ret_corr)
print(f"1000 对独立随机游走:价格相关系数绝对值 > 0.5 的比例 {np.mean(level_corr > 0.5):.1%},> 0.8 的比例 {np.mean(level_corr > 0.8):.1%}")
print(f"                     收益率相关系数绝对值最大 {ret_corr.max():.3f}")
1000 对独立随机游走:价格相关系数绝对值 > 0.5 的比例 37.1%,> 0.8 的比例 6.3%
                     收益率相关系数绝对值最大 0.077

1,000 对完全独立的随机价格,有 37.1% 的价格相关系数绝对值超过 0.5,6.3% 超过 0.8。 而它们收益率的相关系数,最大也只有 0.077。

两串独立随机收益率累积出的价格

原因是:随机游走的价格会漂移,一段时间偏高、一段时间偏低。两条价格如果碰巧都在后半段偏高,相关系数就会很大。这和它们之间有没有关系毫无关系。

这种现象叫伪相关(spurious correlation)。所以:衡量两个资产的关系,永远用收益率,不要用价格。

这对指标意味着什么

绝大多数技术指标,都在想办法把不平稳的价格,变成近似平稳的量:

指标 它怎么处理价格 第几篇
均线的偏离度 价格 ÷ 均线 − 1,一个比例 第 12 篇
MACD 两条均线的差(本身不平稳,所以不同时期的数值没法比) 第 13 篇
RSI 一段时间内上涨幅度占总涨跌幅度的比例,范围 0 到 100 第 14 篇
ATR 平均真实波幅;除以价格之后才能跨时期比较 第 15 篇
布林带 %B 价格在上下轨之间的相对位置 第 15 篇

以后每学一个指标,都问一句:它是平稳的吗? 如果不是(比如 MACD 的原始数值),那么「MACD 大于 500」这种绝对阈值在 BTC 价格 4,000 和 100,000 时的含义完全不同,用在回测里就会出问题。

⚠️ 这一节讲的「平稳」是直观的说法。 统计学里有严格的定义和专门的检验方法(比如 ADF 检验)。这门课暂时不需要这些工具,只要记住核心事实:价格会漂移,收益率不会(大致不会);所有统计都应该建立在收益率上。


七、talab.stats:把这一篇写成模块

放在哪里

第 4 篇搭好的项目里新增两个文件:

talab/
├── talab/
│   ├── __init__.py
│   ├── data.py
│   ├── plot.py
│   └── stats.py          ← 新增
├── tests/
│   ├── test_data.py
│   ├── test_plot.py
│   └── test_stats.py     ← 新增
├── scripts/
│   └── fetch_course_data.py
├── pyproject.toml
└── requirements.txt

这一篇不需要新的数据,也不需要新的依赖。statistics.NormalDist 是 Python 标准库自带的,正态分布的概率和分位数都用它计算。

准备三个标的的收益率

前面所有代码片段里的 retslogsppy,都来自这一段:

import glob
import math

import numpy as np
import pandas as pd
from talab import data as D, stats as S

btc = D.load_binance_klines(glob.glob("data/binance/spot/BTCUSDT/1d/*.zip"))
spy = D.load_nasdaq_daily("data/nasdaq/SPY_historical.json")
spy = spy.drop(pd.Timestamp("2026-04-20"))              # 第 3 篇体检发现的填充行
aapl = D.load_nasdaq_daily("data/nasdaq/AAPL_historical.json")
divs = D.load_nasdaq_dividends("data/nasdaq/AAPL_dividends.json")
aapl_total = D.adjust_total_return(D.unadjust_splits(aapl, D.SPLITS["AAPL"]), D.SPLITS["AAPL"], divs)
close = {"SPY": spy["close"], "AAPL": aapl_total["close"], "BTC": btc["close"]}
ppy = {"SPY": 252, "AAPL": 252, "BTC": 365}
rets = {k: S.simple_returns(v) for k, v in close.items()}
logs = {k: S.log_returns(v) for k, v in close.items()}
for k, r in rets.items():
    print(k, len(r), "个收益率", r.index[0].date(), "至", r.index[-1].date())
SPY 2511 个收益率 2016-09-16 至 2026-09-15
AAPL 2512 个收益率 2016-09-16 至 2026-09-15
BTC 3301 个收益率 2017-08-18 至 2026-08-31

三个处理细节,都来自第 3 篇

  1. SPY 的 2026-04-20 是一行填充数据:四个价格都等于前一天收盘价,成交量为空。如果保留它,会凭空多出一个 0% 的收益率,而 4 月 21 日的收益率会变成两天的涨跌。这里把整行删掉:4 月 21 日的收益率变成「4 月 17 日到 21 日」的两天收益率,总收益仍然正确。在 2,511 个收益率里,这一个的影响可以忽略。
  2. AAPL 用拆股加分红调整后的价格,这样收益率才是持有者真实拿到的总收益。
  3. SPY 只有拆股调整,没有分红调整:我们的数据源不提供 SPY 的分红记录。SPY 每季度分红一次,年股息率约 1.5%,每次除息日的收益率会被低估约 0.3 到 0.4 个百分点。这对波动率、尾部和自相关的影响很小(十年里只有约 40 个除息日),但SPY 的复合年化收益率会比真实的总收益低约 1.5 个百分点

代码

"""talab.stats:收益率、波动率与分布。第 5 篇。"""
from __future__ import annotations

import math
from statistics import NormalDist
from typing import Callable

import numpy as np
import pandas as pd

NORMAL = NormalDist()

# 一年有多少根日线:美股一年约 252 个交易日,加密市场全年无休
PERIODS_PER_YEAR = {"us_stock": 252, "crypto": 365}


# ---------------------------------------------------------------------------
# 一、收益率
# ---------------------------------------------------------------------------

def simple_returns(close: pd.Series) -> pd.Series:
    """简单收益率:今天收盘 ÷ 昨天收盘 − 1。第一天没有「昨天」,去掉。"""
    return close.pct_change().dropna()


def log_returns(close: pd.Series) -> pd.Series:
    """对数收益率:ln(今天收盘 ÷ 昨天收盘)。"""
    return np.log(close).diff().dropna()


def total_return(returns: pd.Series) -> float:
    """把一串简单收益率复利连乘,得到整段时间的总收益率。"""
    return float((1 + returns).prod() - 1)


def annualized_return(returns: pd.Series, periods_per_year: int) -> float:
    """复合年化收益率(CAGR):假设每年都按同一个比例增长,这个比例是多少。"""
    growth = 1 + total_return(returns)
    return growth ** (periods_per_year / len(returns)) - 1


# ---------------------------------------------------------------------------
# 二、波动率
# ---------------------------------------------------------------------------

def annualize_vol(vol: float | pd.Series, periods_per_year: int) -> float | pd.Series:
    """把单根 K 线的波动率换算成年化波动率:乘以一年 K 线根数的平方根。"""
    return vol * math.sqrt(periods_per_year)


def realized_vol(returns: pd.Series, window: int, periods_per_year: int) -> pd.Series:
    """滚动的已实现波动率(年化):每一天用截至当天(含当天)的最近 window 天计算。"""
    return annualize_vol(returns.rolling(window).std(), periods_per_year)


def variance_ratio(log_rets: pd.Series, k: int) -> float:
    """方差比:k 天收益率的方差 ÷ (k × 1 天收益率的方差)。

    每天的收益率互相独立时,方差比等于 1,「波动率按时间的平方根放大」成立;
    大于 1 说明涨跌倾向于延续,小于 1 说明涨跌倾向于反转。
    k 天收益率用相邻 k 天对数收益率之和(重叠窗口)计算。
    """
    k_day = log_rets.rolling(k).sum().dropna()
    return float(k_day.var() / (k * log_rets.var()))


def standardize(returns: pd.Series, window: int) -> pd.Series:
    """把每天的收益率除以「前 window 天」的波动率,得到当天是几倍的近期波动。

    用 shift(1) 保证只用到当天之前的数据,当天的收益率不参与计算自己的标尺。
    """
    trailing = returns.rolling(window).std().shift(1)
    return (returns / trailing).dropna()


# ---------------------------------------------------------------------------
# 三、分布与尾部
# ---------------------------------------------------------------------------

def describe(returns: pd.Series, periods_per_year: int) -> pd.Series:
    """一个标的收益率的统计档案。"""
    return pd.Series({
        "天数": len(returns),
        "日均收益率": returns.mean(),
        "日波动率": returns.std(),
        "年化波动率": annualize_vol(returns.std(), periods_per_year),
        "复合年化收益率": annualized_return(returns, periods_per_year),
        "偏度": returns.skew(),
        "超额峰度": returns.kurt(),
        "最大单日涨幅": returns.max(),
        "最大单日跌幅": returns.min(),
    })


def tail_table(returns: pd.Series, ks=(1, 2, 3, 4, 5, 6)) -> pd.DataFrame:
    """距离均值超过 k 个标准差的天数:实际有多少天,正态分布下预期有多少天。"""
    z = (returns - returns.mean()) / returns.std()
    rows = []
    for k in ks:
        actual = int((z.abs() > k).sum())
        expected = len(z) * 2 * NORMAL.cdf(-k)
        rows.append({"k": k, "实际天数": actual, "正态预期天数": expected,
                     "实际 ÷ 预期": actual / expected})
    return pd.DataFrame(rows).set_index("k")


def threshold_table(returns: pd.Series, thresholds, periods_per_year: int) -> pd.DataFrame:
    """单日跌幅超过某个阈值的日子:实际每年几次,正态分布认为几年才有一次。

    正态分布的均值和标准差用同一段样本估计。
    """
    years = len(returns) / periods_per_year
    mean, std = returns.mean(), returns.std()
    rows = []
    for t in thresholds:
        actual = int((returns <= t).sum())
        p = NORMAL.cdf((t - mean) / std)                  # 正态分布下,某一天跌幅超过 t 的概率
        rows.append({"阈值": t, "实际次数": actual, "实际每年次数": actual / years,
                     "正态下几年一次": 1 / p / periods_per_year})
    return pd.DataFrame(rows).set_index("阈值")


def tail_loss(returns: pd.Series, level: float = 0.01) -> tuple[float, float]:
    """最差 level 比例的日子:返回(分界线,这些日子的平均收益率)。

    分界线就是历史 VaR,平均值就是历史 ES(expected shortfall)。
    """
    cutoff = float(returns.quantile(level))
    return cutoff, float(returns[returns <= cutoff].mean())


def normal_tail_loss(returns: pd.Series, level: float = 0.01) -> tuple[float, float]:
    """假设收益率服从正态分布时的分界线和尾部平均值,用来和 tail_loss 对比。"""
    mean, std = returns.mean(), returns.std()
    z = NORMAL.inv_cdf(level)
    return mean + std * z, mean - std * NORMAL.pdf(z) / level


# ---------------------------------------------------------------------------
# 四、自相关
# ---------------------------------------------------------------------------

def autocorr(x: pd.Series, lags=(1, 2, 3, 4, 5)) -> pd.Series:
    """自相关系数:ρ_k = Σ (x_t − x̄)(x_{t−k} − x̄) ÷ Σ (x_t − x̄)²。"""
    d = x.to_numpy(dtype=float) - x.mean()
    denom = (d ** 2).sum()
    return pd.Series({k: (d[k:] * d[:-k]).sum() / denom for k in lags}, name="自相关")


def autocorr_band(n: int) -> float:
    """没有自相关时,样本自相关系数大约 95% 落在 ±1.96/√n 之内。"""
    return 1.96 / math.sqrt(n)


def shuffle_test(x: pd.Series, stat: Callable[[pd.Series], float],
                 n: int = 1000, seed: int = 0) -> dict[str, float]:
    """打乱检验:把日子的顺序随机打乱 n 次,看统计量在「顺序无关」时通常落在什么范围。

    打乱保留了每一天的收益率本身(包括肥尾),只破坏了先后顺序。
    返回实际值、打乱后 95% 的范围,以及打乱后的绝对值不小于实际值的比例。
    """
    rng = np.random.default_rng(seed)
    observed = stat(x)
    values = x.to_numpy(dtype=float).copy()
    sims = np.empty(n)
    for i in range(n):
        rng.shuffle(values)
        sims[i] = stat(pd.Series(values))
    lo, hi = np.percentile(sims, [2.5, 97.5])
    return {"实际值": observed, "打乱后 2.5% 分位": lo, "打乱后 97.5% 分位": hi,
            "比例": float((np.abs(sims) >= abs(observed)).mean())}

读一遍代码

simple_returnslog_returns 就是第二节的两个公式。两者都去掉了第一行,因为第一天没有「昨天」。

annualized_return 用的是复合年化:先把全部收益率连乘,得到总增长倍数,再开「年数」次方。它回答的是第二节的问题:如果每年都按同一个比例增长,这个比例是多少。 它和「日均收益率 × 252」完全不同,后者正是第二节里那个 142 倍的错误算法。

variance_ratio重叠窗口:第 1 到 5 天、第 2 到 6 天、第 3 到 7 天……每个窗口都算一个 5 天收益率。重叠窗口能用上全部数据,比不重叠的窗口(只有 n ÷ 5 个)估计得更稳定。对数收益率的滚动求和,正好就是窗口首尾两天价格之比的对数,这里用到了第二节「对数收益率在时间上可以相加」的性质。

standardize 里的 .shift(1) 是这个函数最关键的一行。rolling(window).std() 在第 t 天的值,用的是第 t − window + 1 到第 t 天,包含第 t 天自己shift(1) 把它整体往后挪一天,第 t 天拿到的就是第 t − window 到第 t − 1 天的标准差。去掉这一行,暴跌那天会用包含自己的标准差来衡量自己,暴跌的倍数会被系统性地低估。测试 test_standardize_only_uses_past_days 专门检查这一点。

tail_tablethreshold_table 的「正态预期」部分,用的是同一段样本的均值和标准差。这是对正态分布最有利的比较方式:标准差里已经包含了所有暴跌日。即使这样,正态分布在尾部仍然低估了几百倍。

tail_lossquantile 找分界线。pandas 的 quantile 默认在两个相邻的数之间线性插值,所以分界线不一定正好等于某一天的收益率。测试 test_tail_loss_by_hand 手算了一个插值的例子。

normal_tail_loss 的尾部平均值公式是:均值 − 标准差 × φ(z) ÷ level,其中 φ 是标准正态分布的密度函数,z 是对应的分位数。这是正态分布尾部条件期望的标准结果,这里直接使用。

autocorr 用的是第五节的教科书公式:分子和分母都用全样本的均值。pandas 自带的 Series.autocorr 算的是「序列」和「错开 k 天的序列」之间的皮尔逊相关系数,两段各用各的均值和标准差。样本很大时两者几乎一样,但在小样本上会不同。我们选教科书公式,是为了和手算的结果、以及 ±1.96 ÷ √n 的界限保持一致。

shuffle_test 接受一个任意的函数 stat,所以不只能检验自相关,任何「和顺序有关」的统计量都可以用它检验。第 30 篇检验策略时还会用到它。seed 参数固定了随机数,保证每次运行结果完全一样,这样正文里的数字才能复现。

测试

"""talab.stats 的测试。"""
import math

import numpy as np
import pandas as pd
import pytest

from talab import stats as S


def prices(values):
    idx = pd.date_range("2024-01-01", periods=len(values), freq="1D", tz="UTC")
    return pd.Series(values, index=idx, dtype=float)


def test_simple_and_log_returns_by_hand():
    close = prices([100, 110, 99])
    assert S.simple_returns(close).tolist() == pytest.approx([0.10, -0.10])
    assert S.log_returns(close).tolist() == pytest.approx([math.log(1.1), math.log(0.9)])


def test_log_returns_add_up_but_simple_returns_do_not():
    close = prices([100, 150, 75])                                # 先涨 50%,再跌 50%
    assert S.simple_returns(close).sum() == pytest.approx(0.0)    # 简单收益率相加是 0
    assert S.total_return(S.simple_returns(close)) == pytest.approx(-0.25)   # 实际亏了 25%
    assert S.log_returns(close).sum() == pytest.approx(math.log(75 / 100))   # 对数收益率相加正好是总收益


def test_annualized_return_of_constant_growth():
    # 每天涨 0.1%,252 天复利下来的年化收益就是 1.001 ** 252 − 1
    r = prices([0.001] * 504)
    assert S.annualized_return(r, 252) == pytest.approx(1.001 ** 252 - 1)


def test_annualize_vol_uses_square_root_of_time():
    assert S.annualize_vol(0.01, 252) == pytest.approx(0.01 * math.sqrt(252))
    assert S.annualize_vol(0.02, 365) == pytest.approx(0.02 * math.sqrt(365))


def test_variance_ratio_extremes():
    alternating = prices([0.01, -0.01] * 50)                       # 涨跌严格交替:两天加起来总是 0
    assert S.variance_ratio(alternating, 2) == pytest.approx(0.0)
    rng = np.random.default_rng(1)
    independent = pd.Series(rng.normal(0, 0.01, 200_000))          # 互相独立的收益率
    assert S.variance_ratio(independent, 5) == pytest.approx(1.0, abs=0.02)


def test_standardize_only_uses_past_days():
    r = prices([0.01, -0.01, 0.01, -0.01, 0.10])
    z = S.standardize(r, window=4)
    trailing = pd.Series([0.01, -0.01, 0.01, -0.01]).std()        # 只用前 4 天
    assert len(z) == 1
    assert z.iloc[0] == pytest.approx(0.10 / trailing)


def test_autocorr_by_hand():
    x = pd.Series([1.0, -1.0] * 5)                                  # 均值 0,严格交替
    # 分子是 9 个相邻乘积(每个 −1),分母是 10 个平方(每个 1)
    assert S.autocorr(x, lags=[1]).loc[1] == pytest.approx(-0.9)
    assert S.autocorr(x, lags=[2]).loc[2] == pytest.approx(0.8)


def test_autocorr_band():
    assert S.autocorr_band(2500) == pytest.approx(1.96 / 50)


def test_tail_table_expected_counts_match_normal_distribution():
    r = pd.Series(np.random.default_rng(2).normal(0, 1, 1000))
    table = S.tail_table(r, ks=[3])
    assert table.loc[3, "正态预期天数"] == pytest.approx(1000 * 0.0026998, rel=1e-3)


def test_threshold_table_counts_and_normal_period():
    r = pd.Series([-0.20, 0.0, 0.0, 0.0] * 91 + [0.0])             # 365 天里有 91 天跌 20%
    table = S.threshold_table(r, [-0.15], periods_per_year=365)
    assert table.loc[-0.15, "实际次数"] == 91
    assert table.loc[-0.15, "实际每年次数"] == pytest.approx(91)
    p = S.NORMAL.cdf((-0.15 - r.mean()) / r.std())
    assert table.loc[-0.15, "正态下几年一次"] == pytest.approx(1 / p / 365)


def test_tail_loss_by_hand():
    r = pd.Series([-0.05, -0.03, -0.01] + [0.01] * 97)             # 100 天
    cutoff, avg = S.tail_loss(r, level=0.01)
    assert cutoff == pytest.approx(-0.0302)                         # 第 1% 分位,在 −5% 和 −3% 之间插值
    assert avg == pytest.approx(-0.05)                              # 只有 −5% 那一天在分界线之下


def test_shuffle_test_detects_strong_order():
    x = pd.Series([1.0, -1.0] * 200)
    result = S.shuffle_test(x, lambda s: S.autocorr(s, [1]).loc[1], n=200, seed=0)
    assert result["实际值"] < result["打乱后 2.5% 分位"]
    assert result["比例"] == 0.0

这些测试的设计思路和第 4 篇一样:用手算过答案的小数据

  • test_log_returns_add_up_but_simple_returns_do_not:第二节「涨 50% 再跌 50%」的例子。简单收益率相加是 0,实际亏 25%。
  • test_variance_ratio_extremes:两个极端情况。涨跌严格交替,两天加起来总是 0,方差比等于 0;20 万个独立的随机数,方差比应该非常接近 1。
  • test_autocorr_by_hand:+1、−1 交替 10 个数。滞后 1 天的 9 个乘积都是 −1,分母是 10,结果 −0.9;滞后 2 天的 8 个乘积都是 +1,结果 0.8。
  • test_standardize_only_uses_past_days:最后一天暴涨 10%,检查它是被前 4 天的标准差衡量,而不是被包含自己的 4 天衡量。

运行全部测试:

pytest -q
...........................                                              [100%]
27 passed in 0.31s

第 4 篇的 15 个,加上这一篇的 12 个。

✋ 小检查 4

standardize 如果去掉 .shift(1),下面哪个结论会被影响?

  • A. SPY 2018-10-10 是之前 20 天波动率的 7.7 倍
  • B. BTC 全部日收益率的超额峰度是 8.74
  • C. SPY 超过 4 个标准差的日子有 20 天

八、数据怎么说:三个标的的统计档案

把这一篇的主要统计量汇总成一张表:

profile = pd.DataFrame({k: S.describe(r, ppy[k]) for k, r in rets.items()})
print(profile.round(4))
               SPY       AAPL        BTC
天数       2511.0000  2512.0000  3301.0000
日均收益率       0.0006     0.0012     0.0015
日波动率        0.0113     0.0183     0.0351
年化波动率       0.1801     0.2912     0.6702
复合年化收益率     0.1346     0.2888     0.3794
偏度         -0.3081     0.1242    -0.2250
超额峰度       14.7323     6.8500     8.7440
最大单日涨幅      0.1050     0.1533     0.2250
最大单日跌幅     -0.1094    -0.1286    -0.3950

整理成更容易读的形式,并加上这一篇其他小节的结果:

SPY AAPL BTC
样本 2016-09 至 2026-09 2016-09 至 2026-09 2017-08 至 2026-08
复合年化收益率 13.5%(不含分红) 28.9%(含分红) 37.9%
年化波动率 18.0% 29.1% 67.0%
波动率最低和最高的月份 3.5% / 93.3% 7.8% / 106.7% 16.7% / 183.6%
偏度 −0.31 +0.12 −0.22
超额峰度 14.73 6.85 8.74
超过 4 个标准差的天数(正态预期) 20(0.16) 16(0.16) 19(0.21)
最差 1% 日子的平均收益率(正态估计) −4.76%(−2.97%) −6.69%(−4.77%) −13.14%(−9.20%)
滞后 1 天自相关(去掉 2020 年) −0.131(−0.033) −0.061(+0.008) −0.048(−0.026)
收益率绝对值的滞后 1 天自相关 0.359 0.229 0.178
季度方差比 0.60 0.85 1.36

从这张表里可以读出这门课后面会反复用到的几件事:

  1. 三个标的的波动率差异巨大。BTC 的年化波动率是 SPY 的 3.7 倍。同样的止损百分比、同样的仓位比例,放在这三个标的上承担的风险完全不同。第 15 篇的 ATR 和第 26 篇的仓位管理,都是为了解决这个问题。
  2. 波动率本身变化更大。同一个标的,最平静和最剧烈的月份相差 10 倍到 27 倍。用固定参数应对变化的波动率,是许多策略失效的原因。
  3. 三个标的都有肥尾,SPY 以自身标准差衡量反而最肥。任何用正态分布估计的风险数字,在尾部都要打上很大的折扣。
  4. 日收益率的方向几乎不可预测,波动幅度可以预测。去掉 2020 年后,三个标的的滞后 1 天自相关都在界限之内;而收益率绝对值的自相关,三个标的都显著为正。
  5. BTC 在长周期上呈现出一定的延续性,SPY 呈现出一定的反转性,但样本很少,证据很弱。这会在第 31、32 篇讲趋势跟随和均值回归时再次检验。

⚠️ 这张表描述的是过去十年。第 3 条和第 4 条是金融市场里被反复验证过的普遍特征,在更长的历史和更多的市场上都成立。第 1 条的具体数字、第 5 条的方向,则可能随时间改变:BTC 的年化波动率就从 2017 年的 110.6% 降到了 2025 年的 41.7%。


九、常见误用

1. 把多天的简单收益率直接相加。 2020 年 3 月 12 日和 13 日,相加是 −23.28%,实际是 −29.69%。多天的收益率要连乘,或者用对数收益率相加。

2. 用日均收益率推算长期收益。 BTC 日均 0.1504%,按它复利是 142.84 倍,实际是 18.34 倍。长期增长看复合增长率,或者对数收益率的均值。

3. 年化时用错一年的天数。 加密货币用 365,美股用 252。BTC 用 √252 会把年化波动率从 67.0% 低估到 55.7%。

4. 用长期平均波动率描述当前的市场。 决策点的模型用了 365 天的平均波动率 3.56%,而接下来 5 天的波动率是 7.40%。波动率会成片地升高和降低,风险估计应该使用近期的波动率,并且随时更新。

5. 用正态分布估计极端风险。 BTC 单日跌 15% 以上,正态分布说 349 年一次,实际九年 5 次。在 3 个标准差以外,正态分布会低估风险几倍到几十万倍。

6. 过度相信峰度、自相关的具体数值。 BTC 的超额峰度去掉一天就减少一半;三个标的的负自相关去掉 2020 年就消失。看到一个统计量,先检查它是不是被少数几天决定的。

7. 用价格算两个资产的相关性。 SPY 和 BTC 的价格相关系数是 0.90,收益率相关系数是 0.28。两个独立的随机游走,有 37.1% 的机会价格相关系数超过 0.5。

8. 衡量「当天有多极端」时,把当天也放进了标尺。 用包含当天的窗口计算波动率,暴跌日会把自己的标准差撑大,显得没那么极端。窗口必须只包含之前的日子。

9. 用没有调整的价格算收益率。 AAPL 2020 年 8 月 31 日拆股,未调整的价格会显示一天跌 74.2%(第 3 篇)。这一个假收益率就能让峰度、最大跌幅、尾部统计全部失真。


十、这一篇能回答什么,不能回答什么

这一篇帮你回答 这一篇不能回答
一段时间实际涨了多少,平均每年增长多少 接下来会涨还是会跌
一个标的波动有多大,和另一个标的相比如何 波动率明天具体是多少(只知道它和近期相关)
某个跌幅在历史上多久出现一次 下一次极端行情什么时候来、会有多大
正态分布在尾部会低估多少 历史上没出现过的极端情况(历史 ES 只是下限)
一个自相关是否只来自少数几天 一个统计上稳定的规律,扣除成本后能不能赚钱(第六部分)
为什么指标要建立在收益率上 具体用哪个指标更好(第三部分)

十一、小结

  1. 收益率有两种算法。 简单收益率 r = P₁ ÷ P₀ − 1,对数收益率 R = ln(P₁ ÷ P₀)。涨跌幅小时两者几乎一样,涨跌幅大时差距迅速拉开。

  2. 沿着时间加,用对数;在资产之间加,用简单。 对数收益率可以跨天相加;组合的收益率等于各资产简单收益率的加权平均。

  3. 波动率拖累:对数收益率均值 ≈ 简单收益率均值 − 方差 ÷ 2。 BTC 日均 0.1504%,复利下来不是 142.84 倍而是 18.34 倍。波动越大,拖累越大。

  4. 年化波动率 = 日波动率 × √一年的天数。 美股用 252,加密用 365。平方根来自「独立随机变量的方差可以相加」,涨跌会互相抵消。

  5. 平方根法则要求收益率互相独立。 方差比可以检验这一点:SPY 的季度方差比 0.60,BTC 是 1.36。

  6. 波动率本身在变,而且成片出现。 SPY 最平静和最剧烈的月份,年化波动率相差 27 倍。

  7. 收益率有肥尾。 在 2 个标准差以内,正态分布是不错的近似;在 4 个标准差处,实际频率是正态分布的约 100 倍。BTC 单日跌 15% 以上,正态分布说 349 年一次,实际九年 5 次。

  8. 肥尾有两个来源:波动率聚集和跳跃。 按近期波动率标准化,能消除 SPY 大部分的峰度,但消除不了「平静中的突然跳跃」:财报、政策、原因不明的暴涨暴跌。

  9. 风险估计用历史的尾部,并在它上面留出余量。 最差 1% 的日子,SPY 实际平均跌 4.76%,正态分布估计只有 2.97%。历史 ES 是风险的下限。

  10. 收益率方向的自相关很弱且不稳定,收益率幅度的自相关很强。 三个标的「显著」的负自相关,去掉 2020 年后都不再显著;而收益率绝对值的自相关都显著为正。预测方向很难,预测波动相对容易。

  11. 检查一个统计规律,问三个问题: 去掉最极端的时期还在吗?按年拆开是否每年都有?打乱检验的结果是什么?

  12. 价格不平稳,收益率近似平稳。 用价格算相关性会产生伪相关;所有统计都应该建立在收益率上,好的指标也都在做同样的事情。

最后回到那条河。水文站说「百年一遇」,意思是模型算出的概率是每年 1%。如果这个模型假设水位的变化是钟形分布,又用了一个包含旱季和汛期的平均幅度,那么它在汛期到来时,一定会系统性地低估洪水。2022 年 6 月 12 日那个「208 年一次」,就是这样一个模型的输出。下一次看到任何「几百年一次」的风险数字,先问它是怎么算出来的;然后去数一数,历史上到底发生过几次。

到这里,第一部分「地基」结束了。你知道了一张价格图记录了什么、谁在交易、数据从哪里来、怎么把它画出来,以及怎么用统计量描述它。

下一篇进入第二部分「读懂价格行为」。我们从最熟悉的东西开始:一根 K 线。 它用四个数字概括了一段时间里成千上万笔成交,这个概括丢掉了什么?两根 OHLC 完全一样的 K 线,背后的走势可以有多不同?


练习

练习 1(手算) 某资产连续 6 天的收盘价是:100、104、98、101、107、103。

  • (a) 算出 5 个简单收益率和 5 个对数收益率。
  • (b) 用对数收益率之和算出总收益率,并和 103 ÷ 100 − 1 对比。
  • (c) 手算简单收益率的样本标准差。如果这是一只美股,年化波动率是多少?如果是加密货币呢?
  • (d) 手算简单收益率滞后 1 天的自相关系数。它在 ±1.96 ÷ √5 的范围之内吗?

练习 2(数据)talab.stats 分析 TSLA(第 3 篇已经下载了它的日线):

  • (a) 画出它的统计档案,和 AAPL 对比。TSLA 的年化波动率、超额峰度分别是多少?
  • (b) 用 standardize(r, 20) 找出 TSLA 按近期波动率衡量最极端的 5 天。查一查这些日子发生了什么,其中有几天是财报日?

练习 3(决策点复盘) 回到 2022 年 6 月 12 日,分别用过去 30 天、90 天、365 天、以及当时能拿到的全部历史数据估计波动率,计算正态分布下「单日跌 15% 以上」几年一次。再直接数一数当时的全部历史里有几次。哪种估计最接近实际?为什么窗口越短不一定越好?

练习 4(编程)talab.stats 增加一个函数 ewma_vol(returns, lam=0.94):用指数加权的方式估计波动率,今天的方差估计 = lam × 昨天的方差估计 + (1 − lam) × 昨天收益率的平方。

  • (a) 写一个测试:收益率恒为 0.01 时,足够多天之后估计值应该收敛到 0.01。
  • (b) 用它代替 standardize 里的 20 天标准差,重新计算三个标的标准化后的超额峰度,和正文的结果对比。

练习 5(数据) 第五节发现收益率本身的自相关不稳定。那么收益率绝对值的自相关呢?按年计算三个标的收益率绝对值的滞后 1 天自相关,看看它是不是每一年都显著为正。

练习 6(思考 + 数据) 平方根法则说,1 分钟的波动率乘以 √1440 应该等于 1 天的波动率。用 BTC 2025 年的 1 分钟数据验证这件事:分别算出 1 分钟、5 分钟、60 分钟、1 天的收益率标准差,和按平方根法则从 1 分钟推算出的值对比。如果对不上,是偏大还是偏小?结合第五节,你能想到什么原因?(提示:想想第 1 篇讲的买卖价差,成交价在买一和卖一之间来回跳动,会让相邻两分钟的收益率呈现什么样的自相关。)


小检查答案

小检查 1

(a) 简单收益率之和:20% + (−20%) = 0。实际总收益率:1.20 × 0.80 − 1 = −4%

(b) 对数收益率:ln(1.20) = +18.23%,ln(0.80) = −22.31%,相加 = −4.08%。换回简单收益率:e^(−0.0408) − 1 = −4.00%,和 (a) 一致。

小检查 2

(a) 美股:2% × √252 = 2% × 15.87 = 31.7%。加密:2% × √365 = 2% × 19.10 = 38.2%。日波动率相同,年化波动率不同,因为加密货币一年多出 113 个交易日。

(b) 一年有 52 周,5% × √52 = 5% × 7.21 = 36.1%

小检查 3

(a) 整体方差 = 0.8 × 1² + 0.2 × 4² = 0.8 + 3.2 = 4,整体标准差 = 2%

(b) 3 个整体标准差 = 6%。

  • 平静的日子:6% 是 6 个标准差,概率几乎为 0
  • 剧烈的日子:6% 是 6 ÷ 4 = 1.5 个标准差,双侧概率 13.4%
  • 混合:0.2 × 13.4% = 2.67%

正态分布预测 0.27%,混合后约是它的 9.9 倍。和正文里「各占一半」的算例(4.7 倍)相比,剧烈的日子更少但更剧烈,尾巴反而更肥。这有助于理解为什么平时很平静、偶尔剧烈暴跌的 SPY,峰度反而比一直很剧烈的 BTC 更高。

小检查 4

只有 A 会被影响。

  • A 用到了 standardize:去掉 .shift(1) 后,2018-10-10 的 −3.17% 会被放进自己的 20 天窗口里,把标准差从 0.41% 撑大,倍数会明显小于 7.7。
  • B 和 C 用的是全样本的均值和标准差(kurt()tail_table),没有经过 standardize,不受影响。

顺便想一想:全样本的标准差,其实也包含了「当天」。为什么在描述整段历史的统计特征时,这样做没有问题;而在决策点那种「站在某一天判断接下来的风险」的场景下,就必须只用之前的数据?