1. 从一次风控复盘说起:为什么单纯看VaR会翻车
前阵子帮一个做量化策略的朋友复盘他的组合回撤,他给我看了一张图,横轴是日期,纵轴是每日盈亏,红线是他自己算的95% VaR阈值。图上大部分时间盈亏都在红线以上,看起来风平浪静,但偏偏有那么三天,亏损直接击穿了红线,而且击穿幅度是阈值的两到三倍。他当时的第一反应是"模型参数没调好",想重新拟合一下波动率窗口。我拦住他,说先别急着调参,你先把Expected Shortfall(ES)算出来看看,再跑一遍VaR的backtesting,问题大概率不在参数上,而在你只看了VaR这一个分位点。
这个场景在风险管理里太常见了。VaR(Value at Risk,在险价值)回答的是"在给定置信水平下,未来一段时间内最大可能损失是多少",比如95% VaR等于100万,意思是明天有95%的概率亏损不超过100万。但它有个致命缺陷:它只告诉你门槛在哪,不告诉你门槛被跨过之后会摔多惨。那三天击穿红线的亏损,VaR完全没预警,因为它压根不关心尾部有多厚。Expected Shortfall(也叫CVaR、条件在险价值、平均尾部损失)补的就是这个洞——它算的是"当损失超过VaR阈值时,平均会亏多少"。
这篇东西我打算把ES和VaR backtesting这两件事从头到尾捋一遍。不是教科书式的定义罗列,而是按我实际做风控时踩过的顺序来:先讲清楚ES到底怎么算、为什么比VaR更适合管尾部风险,再讲VaR backtesting的几种主流方法(Kupiec、Christoffersen、以及基于ES的检验),最后落到实操——数据怎么准备、参数怎么选、结果怎么读、哪些坑我踩过。适合做量化、做组合风控、或者单纯想搞懂自己投资组合尾部风险的朋友。哪怕你数学一般,我也会尽量用生活化的例子把公式背后的直觉讲明白。
2. VaR与ES的核心原理拆解:一个看门槛,一个看门槛后面的坑
2.1 VaR到底在算什么:分位数的直觉
VaR本质上就是一个分位数。假设你有一个收益率序列,把它从小到大排序,95% VaR就是第5百分位对应的那个损失值(注意方向,损失是负收益,所以是左尾)。换句话说,有5%的历史(或模拟)情景,损失会比这个值更糟。
用生活类比:你每天开车上班,统计了过去一年的通勤时间。95% VaR就像"最堵的那5%的日子里,堵车时间的门槛值"。它告诉你"大部分日子不会比这更堵",但完全没说最堵的那几天到底堵成什么样——可能只是多堵10分钟,也可能直接堵到中午。
计算VaR有三条主流路径,我按实际使用频率排一下:
- 历史模拟法:直接把历史收益率排序取分位数。优点是简单、不假设分布;缺点是极度依赖历史窗口,窗口里没出现过的极端行情就估不出来。
- 参数法(方差-协方差法):假设收益率服从正态分布,用均值和标准差算分位数。优点是快、解析解;缺点是正态分布假设在金融数据里基本不成立,尾部太薄。
- 蒙特卡洛模拟:用模型生成大量情景再取分位数。优点是灵活;缺点是慢、且依赖模型设定。
我个人的经验是,做日频风控用历史模拟法打底,配合参数法做交叉验证,蒙特卡洛留给复杂衍生品组合。单纯用参数法在股票组合上,2008、2015、2020那种行情里会严重低估风险。
2.2 ES为什么是VaR的补丁:条件期望的威力
ES的定义是:损失超过VaR阈值的那部分情景下,损失的期望值。数学上写成:
ES_α = E[L | L > VaR_α]
其中L是损失,α是置信水平。还是用通勤类比:ES就是"在最堵的那5%的日子里,平均堵多久"。这个数字天然比VaR大,因为它把尾部所有糟糕情景都平均进来了。
ES相比VaR有三个实打实的优势,这也是为什么巴塞尔协议在2016年之后把市场风险的内部模型法从VaR转向了ES:
第一,ES是次可加性的。意思是把两个组合合并,合并后的ES不会超过两个组合ES之和。VaR不满足这个性质,会出现"分散化反而增加风险"的悖论,这在理论上就站不住脚。次可加性意味着ES是一致性风险度量,VaR不是。
第二,ES对尾部形状敏感。两个组合可能有相同的VaR,但一个尾部很薄、一个尾部很厚,ES能区分出来,VaR不能。前面我朋友那个案例就是典型:VaR看起来正常,但ES会明显偏高,提示尾部有肥尾。
第三,ES更平滑。VaR对分位点附近的样本很敏感,样本稍微动一下,VaR可能跳变。ES因为是尾部平均,稳定性更好,回测时不会因为一两个样本就剧烈波动。
但ES也不是没缺点。它需要估计尾部期望,对样本量要求更高,尾部样本本来就少,估计误差大。而且ES不像VaR那样有明确的"击穿"事件可以计数,回测方法更复杂,这也是后面要重点讲的。
2.3 两者的计算实操:从收益率序列到风险数字
我拿一段模拟的日收益率序列走一遍流程,你可以直接套。假设有1000个交易日的日收益率,置信水平取97.5%(巴塞尔用的就是这个,对应ES)。
第一步,把收益率转成损失:L = -r。第二步,排序损失,取第97.5百分位作为VaR。第三步,把所有超过VaR的损失取平均,得到ES。
import numpy as np # 假设 returns 是长度为1000的日收益率数组 losses = -returns var_975 = np.percentile(losses, 97.5) tail_losses = losses[losses >= var_975] es_975 = tail_losses.mean() print(f"97.5% VaR: {var_975:.4f}") print(f"97.5% ES: {es_975:.4f}") print(f"尾部样本数: {len(tail_losses)}")这里有个实操细节:np.percentile默认用线性插值,尾部样本数大概是1000 * 2.5% = 25个。25个样本估一个期望,标准误不小。所以我一般会做两件事:一是用更长的窗口(至少500个交易日,最好1000+),二是用极值理论(EVT)对尾部做参数化拟合再算ES,减少样本不足带来的噪声。
注意:置信水平的选择直接影响尾部样本量。99% VaR在1000个样本里只有10个尾部点,ES估计会非常不稳。巴塞尔选97.5%不是随便定的,就是为了在覆盖极端风险和保证样本量之间取平衡。
3. VaR Backtesting的三大主流方法:从计数到独立性
3.1 为什么VaR需要回测:模型不是算完就完事
VaR是个统计模型,任何模型都需要验证它在样本外是否靠谱。Backtesting的核心逻辑是:如果模型说"95%的日子损失不超过VaR",那实际数据里击穿VaR的天数应该接近5%。如果实际击穿率远高于5%,说明模型低估风险;远低于5%,说明模型过于保守,浪费资本。
这里有个关键概念叫击穿(exception/exceedance):某天实际损失超过了前一天预测的VaR,就算一次击穿。回测就是统计击穿次数,然后检验它是否符合预期。
我见过太多人算完VaR就扔那不管了,等到真出事才发现模型早就失效了。回测不是可选项,是必选项。而且回测要滚动做,不能只做一次——市场结构在变,模型的适用性也在变。
3.2 Kupiec POF检验:最基础的无条件覆盖检验
Kupiec的POF(Proportion of Failures)检验是最经典的一个,它只关心一件事:击穿率对不对。原假设是"实际击穿率等于预期击穿率"。
检验统计量是似然比:
LR_POF = -2 * ln[ (1-p)^(N-x) * p^x / ((1-π)^(N-x) * π^x) ]
其中N是总天数,x是击穿次数,p是预期击穿率(比如95% VaR对应5%),π = x/N是实际击穿率。这个统计量服从自由度为1的卡方分布。
我实际用的时候,会把它整理成一张速查表,方便快速判断:
| 置信水平 | 预期击穿率 | 250天预期击穿数 | 可接受范围(95%置信) |
|---|---|---|---|
| 95% | 5% | 12.5 | 6 ~ 21 |
| 97.5% | 2.5% | 6.25 | 2 ~ 12 |
| 99% | 1% | 2.5 | 0 ~ 7 |
这张表的意思是,如果你用95% VaR跑了250天,击穿次数落在6到21之间,那在95%置信水平下不能拒绝模型;超出这个范围,模型就有问题。
Kupiec检验的优点是简单直观,缺点是它只看击穿次数,不看击穿的时间分布。一个模型可能击穿次数刚好,但所有击穿都集中在某一个月,这明显说明模型在某些市场状态下失效了,Kupiec却检测不出来。
3.3 Christoffersen独立性检验:击穿是不是扎堆出现
Christoffersen检验补的就是Kupiec的短板。它检验击穿事件之间是否独立,具体是看"今天击穿"和"昨天击穿"之间有没有相关性。
它构造一个一阶马尔可夫链,统计四个量:n00(昨天没击穿今天也没)、n01(昨天没击穿今天击穿)、n10(昨天击穿今天没)、n11(昨天击穿今天也击穿)。然后检验条件击穿率是否相等:
LR_IND = -2 * ln[ L(独立) / L(马尔可夫) ]
这个统计量也服从卡方分布,自由度1。如果拒绝原假设,说明击穿是扎堆的,模型在波动率聚集时期失效——这在金融数据里太常见了,因为波动率本身就有聚集性。
Christoffersen还提出了一个联合检验,把POF和独立性合在一起,统计量是两者之和,自由度2。我一般直接跑联合检验,一步到位。
3.4 基于ES的回测:更难的题目
ES的回测比VaR难得多,因为ES没有"击穿"这种离散事件可以计数。目前主流有几类方法:
第一类是Acerbi-Szekely检验,它构造两个统计量Z1和Z2,分别检验ES的水平和尾部风险的稳定性。Z1检验实际尾部平均损失是否等于预测ES,Z2检验尾部损失的超额部分是否独立于VaR水平。
第二类是Fissler-Ziegel检验,基于联合损失函数(joint loss function),把VaR和ES放在一起评估。这个方法的理论最扎实,因为它是对ES的elicitability问题的回应——ES不是elicitable的,单独用某个损失函数无法唯一地识别它,必须和VaR联合。
第三类是模拟对比法,用bootstrap生成大量样本,比较实际ES和预测ES的分布。这个方法直观但计算量大。
我实操里最常用的是Acerbi-Szekely的Z1,因为它相对好实现,而且对尾部风险的捕捉够用。Z2作为补充,看尾部稳定性。
# Acerbi-Szekely Z1 简化实现 def as_z1_test(returns, var_pred, es_pred, alpha=0.975): losses = -returns exceedances = losses[losses > var_pred] if len(exceedances) == 0: return None # Z1 统计量 n = len(returns) p = 1 - alpha z1 = (exceedances.mean() / es_pred - 1) * np.sqrt(len(exceedances)) / np.sqrt(p * (1-p)) return z1注意:ES回测的样本量要求比VaR更高。97.5%置信下,1000个样本只有25个尾部点,Z1统计量的功效很低。实务里我会用至少2000个交易日,或者用重叠窗口增加样本,但要小心自相关。
4. 完整实操流程:从数据准备到结果解读
4.1 数据准备与清洗:脏数据比坏模型更致命
回测的第一步永远是数据。我踩过最大的坑就是数据没洗干净,导致回测结果完全不可信。
几个必须做的清洗步骤:
- 处理停牌和缺失值:股票停牌期间没有收益率,直接跳过会导致时间序列不连续。我的做法是停牌日用0填充,但标记出来,回测时单独看这些日子的影响。
- 复权处理:用前复权还是后复权?做回测我一般用后复权,因为前复权会随着新数据不断变化,导致历史数据不稳定。
- 异常值检查:有些数据源会有明显的错误值,比如收益率突然出现-50%又+100%。这种要么是数据错误,要么是极端事件,需要人工确认。
- 时间对齐:如果组合里有多个资产,确保所有资产的时间戳对齐,跨时区的资产要特别小心。
我一般会写一个数据质量报告,统计缺失率、异常值数量、时间跨度,作为回测的前置检查。这个习惯帮我避免了好几次"模型没问题但结果离谱"的尴尬。
4.2 滚动窗口回测框架:参数怎么选
回测框架的核心是滚动窗口。每个交易日,用过去W天的数据估计VaR和ES,然后和当天的实际损失比较,记录击穿。
窗口长度W的选择是个权衡:
- 窗口太短(比如250天):对市场变化反应快,但估计噪声大,尾部样本少。
- 窗口太长(比如2000天):估计稳定,但对市场结构变化反应慢,可能把过时的信息也算进来。
我的经验值是500到1000天。对于波动率变化快的市场(比如加密货币),用250到500天;对于成熟市场(比如大盘股),用1000天左右。
还有一个选择是等权还是指数加权。等权简单,但近期数据应该更重要。指数加权(EWMA)给近期数据更高权重,对波动率聚集的捕捉更好。我一般用EWMA估计波动率,再用历史模拟法取分位数,算是两者的折中。
def rolling_backtest(returns, window=500, alpha=0.975): n = len(returns) var_list, es_list, breaches = [], [], [] for t in range(window, n): hist = returns[t-window:t] losses = -hist var = np.percentile(losses, alpha*100) tail = losses[losses >= var] es = tail.mean() if len(tail) > 0 else var var_list.append(var) es_list.append(es) breaches.append(1 if -returns[t] > var else 0) return np.array(var_list), np.array(es_list), np.array(breaches)跑完这个,你就有了每天的VaR预测、ES预测和击穿标记。接下来就是统计检验。
4.3 结果解读:击穿率、独立性、ES偏差
拿到回测结果后,我一般看三个层面:
第一层,击穿率。实际击穿次数除以总天数,和预期比。如果偏离超过20%,就要警惕。比如95% VaR预期5%,实际跑到7%,说明模型低估风险。
第二层,击穿的时间分布。把击穿点画在时间轴上,看是不是扎堆。如果集中在某几个时间段,说明模型在特定市场状态下失效。这时候要去看那段时间的市场特征,是波动率飙升还是相关性突变。
第三层,ES偏差。实际尾部平均损失和预测ES的比值。如果实际是预测的1.5倍,说明尾部风险被严重低估。这个指标比击穿率更敏感,因为它是连续值。
我整理了一个结果解读的对照表:
| 指标 | 正常范围 | 警告信号 | 可能原因 |
|---|---|---|---|
| 击穿率偏差 | ±20%以内 | 超过±30% | 模型低估/高估风险 |
| 击穿扎堆 | 均匀分布 | 集中在少数时段 | 波动率聚集未建模 |
| ES比值 | 0.9 ~ 1.1 | 超过1.3 | 尾部肥厚,分布假设错误 |
| 最大单次击穿 | 小于2倍VaR | 超过3倍VaR | 极端事件,模型完全失效 |
4.4 一个完整的回测案例:模拟组合的实操记录
我用一段模拟的股票组合日收益率(2000个交易日)走一遍完整流程。数据是我用几何布朗运动加上跳跃项生成的,故意让尾部比正态分布厚,用来测试模型在肥尾下的表现。
第一步,数据准备。2000个交易日,无缺失,收益率均值0.0003,标准差0.012,跳跃项概率1%,跳跃幅度均值-0.05。
第二步,滚动回测。窗口500天,97.5%置信,跑1500次预测。
第三步,统计结果。击穿次数实际是52次,预期是1500 * 2.5% = 37.5次。击穿率3.47%,明显高于预期2.5%。Kupiec检验的LR统计量算出来是5.8,超过卡方分布95%分位3.84,拒绝原假设,模型低估风险。
第四步,独立性检验。n11 = 8,n01 = 44,n10 = 44,n00 = 1404。条件击穿率:昨天击穿今天击穿的概率是8/(8+44)=15.4%,昨天没击穿今天击穿的概率是44/(44+1404)=3.0%。两者差很多,说明击穿扎堆。Christoffersen独立性检验LR统计量是12.3,拒绝独立假设。
第五步,ES检验。实际尾部平均损失是预测ES的1.28倍,Z1统计量2.9,在5%水平下显著,说明ES也被低估。
结论:这个模型在肥尾数据下全面失效。原因是用历史模拟法但窗口内极端样本不足,加上正态分布假设的隐含影响。改进方向是用EVT拟合尾部,或者用更长的窗口。
这个案例说明,回测不是走过场,它能精确定位模型的问题在哪。
5. 常见问题与排查技巧实录
5.1 击穿率正常但ES偏差大:尾部形状的问题
这是最容易被忽略的情况。击穿率刚好5%,Kupiec检验通过,但ES比值1.4,说明尾部平均损失被低估。原因通常是分布假设错误——用正态分布拟合肥尾数据,VaR阈值可能碰巧对,但尾部期望完全不对。
排查方法:画QQ图,看尾部偏离。如果尾部明显偏离对角线,就是分布问题。解决方法是换用t分布、极值理论或者直接用历史模拟。
5.2 回测结果对窗口长度极度敏感:参数不稳健
如果窗口从500天换到600天,击穿率从4%跳到8%,说明模型不稳健。这种情况通常发生在市场结构变化快的时期,或者数据里有异常值。
我的处理方式是做敏感性分析,跑多个窗口长度,看结果是否稳定。如果只在某个窗口下表现好,那大概率是过拟合。
5.3 零击穿:模型过于保守
击穿次数为0,看起来"很安全",但其实是模型过于保守,浪费资本。这种情况常见于用很长的窗口加上正态分布假设,把VaR估得过高。
判断方法:看ES和VaR的比值。如果ES只比VaR高一点点,说明尾部很薄,可能是模型把尾部削平了。正常肥尾数据里,97.5% ES应该比VaR高20%到50%。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 击穿率偏高 | 模型低估风险 | 检查窗口长度、分布假设 | 换肥尾分布、缩短窗口 |
| 击穿扎堆 | 波动率聚集未建模 | 看击穿时间分布 | 用GARCH、EWMA |
| ES偏差大 | 尾部形状错误 | QQ图、尾部指数 | EVT、历史模拟 |
| 零击穿 | 模型过于保守 | 看ES/VaR比值 | 缩短窗口、降低置信 |
| 结果对窗口敏感 | 参数不稳健 | 敏感性分析 | 用自适应窗口 |
实操心得:回测不是一次性的,要定期做。我一般每月跑一次全量回测,每周看一次击穿监控。市场结构变化时(比如政策突变、流动性枯竭),要立即重跑。
6. 我踩过的坑和几条实用建议
第一个坑是用未来数据。早期我做回测时,不小心用了当天收盘后的数据去估计当天的VaR,结果击穿率低得离谱。后来严格用T-1日数据预测T日风险,才符合实盘逻辑。这个错误很隐蔽,因为代码跑起来不报错,但结果完全失真。
第二个坑是忽略交易成本。回测里只看收益率,没考虑调仓成本。实盘里频繁调仓会吃掉收益,也会改变风险特征。做组合回测时,要把换手率纳入考虑。
第三个坑是过度优化参数。我曾经花了两周调窗口长度和置信水平,就为了让击穿率刚好5%。后来意识到这是过拟合,样本外一跑就露馅。现在我固定用500天窗口和97.5%置信,不调了,把精力放在模型结构上。
几条实用建议:一是回测要分样本内和样本外,样本内调参,样本外验证;二是记录每次回测的完整参数和结果,方便追溯;三是不要迷信单一指标,击穿率、独立性、ES偏差要一起看;四是极端行情要单独分析,不要被平均值掩盖。
ES和VaR backtesting这套东西,说到底就是让你对自己的风险模型有清醒的认识。模型不可能完美,但回测能告诉你它什么时候会失效、失效得多严重。知道边界在哪,比假装没有边界要安全得多。