回测曲线一路向东北,夏普 1.8,你很满意。但放进 2008 年会怎样?2015 年连续跌停呢?2020 年疫情那种 V 型急跌呢?
不在极端行情里测过的策略,上线就是裸奔。
压力测试不是看历史最大回撤那么简单。本文教你用蒙特卡洛 + 三场景冲击,量化组合在极端行情下的尾部风险。
一、本文你将得到什么
- 三种典型极端场景的冲击设计:2008 金融危机、2015 股灾、2020 疫情。
- 蒙特卡洛模拟 1000 条路径,输出中位数、5% 分位、最大回撤、VaR、ES。
- 一段可直接运行的 Python 脚本,输入你的策略收益率就能跑压力测试。
二、为什么做压力测试
历史回测只告诉你「过去发生过什么」,压力测试告诉你「如果再次发生会怎样」:
- 2008:系统性暴跌 + 流动性枯竭,资产相关性趋近于 1。
- 2015:杠杆断裂 + 连续跌停,波动率和左尾同时放大。
- 2020:突发黑天鹅 + 政策救市,急跌后快速反弹,V 型走势。
一个策略如果在三种场景下都死不掉,才有资格谈稳健。
三、压力测试设计思路
我们用历史日收益率分布作为「基础分布」,然后叠加三类冲击:
- 均值下挪:整体收益环境变差。
- 波动放大:标准差乘 2~3 倍。
- 结构冲击 :
- 2008:随机加入 -5% 跳空(左尾)。
- 2015:前 5 日连续 -7%。
- 2020:前 1/3 急跌、中 1/3 震荡、后 1/3 反弹。
每个场景模拟 1000 条路径,观察组合净值分布。
四、代码实战
python
import json
import random
import numpy as np
from mairui import Client
DEMO_LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073" # 演示证书,请换成你自己的正式证书
CODE = "600519"
INIT_NAV = 100000.0 # 初始组合净值
POSITION_RATIO = 0.6 # 股票仓位 60%
def perturb_ohlc(bars, seed=2026091305):
rng = random.Random(seed)
out = []
for b in bars:
k = 1.0 + (rng.random() - 0.5) * 0.10
nb = dict(b)
for f in ("o", "c", "h", "l"):
if f in nb and nb[f] is not None:
nb[f] = round(float(nb[f]) * k, 2)
out.append(nb)
return out
def max_drawdown(nav_path):
peak = np.maximum.accumulate(nav_path)
dd = (nav_path - peak) / peak
return float(dd.min())
def var_es(returns, alpha=0.05):
sorted_r = np.sort(returns)
idx = int(alpha * len(sorted_r))
var = sorted_r[idx]
es = sorted_r[:idx].mean()
return float(var), float(es)
def scenario_paths(base_returns, scenario, n_path=1000):
r = base_returns.copy()
n = len(r)
paths = []
if scenario == "crisis_2008":
mu_shift = -0.003
vol_mult = 3.0
for _ in range(n_path):
shocks = np.random.normal(mu_shift, r.std() * vol_mult, n)
jumps = np.random.rand(n) < 0.05
shocks[jumps] -= 0.05
nav = INIT_NAV * np.cumprod(1 + shocks * POSITION_RATIO)
paths.append(nav)
elif scenario == "crash_2015":
for _ in range(n_path):
shocks = np.random.normal(-0.002, r.std() * 2.5, n)
if n >= 5:
shocks[:5] = -0.07
nav = INIT_NAV * np.cumprod(1 + shocks * POSITION_RATIO)
paths.append(nav)
elif scenario == "pandemic_2020":
for _ in range(n_path):
shocks = np.random.normal(0, r.std() * 2.0, n)
t1, t2 = n // 3, 2 * n // 3
shocks[:t1] += np.linspace(-0.04, -0.01, t1)
shocks[t1:t2] += np.random.normal(0, 0.015, t2 - t1)
shocks[t2:] += np.linspace(0.01, 0.04, n - t2)
nav = INIT_NAV * np.cumprod(1 + shocks * POSITION_RATIO)
paths.append(nav)
return np.array(paths)
def summarize(paths):
final_nav = paths[:, -1]
total_returns = (final_nav - INIT_NAV) / INIT_NAV
mdds = [max_drawdown(p) for p in paths]
daily_returns = np.diff(paths, axis=1).flatten() / paths[:, :-1].flatten()
var, es = var_es(daily_returns, 0.05)
return {
"期末净值中位数": round(float(np.median(final_nav)), 2),
"期末净值 5% 分位": round(float(np.percentile(final_nav, 5)), 2),
"总收益率中位数": f"{float(np.median(total_returns)) * 100:.2f}%",
"总收益率 5% 分位": f"{float(np.percentile(total_returns, 5)) * 100:.2f}%",
"最大回撤中位数": f"{float(np.median(mdds)) * 100:.2f}%",
"最大回撤 95% 分位": f"{float(np.percentile(mdds, 95)) * 100:.2f}%",
"日收益 VaR(5%)": f"{var * 100:.2f}%",
"日收益 ES(5%)": f"{es * 100:.2f}%",
}
np.random.seed(20260913)
client = Client(DEMO_LICENCE)
bars = perturb_ohlc(client.stock_history(code=CODE, period="d", dividend="f"))
closes = np.array([float(b["c"]) for b in bars])
base_returns = np.diff(closes) / closes[:-1]
scenarios = {
"2008 金融危机(系统性暴跌 + 左尾跳空)": "crisis_2008",
"2015 股灾(连续跌停 + 高波动)": "crash_2015",
"2020 疫情(V 型急跌反弹)": "pandemic_2020",
}
result = {
"基础日收益统计": {
"样本数": int(len(base_returns)),
"均值": f"{float(base_returns.mean()) * 100:.4f}%",
"标准差": f"{float(base_returns.std()) * 100:.4f}%",
},
"压力测试结果": {},
}
for name, key in scenarios.items():
paths = scenario_paths(base_returns, key, n_path=1000)
result["压力测试结果"][name] = summarize(paths)
print(json.dumps(result, ensure_ascii=False, indent=2))
五、运行结果(演示环境)
json
{
"基础日收益统计": {
"样本数": 49,
"均值": "0.0936%",
"标准差": "3.8243%"
},
"压力测试结果": {
"2008 金融危机(系统性暴跌 + 左尾跳空)": {
"期末净值中位数": 75266.25,
"期末净值 5% 分位": 34923.93,
"总收益率中位数": "-24.73%",
"总收益率 5% 分位": "-65.08%",
"最大回撤中位数": "-46.00%",
"最大回撤 95% 分位": "-22.86%",
"日收益 VaR(5%)": "-11.75%",
"日收益 ES(5%)": "-14.64%"
},
"2015 股灾(连续跌停 + 高波动)": {
"期末净值中位数": 72580.76,
"期末净值 5% 分位": 38035.42,
"总收益率中位数": "-27.42%",
"总收益率 5% 分位": "-61.96%",
"最大回撤中位数": "-38.51%",
"最大回撤 95% 分位": "-20.64%",
"日收益 VaR(5%)": "-9.27%",
"日收益 ES(5%)": "-11.69%"
},
"2020 疫情(V 型急跌反弹)": {
"期末净值中位数": 98450.61,
"期末净值 5% 分位": 58464.43,
"总收益率中位数": "-1.55%",
"总收益率 5% 分位": "-41.54%",
"最大回撤中位数": "-32.82%",
"最大回撤 95% 分位": "-16.00%",
"日收益 VaR(5%)": "-7.72%",
"日收益 ES(5%)": "-9.69%"
}
}
}
关键结论:
- 2008 / 2015:组合大概率腰斩,5% 分位下亏损超过 60%,说明单一高仓位策略在极端行情下非常脆弱。
- 2020:中位数接近回本,但 5% 分位仍亏损 41%,说明 V 型反弹对大多数人只是「幸存者偏差」,尾部风险不可忽视。
六、常见坑与注意
- 不要把历史最大回撤当压力测试。历史最大回撤只是过去发生过的最坏情况,不代表未来。
- 仓位是核心变量。示例里股票仓位 60%,如果你仓位 90%,所有损失数字会再放大 1.5 倍。
- 不要只跑一个场景。三种场景分别测试系统性暴跌、流动性危机、黑天鹅 V 型,漏掉任何一种都可能踩雷。
- 演示证数据窗口有限。正式环境换自己的 licence 后,可用更长的历史窗口做 bootstrapping,模拟结果会更稳定。
七、小结
压力测试的价值不是预测未来,而是:
- 让你知道「如果 2008 重来,我会亏多少」。
- 让你在上线前决定是否降仓位、加对冲、或重写策略。
- 让你在客户问起时,有数字而不是有信仰。
下篇预告:【Python量化策略评估体系 #06】策略上线前 8 项必查:一份可落地的评估 checklist。
了解更多
- 代码与文档:github.com/MaiRuiApi
免责声明:本文仅为技术方法演示,不构成投资建议。历史表现不代表未来收益,投资有风险,入市需谨慎。