第 3 章 · 04 CVaR 条件风险价值手写实现(对应 QS040) 速查摘要:本节不调任何风险库,从零手写 VaR 和 CVaR,把"尾部风险度量"的本质看清。数据是 OEX(S&P 100 原 90 只成分,2014-2016)。流程: 算收益 → 减均值去均值化 → 随机权重 → 手写 用 算 95% 分位点 → 先取 VaR,再对所有小于 VaR 的收益求 (尾部期望损失) → 直方图标注 VaR 实线、CVaR 虚线对比。手写一遍,你才真正理解"VaR 是分位点、CVaR 是超过分位点的损失均值"。
速查摘要:本节不调任何风险库,从零手写 VaR 和 CVaR,把"尾部风险度量"的本质看清。数据是 OEX(S&P 100 原 90 只成分,2014-2016)。流程:
Close.pct_change(fill_method=None)算收益 → 减均值去均值化 → 随机权重scale(x)=x/sum(|x|)→ 手写value_at_risk用np.percentile(portfolio_returns, 100*(1-alpha))算 95% 分位点 →cvar先取 VaR,再对所有小于 VaR 的收益求nanmean(尾部期望损失) → 直方图标注 VaR 实线、CVaR 虚线对比。手写一遍,你才真正理解"VaR 是分位点、CVaR 是超过分位点的损失均值"。
涉及脚本:原项目
QS040-cvar/01_cvar.py(约 104 行)
⚠️ 注意:OEX 这个列表里有些 ticker 在 2014-2016 已退市或更名(如
EMC、BRK-B写法),yfinance 可能取不到或返回 NaN。脚本靠fillna(0.0)和nanmean容错。本节的重点是理解算法本质,不是回测严谨性——如果想跑出干净结果,建议换成现役 S&P 100 成分。
阅读完本节,你应当能够:
np.percentile 手写历史 VaR,说清 95% VaR 的分位点含义。dot(weights) 算组合收益、用 iloc[-lookback_days:] 取回看窗口。VaR 回答一个问题:"在 95% 置信水平下,这个组合未来一段时间内最多亏多少?" 数学定义:
VaR_α = -inf{x : P(loss > x) ≤ 1 - α}
用历史模拟法,就是把历史收益排序,取 (1-α) 分位点。95% VaR 就是第 5 百分位(最差的 5% 边界)。脚本里 np.percentile(portfolio_returns, 100*(1-alpha)) 当 alpha=0.95 就是取第 5 百分位。
VaR 的关键缺陷:它只告诉你"阈值在哪",不告诉你"超过阈值之后有多惨"。一个组合可能在 5% 尾部亏 6%,另一个可能在尾部亏 60%,但两者 95% VaR 完全相同。这是 VaR 致命的盲区——它对尾部形状视而不见。
CVaR 弥补了 VaR 的盲区,它问:"如果跌破了 VaR,平均会亏多少?"
CVaR_α = -E[loss | loss > VaR_α]
即"超过 VaR 阈值的那部分损失的期望"。脚本实现:
var_pct_loss = var / value_invested np.nanmean(portfolio_returns[portfolio_returns < var_pct_loss])
portfolio_returns[portfolio_returns < var_pct_loss] 取出所有比 VaR 阈值更差的收益(尾部),nanmean 求平均——这就是 CVaR 的全部秘密。它天然包含了尾部形状信息:尾部越肥,CVaR 比 VaR 大得越多。
次可加性(subadditivity)指"组合的风险 ≤ 各部分风险之和",即分散化应该降低风险:
ρ(A + B) ≤ ρ(A) + ρ(B)
VaR 不满足次可加性:两个资产单独算的 VaR 加起来,可能比组合的 VaR 还小——这在金融上荒谬(分散持仓反而显得更危险)。原因在于 VaR 是分位点,分位点之间没有线性关系。
CVaR 满足次可加性(Rockafellar & Uryasev 2000 证明),作为一致性风险度量(Coherent Risk Measure),它在数学上"行为正确"。这就是为什么巴塞尔协议 III(Basel III)在市场风险监管(BRTF 2019 起的 FRTB)里把 VaR 换成预期尾部损失 ES(即 CVaR)——监管宁愿要一个数学性质好的度量。
oex = ['MMM','T','ABBV','ABT','ACN','ALL','GOOGL','GOOG','MO','AMZN','AXP','AIG', 'AMGN','AAPL','BAC','BRK-B','BIIB','BLK','BA','BMY','CVS','COF','CAT','CVX', 'CSCO','C','KO','CL','CMCSA','COP','DHR','DUK','DD','EMC','EMR','EXC','XOM', 'META','FDX','F','GD','GE','GM','GILD','GS','HAL','HD','HON','INTC','IBM','JPM', 'JNJ','KMI','LLY','LMT','LOW','MA','MCD','MDT','MRK','MET','MSFT','MS','NKE', 'NEE','OXY','ORCL','PYPL','PEP','PFE','PM','PG','QCOM','SLB','SPG','SO','SBUX', 'TGT','TXN','BK','USB','UNP','UPS','UNH','VZ','V','WMT','WBA','DIS','WFC'] data = yf.download(oex, start='2014-01-01', end='2016-04-04') returns = data.Close.pct_change(fill_method=None) returns = returns - returns.mean(skipna=True)
pct_change(fill_method=None):显式禁用前向填充。新版 pandas 默认 fill_method='ffp'(用前值填缺失),在金融数据里会掩盖真实的停牌/退市,应该关掉。returns - returns.mean(skipna=True):去均值化。组合层面的"风险"只关心波动、不关心漂移,减掉均值把所有股票放在"零漂移"基准上比较。注意这是用全程均值减,严格回测应该用滚动均值,本脚本简化处理。def scale(x): return x / np.sum(np.abs(x)) weights = scale(np.random.random(num_stocks))
scale 把权重归一化到绝对值之和为 1(支持做空时用绝对值,本脚本全正即等价于 x/sum(x))。随机生成只是演示——真实场景应该用前几节学的优化器出权重,这里为了聚焦 VaR/CVaR 算法本身。
def value_at_risk(value_invested, returns, weights, alpha=0.95, lookback_days=500): returns = returns.fillna(0.0) portfolio_returns = returns.iloc[-lookback_days:].dot(weights) return np.percentile(portfolio_returns, 100 * (1 - alpha)) * value_invested
三步:填 0 → 取回看窗口 → 取分位点。
returns.iloc[-lookback_days:].dot(weights):最后 500 天的收益矩阵(500×90)点乘权重向量(90,),得到 500 个组合日收益。这是"用历史权重组合出组合收益"的标准写法。np.percentile(portfolio_returns, 100*(1-alpha)):当 alpha=0.95,取第 5 百分位——这是 95% VaR 的"分位点定义"。* value_invested:从收益率转回金额(本脚本 value_invested=100_000,即 10 万美元)。注意 VaR 是负数(因为是左尾亏损)。* value_invested 后,VaR 表示"亏损金额",负值代表亏多少。
def cvar(value_invested, returns, weights, alpha=0.95, lookback_days=500): var = value_at_risk(value_invested, returns, weights, alpha, lookback_days=lookback_days) returns = returns.fillna(0.0) portfolio_returns = returns.iloc[-lookback_days:].dot(weights) var_pct_loss = var / value_invested return np.nanmean(portfolio_returns[portfolio_returns < var_pct_loss]) * value_invested
四步:先算 VaR → 重算组合收益(代码冗余但清晰) → 把 VaR 转回百分比阈值 → 取所有比阈值更差的收益求 nanmean。
portfolio_returns < var_pct_loss:布尔掩码,挑出"比 VaR 阈值更差"的尾部收益。nanmean:用 nanmean 而不是 mean,跳过 NaN(90 只里总有几只停牌产生 NaN,普通 mean 会报错或返回 NaN)。CVaR 的本质就这一句:"超过 VaR 的损失,平均多深"。
portfolio_returns = returns.fillna(0.0).iloc[-lookback_days:].dot(weights) portfolio_VaR = value_at_risk(value_invested, returns, weights) portfolio_VaR_return = portfolio_VaR / value_invested portfolio_CVaR = cvar(value_invested, returns, weights) portfolio_CVaR_return = portfolio_CVaR / value_invested plt.hist(portfolio_returns[portfolio_returns > portfolio_VaR_return], bins=20) plt.hist(portfolio_returns[portfolio_returns < portfolio_VaR_return], bins=10) plt.axvline(portfolio_VaR_return, color='red', linestyle='solid') plt.axvline(portfolio_CVaR_return, color='red', linestyle='dashed')
直观对比:VaR 实线和 CVaR 虚线的距离 = 尾部肥瘦程度。距离大说明尾部肥(黑天鹅风险高),距离小说明尾部薄(亏损集中在阈值附近,可控)。
fill_method=None 是金融数据卫生。pandas 2.x 起 pct_change 默认会前向填充,会把停牌/退市的缺失偷偷填上前值,导致你算出的收益"虚假平滑"。任何对真实缺失敏感的指标(VaR/CVaR 尤其)都应显式 fill_method=None,再决定怎么处理 NaN。
dot(weights) 是组合收益的标准写法。收益矩阵(T×n)点乘权重(n,)→ 组合收益(T,),远比 for 循环逐资产加权快。这也是为什么权重通常存成 numpy 数组而非列表。
iloc[-lookback_days:] 是回看窗口。500 天约 2 年。窗口太短分位点估计噪声大、太长又把"过时"的波动算进来。FRTB 标准是最近 1 年(约 250 天)的历史窗口,本脚本用 500 是经验值。
nanmean 而非 mean。90 只成分总有停牌、退市、合并产生的 NaN。用 mean 会得到 NaN、用 np.nanmean 自动跳过。同理 nanstd/nansum 都是金融代码常客。
CVaR 的监管优势是次可加性。Basel III / FRTB 用 CVaR(称 ES,Expected Shortfall)替代 VaR,正是看中它的次可加性——分散化在 CVaR 框架下永远降低风险,符合金融直觉。VaR 不满足,这意味着按 VaR 算资本充足率,可能出现"拆成两个子公司算反而资本要求更低"的荒谬结果。
去均值化的取舍。脚本用 returns - returns.mean(skipna=True),这把全程均值减掉。严格做"当日 VaR"应该用滚动均值(当日之前 N 天的均值),否则有前视偏差——用未来数据估计均值去减当日收益。本脚本为了简化用了全程均值,演示算法本质,生产环境要改滚动。
💡 速查要点:VaR 用
np.percentile(returns, 100*(1-alpha))取分位点,CVaR 用nanmean(returns[returns < var_pct])取尾部均值。一个分位点、一个尾部期望,差出尾部肥瘦。组合收益用dot(weights),回看用iloc[-lookback_days:],缺值用nanmean跳过。CVaR 满足次可加性所以 Basel III 用它。
np.percentile(returns, 5) 即可;只知阈值不知尾部多惨,是它的盲区。nanmean(returns[returns < var_pct]),比 VaR 更不乐观、对尾部形状敏感。dot(weights) 算组合收益、iloc[-lookback_days:] 回看窗口、fill_method=None 不前向填充、nanmean 容错。下一节,我们用数值积分求 Kelly 凯利公式的最优杠杆——最大化长期复利增长,看看为什么"理论最优 f"可能大于 1(需要加杠杆)。