量化交易中文教材

第 11 章 风险管理与绩效评估

研究流程的最后一环是两个问题:这个组合明天最坏会亏多少,以及过去赚的钱从哪里来、靠不靠得住。本章用前面各册的 VaR、极值理论、希腊字母和绩效评估方法,搭起一套能每天运行的风控与评估流程。主要结论有五条。第一,在波动聚集的市场中,模型必须"有条件":过滤历史模拟和条件 EVT 的突破率接近名义水平,而 500 天历史模拟和 EWMA-正态都会在危机中成串突破。第二,VaR 必须回测,ES 也能回测;突破次数、突破是否成簇、尾部损失的大小是三个不同的检验。第三,含期权的账户必须做完全重估的压力测试,delta 近似在大行情下会把亏损低估一半以上;反向压力测试能找到"最可能让你亏到底线"的情景。第四,回撤规则只有在策略收益有持续性(失效期会延续)时才改善风险调整收益,在 iid 世界里只是按比例降低收益。第五,归因要把收益拆成因子暴露和真正的 alpha,而 alpha 的置信区间往往宽得惊人;多期归因要用对数链接才能对上几何超额。大多数重大亏损事故不是模型精度不够,而是限额、流程和激励失效,所以事前风控(pre-trade check)和 kill switch 是代码,不是制度文件。

学习目标

  • 实现历史模拟、EWMA-正态、GARCH-t、过滤历史模拟(FHS)和条件 EVT 五种 1 日 VaR/ES,并理解它们在危机中的差别。
  • 用 Kupiec、Christoffersen、巴塞尔交通灯和 Acerbi–Szekely \(Z_2\) 回测 VaR 与 ES。
  • 对含期权的账户做完全重估、delta 和 delta-gamma-vega 三种压力测试,做反向压力测试和相关性压力。
  • 用模拟理解最大回撤的基准分布,比较波动率目标、回撤线性降仓和止损冷却规则。
  • 做收益型因子归因、Brinson–Fachler 归因和 Carino 多期链接,并把实盘收益与同期回测对照。
  • 写一个事前风控引擎:单笔、价格带、持仓、敞口、频率、重复订单与日内亏损熔断。

读前导读

这一章在解决什么问题。 这是全书离你日常工作最近的一章。它回答两个问题:明天最坏会亏多少(风险管理),过去赚的钱从哪里来、靠不靠得住(绩效评估)。各节可以直接对应到你熟悉的部门和工作:

  • 11.2–11.3 节的 VaR、ES 及其回测,就是银行风控部门市场风险组每天出的报告,也是巴塞尔资本计量(FRTB 用 97.5% ES)和交通灯规则的出处。CFA 里你学过 VaR 的三种算法(参数法、历史模拟、蒙特卡洛),本章往前多走一步:波动是时变的,所以要用"条件"模型;模型要回测,否则无从知道它准不准。
  • 11.4 节压力测试对应风控部门的情景分析与监管压力测试,以及风险委员会讨论"最坏情形"的材料。其中的 delta、gamma 近似和你熟悉的"久期 + 凸性"估算债券价格变动是同一套数学。
  • 11.5 节回撤控制对应投资政策书(IPS)中的风险预算和止损条款,也是 FOF 或资金方给量化管理人设"清盘线、预警线"时要先想清楚的问题。
  • 11.6 节绩效评估对应 CFA 三级的业绩评估、业绩归因与 GIPS:收益型归因是基于收益的风格分析,Brinson 是基于持仓的归因,Carino 链接解决的是 GIPS 报告里多期几何收益与各期归因"对不上"的问题。
  • 11.7 节风控体系对应内部控制与运营风险:前中后台分离就是 CPA 审计里的"不相容职务分离",事前风控引擎相当于把内控制度写成每笔交易都要过的程序。

讲解框会重点解释每段代码在做什么、为什么这样设计,因为本章的代码本身就是一个可以每天运行的风控流程的雏形。

需要先想起来的数学。

  • 分位数与 \(\inf\)。 \(\inf A\)(下确界)可以先理解为"集合 \(A\) 的最小值";严格说是"不超过 \(A\) 中所有元素的最大的数",用它是为了在分布有跳跃时定义也成立。VaR 的定义就是"使损失不超过 \(\ell\) 的概率至少为 \(p\) 的最小 \(\ell\)",即损失分布的 \(p\) 分位数。见第 00 册第 01 章 函数极限与连续。
  • 条件期望与 \(\mathcal F_{t-1}\)。 \(\mathcal F_{t-1}\) 读作"截至 \(t-1\) 日收盘的全部信息",\(E[\cdot\mid\mathcal F_{t-1}]\) 就是"站在昨天收盘时看的期望"。条件 VaR 随每天的新信息更新,无条件 VaR 不更新。见第 00 册第 07 章 概率中的分析工具。
  • 泰勒展开。 \(f(x+\Delta x)\approx f(x)+f'(x)\Delta x+\tfrac12f''(x)\Delta x^2\)。债券的"久期 + 凸性"、期权的"delta + gamma"都是它的特例。大行情下高阶项不可忽略,近似就会失效。见第 00 册第 02 章 导数与泰勒展开。
  • 矩阵求逆、二次型与拉格朗日乘子。 \(x^\top\Sigma^{-1}x\) 是"马氏距离的平方",一个标量:把各因子变动按波动和相关性标准化后的总长度。反向压力测试是"在亏损约束下最小化它",用拉格朗日乘子求解。见第 00 册第 06 章 线性代数速成和第 05 章 多元微积分与优化。
  • 对数与几何链接。 \(\ln(1+R)\) 把乘法变加法:多期累计收益 \(\prod(1+R_t)-1\) 取对数后变成各期对数收益之和。Carino 链接利用的正是这一点。例:两个月各 +10%,算术相加 20%,几何累计 21%。见第 00 册第 04 章 级数与收敛。

怎么读这一章。 核心必读是 11.2.1(五种方法的区别)、11.3(回测的三种失效方式)、11.4 的三点观察和 11.6。11.2.2 的代码较长,第一次读可以只看注释和输出表格,抓住"HS 在错误的时间高、EWMA-正态尾部太薄"两个结论。11.5 的结论一句话就够:回撤规则只在收益有持续性时才有用。11.7 偏制度,CPA 背景读起来会很快,重点看事前风控代码的检查顺序。建议顺序:11.1 → 11.2.1 → 11.3.1 → 11.4 → 11.6 → 11.5 → 11.7,最后回头读各段代码。

11.1 问题与动机

风险管理在量化流程中有三个层次。第一层是度量:VaR、ES、因子暴露、希腊字母、压力情景,回答"可能亏多少"。第二层是控制:限额、仓位规则、对冲和熔断,回答"亏到一定程度怎么办"。第三层是评估:绩效统计、归因和事后复盘,回答"赚的钱是不是我们以为的那种钱"。

前面各册提供了度量所需的全部理论:VaR 与 ES 的定义、历史模拟与模型构建法见第 08 册第 22 章;RiskMetrics、ARMA-GARCH 与分位数估计以及 VaR 回测见第 06 册第 07a 章;极值理论与 POT 见第 06 册第 07b 章;波动率与相关估计见第 08 册第 23 章、第 06 册第 10 章;希腊字母见第 08 册第 19a、19b 章。绩效评估的统计困难见第 07 册第 22a、22b 章,回测指标与 Deflated Sharpe 见本册第 06 章。本章不重复这些推导,而是关注怎样把它们组装成每天运行、能被检验、出错时能及时发现的系统。

本章的合成数据包括:带危机段的 GJR-GARCH 厚尾组合收益;一个股票因子组合加卖出看跌的期权账户;iid 与两状态马尔可夫两种策略收益;带因子暴露的月度收益与行业组合;以及一个交易日内的订单流。与真实数据的主要差别在每节末尾说明。

11.2 VaR 与 ES:五种方法

11.2.1 方法回顾

记 \(L_t=-r_t\) 为 \(t\) 日损失,\(\mathcal F_{t-1}\) 为前一日收盘的信息。置信水平 \(p\) 的 1 日 VaR 和 ES 为

\[\text{VaR}_p=\inf\{\ell:\ P(L_t\le\ell\mid\mathcal F_{t-1})\ge p\},\qquad \text{ES}_p=E[L_t\mid L_t\ge\text{VaR}_p,\mathcal F_{t-1}].\]

巴塞尔的市场风险新框架(FRTB)用 97.5% 的 ES 替代 99% 的 VaR 作为资本计量标准,两者在正态分布下数值接近,但 ES 对尾部形状敏感,并且满足次可加性(第 08 册第 22 章 22.1 节)。

白话解释:用一个小例子读这两个式子。假设站在昨天收盘时看,今天的损失有 100 种等可能的情形,从小到大排好。\(\text{VaR}_{99\%}\) 是"至少 99 种情形的损失都不超过它"的最小值,也就是第 99 小的那个损失;\(\text{ES}_{99\%}\) 是"损失达到或超过 VaR 的那些情形"的平均。若最差的两个损失是 3% 和 5%,则 VaR 是 3%,ES 是 \((3\%+5\%)/2=4\%\)。VaR 只告诉你"门槛在哪里",不管门后有多深;ES 告诉你"过了门槛平均亏多少"。 两个式子里都有 \(\mid\mathcal F_{t-1}\),它是本章一切"条件模型"的出发点:分布要用昨天收盘时已知的信息(尤其是当前波动水平)来刻画,而不是用一个固定的长期分布。次可加性指 \(\text{ES}(A+B)\le\text{ES}(A)+\text{ES}(B)\),即合并两个组合的风险不会超过分别计算之和,符合"分散化不增加风险"的直觉;VaR 在某些分布下会违反这一点。

五种方法的区别在于对"条件分布"做了多少假设:

  1. 历史模拟(HS):直接用过去 \(W\) 天损失的经验分位。不假设分布,但也不区分平静期和危机期,波动上升后要很久才反映到 VaR 中。
  2. EWMA-正态(RiskMetrics):\(\sigma_t^2=\lambda\sigma_{t-1}^2+(1-\lambda)r_{t-1}^2\),\(\lambda=0.94\),VaR \(=z_p\sigma_t\)。对波动变化反应快,但正态尾部太薄(第 06 册第 07a 章 7.2 节)。
  3. GARCH-t:GJR-GARCH(1,1) 加标准化 t 新息,参数用极大似然估计。条件分布全部参数化。
  4. 过滤历史模拟(FHS):先用 GARCH 得到 \(\sigma_t\) 和标准化残差 \(\hat z_t=(r_t-\hat\mu)/\hat\sigma_t\),再用 \(\hat z\) 的经验分位乘以当天的 \(\sigma_t\)。波动用模型,形状用数据。
  5. 条件 EVT(McNeil–Frey 2000):同样先过滤,再对标准化损失的上尾用 POT 拟合广义帕累托分布(第 06 册第 07b 章式 7.37、7.38):
    \[\text{VaR}_q=u+\frac{\beta}{\xi}\Big[\Big(\frac{n}{N_u}(1-q)\Big)^{-\xi}-1\Big],\qquad \text{ES}_q=\frac{\text{VaR}_q}{1-\xi}+\frac{\beta-\xi u}{1-\xi}.\]
    对极端分位数,它比经验分位更稳定,因为经验分位在 99.9% 这样的水平只靠几个点决定。

推导拆解:EVT 的 VaR 公式怎么来的。POT 的结论是:损失超过一个较高阈值 \(u\) 之后,超出量 \(y=L-u\) 近似服从广义帕累托分布,尾部概率 \(P(Y>y)=(1+\xi y/\beta)^{-1/\xi}\)(\(\xi\) 是尾指数,越大尾越厚;\(\beta\) 是尺度)。 第一步,把超阈值概率拆成两段相乘:\(P(L>u+y)=P(L>u)\cdot P(Y>y)\),其中 \(P(L>u)\) 用样本频率 \(N_u/n\) 估计(\(N_u\) 是超过阈值的点数,\(n\) 是样本数)。 第二步,令 \(P(L>\text{VaR}_q)=1-q\),即 \(\frac{N_u}{n}(1+\xi y/\beta)^{-1/\xi}=1-q\)。 第三步,两边乘 \(n/N_u\) 再取 \(-\xi\) 次方:\(1+\xi y/\beta=\big(\frac{n}{N_u}(1-q)\big)^{-\xi}\),解出 \(y\) 再加上 \(u\),就是文中的 \(\text{VaR}_q\)。 这个公式的好处是把"只有几个点的极端尾部"外推交给了一个由几百个超阈值点估出的分布形状,相当于用整个尾部的信息估计 99.9% 分位,而不是只看最差的三四天。

工程上有两个要点。第一,参数估计和滤波要分开:每隔一段时间(本例 250 天)用截至当时的数据重估参数,其余日期固定参数只做滤波。\(\sigma_t\) 只依赖 \(t-1\) 及以前的收益,所以用固定参数在更长样本上滤波不会引入前视。第二,每种方法同时输出 99% VaR、97.5% ES 和 97.5% VaR,后者是 ES 回测需要的。

11.2.2 代码:五种方法的滚动预测

模拟 14 年组合日收益(%):GJR-GARCH(1,1),新息为 t4 与 1% 概率的 −3 倍冲击的混合;第 9 年初嵌入 40 天的"危机",期间常数项放大 2.5 倍。

# 代码 11.1  五种 1 日 VaR/ES 的滚动预测(代码 11.2 接在本段之后运行)
import numpy as np
import pandas as pd
from scipy import stats
from arch import arch_model

# ---------- 合成组合日收益(%):GJR-GARCH(1,1) + 厚尾新息,中间嵌入一段"危机" ----------
rng = np.random.default_rng(42)
n = 252 * 14
omega, a, g, b = 0.02, 0.03, 0.10, 0.90
def skew_t(size):                    # 厚尾新息:t4 与"偶发负冲击"的混合,近似标准化到单位方差
    x = rng.standard_t(4, size) / np.sqrt(2) + np.where(rng.random(size) < 0.01, -3.0, 0.0)
    return (x + 0.03) / np.sqrt(1 + 0.09 * 0.99)
z = skew_t(n); h = np.empty(n); r = np.empty(n); h[0] = omega / (1 - a - g / 2 - b)
for t in range(n):
    if t:
        shock = 2.5 if 252 * 9 <= t < 252 * 9 + 40 else 1.0       # 第 9 年初:40 天的危机
        h[t] = shock * omega + (a + g * (r[t-1] < 0)) * r[t-1]**2 + b * h[t-1]
    r[t] = 0.04 + np.sqrt(h[t]) * z[t]
L = -r                                                             # 损失 = 负收益
print(f"日收益:均值 {r.mean():.3f}%  波动 {r.std():.2f}%  偏度 {stats.skew(r):.2f}  超额峰度 {stats.kurtosis(r):.1f}")

# ---------- 五种 1 日 VaR(99%) 与 ES(97.5%) 的滚动预测 ----------
p_var, p_es = 0.99, 0.975
start, win = 1000, 500
idx = np.arange(start, n)
out = {k: np.full((n, 3), np.nan) for k in ["HS", "EWMA-正态", "GARCH-t", "FHS", "GARCH-EVT"]}
# 每种方法输出三列:VaR(99%)、ES(97.5%)、VaR(97.5%)(后者供 ES 回测使用)

# (1) 历史模拟:过去 500 天
for t in idx:
    w = L[t-win:t]
    v, v2 = np.quantile(w, p_var), np.quantile(w, p_es); e = w[w >= v2].mean()
    out["HS"][t] = v, e, v2
# (2) RiskMetrics:EWMA λ=0.94,零均值正态
s2 = pd.Series(r**2).ewm(alpha=0.06, adjust=False).mean().shift(1).values
zq, ze = stats.norm.ppf(p_var), stats.norm.pdf(stats.norm.ppf(p_es)) / (1 - p_es)
out["EWMA-正态"][idx] = np.c_[zq, ze, stats.norm.ppf(p_es)] * np.sqrt(s2[idx])[:, None]
# (3)(4)(5) GJR-GARCH(1,1)-t:每 250 天用过去全部数据重估一次参数,其余日期只做滤波
def gpd_var_es(x, u_q=0.90):
    """对标准化损失 x 的上尾做 POT:阈值取 90% 分位,GPD 拟合超出量,返回 (VaR, ES) 分位"""
    u = np.quantile(x, u_q); exc = x[x > u] - u
    xi, _, beta = stats.genpareto.fit(exc, floc=0)
    tail = lambda p: u + beta / xi * ((len(x) / len(exc) * (1 - p))**(-xi) - 1)
    q_es = tail(p_es)
    return tail(p_var), q_es / (1 - xi) + (beta - xi * u) / (1 - xi), q_es, xi
xi_path = []
for s in range(start, n, 250):
    am = arch_model(r[:s], mean="Constant", vol="GARCH", p=1, o=1, q=1, dist="t")
    res = am.fit(disp="off")
    seg = np.arange(s, min(s + 250, n))
    # 用固定参数对 [0, seg 末] 滤波:sigma_t 只依赖 t-1 及以前的收益,所以没有前视
    full = arch_model(r[:seg[-1]+1], mean="Constant", vol="GARCH", p=1, o=1, q=1, dist="t")
    fr = full.fix(res.params)
    sig = fr.conditional_volatility                                 # sigma_t(基于 t-1 的信息)
    mu, nu = res.params["mu"], res.params["nu"]
    std_resid_hist = ((r[:s] - mu) / sig[:s])                       # 估计样本的标准化残差
    # (3) 参数化 t
    tq = stats.t.ppf(p_var, nu) * np.sqrt((nu - 2) / nu)
    te_ = stats.t.ppf(p_es, nu)
    tes = stats.t.pdf(te_, nu) / (1 - p_es) * (nu + te_**2) / (nu - 1) * np.sqrt((nu - 2) / nu)
    out["GARCH-t"][seg] = -mu + sig[seg][:, None] * np.array([tq, tes, te_ * np.sqrt((nu - 2) / nu)])
    # (4) 过滤历史模拟:标准化损失的经验分位 × 当日 sigma
    zl = -std_resid_hist
    qv, qv2 = np.quantile(zl, p_var), np.quantile(zl, p_es); qe = zl[zl >= qv2].mean()
    out["FHS"][seg] = -mu + sig[seg][:, None] * np.array([qv, qe, qv2])
    # (5) 条件 EVT(McNeil–Frey 2000):对标准化损失上尾拟合 GPD
    gv, ge, gv2, xi = gpd_var_es(zl); xi_path.append(xi)
    out["GARCH-EVT"][seg] = -mu + sig[seg][:, None] * np.array([gv, ge, gv2])
print("GPD 尾指数 ξ 的估计(各次重估):", np.round(xi_path, 2))
for k, v in out.items():
    hit = L[idx] > v[idx, 0]
    print(f"{k:10s} 平均 VaR {v[idx,0].mean():.2f}%  平均 ES {v[idx,1].mean():.2f}%  "
          f"突破率 {100*hit.mean():.2f}%(应为 1%)  危机 60 天内突破 {hit[252*9-start:252*9-start+60].sum()} 次")

输出:

日收益:均值 0.046%  波动 0.88%  偏度 0.25  超额峰度 9.9
GPD 尾指数 ξ 的估计(各次重估): [0.18 0.19 0.15 0.09 0.12 0.09 0.06 0.08 0.11 0.08 0.05]
HS         平均 VaR 2.27%  平均 ES 2.32%  突破率 1.38%(应为 1%)  危机 60 天内突破 3 次
EWMA-正态    平均 VaR 1.81%  平均 ES 1.82%  突破率 2.29%(应为 1%)  危机 60 天内突破 3 次
GARCH-t    平均 VaR 2.19%  平均 ES 2.38%  突破率 1.11%(应为 1%)  危机 60 天内突破 1 次
FHS        平均 VaR 2.39%  平均 ES 2.41%  突破率 0.91%(应为 1%)  危机 60 天内突破 1 次
GARCH-EVT  平均 VaR 2.27%  平均 ES 2.38%  突破率 1.03%(应为 1%)  危机 60 天内突破 1 次

各方法的平均 VaR 相差不大,差别在于"什么时候高、什么时候低"。EWMA-正态平均 VaR 最低,突破率 2.3%,是名义水平的两倍多,原因是正态尾部太薄。HS 的平均 VaR 不低,突破率却也偏高(1.38%),原因是它在错误的时间高:危机开始时 500 天窗口里几乎全是平静期,VaR 偏低,于是在危机前 60 天内突破 3 次;危机过后它又在很长时间里保持偏高,浪费资本。三种基于 GARCH 的方法在危机中只突破 1 次,突破率都接近 1%。

白话解释:代码 11.1 的结构值得看懂,它就是风控系统每天跑的那个批处理的缩影。 外层循环每 250 天做一次"重估":arch_model(r[:s]).fit() 只用截至第 \(s\) 天的数据估参数,相当于每年年初做一次模型校准。之后用 full.fix(res.params) 把这组参数固定下来,在更长的数据上只做滤波,算出这一年每天的 \(\sigma_t\)。之所以可以这样做而不偷看未来,是因为 GARCH 的 \(\sigma_t\) 由 \(t-1\) 及以前的收益递推得到,参数固定后,第 \(t\) 天的 \(\sigma_t\) 不会因为数据延长而改变。 三种 GARCH 类方法共用同一个 \(\sigma_t\),只在"标准化损失的分位从哪里来"上不同:GARCH-t 用 t 分布的理论分位;FHS 用历史标准化残差的经验分位;EVT 用 GPD 拟合尾部。这就是 11.2.1 说的"波动用模型,形状用数据"。 代码里反复出现的 \(\sqrt{(\nu-2)/\nu}\):自由度 \(\nu\) 的 t 分布方差是 \(\nu/(\nu-2)\),乘以这个因子把它缩到单位方差,才能和 \(\sigma_t\) 相乘。例如 \(\nu=4\) 时方差为 2,因子为 \(1/\sqrt2\)。 HS 的问题可以这样记:它是一个 500 天的"平均",就像用过去两年平均坏账率给今天的贷款拨备,经济刚转坏时拨备不足,经济恢复后又长期拨备过度。

GPD 尾指数 \(\xi\) 在 0.05 到 0.19 之间,随样本增长而下降并趋于稳定。早期估计偏高是因为样本少、只有约 100 个超阈值点。实务中 \(\xi\) 的估计误差很大,常对阈值做敏感性分析(第 06 册第 07b 章 7.8.3 节的平均超出图),并对 \(\xi\) 设合理上下限。

与真实数据的差别:真实组合的风险来自成百上千个头寸,要先把头寸映射到风险因子(第 08 册第 22 章 22.4 节),再对因子收益建模;单变量 GARCH 只适用于"组合收益序列"这一层次的风险汇总,无法回答"风险来自哪里"。真实数据还有节假日、停牌、价格陈旧导致的低估波动等问题。

11.3 回测 VaR 与 ES

11.3.1 四个检验

定义突破 \(I_t=\mathbb 1(L_t>\text{VaR}_t)\)。

  • Kupiec 无条件覆盖:突破比例是否等于 \(1-p\)。似然比 \(LR_{uc}\sim\chi^2_1\)(第 06 册第 07a 章 7.5 节)。
  • Christoffersen 独立性:今天突破是否让明天更容易突破。突破成簇说明模型对波动变化反应太慢。
  • 巴塞尔交通灯:最近 250 天 99% VaR 的突破次数,0–4 次为绿灯,5–9 次为黄灯,10 次及以上为红灯。黄灯和红灯会提高资本乘数。它不是严格的统计检验,但它是监管实际使用的规则,在代码里应当逐日计算。
  • ES 回测:ES 是尾部均值,不能像 VaR 那样数突破次数。Acerbi 与 Szekely(2014)提出的 \(Z_2\) 统计量
    \[Z_2=1-\frac{1}{T\alpha}\sum_{t=1}^{T}\frac{L_tI_t}{\text{ES}_t},\qquad \alpha=1-p,\]
    其中 \(I_t\) 是突破 \(\text{VaR}_{97.5\%}\) 的指示。模型正确时 \(E[Z_2]=0\);突破太多或者突破时亏得比 ES 预测的多,\(Z_2\) 都会变成负数。临界值依赖样本长度和分布,这里用模拟得到。

推导拆解:Kupiec 检验是你在 CFA 里见过的似然比检验的直接应用。突破是一串 0/1,在"突破率等于 \(1-p\)"的原假设下,\(n\) 天里出现 \(x\) 次突破的对数似然是 \(\ell_0=(n-x)\ln p+x\ln(1-p)\);不加限制时突破率的最优估计是样本频率 \(\hat\pi=x/n\),对数似然为 \(\ell_1=(n-x)\ln(1-\hat\pi)+x\ln\hat\pi\)。\(LR=-2(\ell_0-\ell_1)\) 越大,说明原假设越不像真的;它近似服从自由度 1 的卡方分布(只约束了一个参数)。代码 11.2 的 kupiec 函数里,参数 p 传入的是突破率 \(1-p_{\text{var}}=0.01\),所以写法和这里的符号相反,但内容一样。例:1000 天、期望 10 次,实际 20 次,\(LR\approx7.8\),p 值约 0.005,拒绝。 Christoffersen 独立性检验把突破序列看成两状态的转移:\(p_{01}\) 是"昨天没突破、今天突破"的概率,\(p_{11}\) 是"昨天突破、今天又突破"的概率。模型正确时两者应相等;\(p_{11}\) 明显更大,就说明突破成簇,模型对波动上升反应太慢。 \(Z_2\) 的直觉:如果 ES 准确,每次突破的损失除以当天 ES,平均约为 1,而突破次数的期望是 \(T\alpha\),所以 \(\sum L_tI_t/\text{ES}_t\) 的期望约为 \(T\alpha\),\(Z_2\) 期望为 0。突破次数偏多或突破时亏得偏狠,都会让这个和变大、\(Z_2\) 变负。

11.3.2 代码

# 代码 11.2  VaR 与 ES 回测:Kupiec、Christoffersen、交通灯与 Acerbi–Szekely Z2
# —— 接上段(同一会话):使用 L, out, idx, p_var, p_es ——
def kupiec(hit, p):
    n, x = len(hit), hit.sum(); pi = x / n
    ll0 = (n - x) * np.log(1 - p) + x * np.log(p)
    ll1 = (n - x) * np.log(1 - pi) + x * np.log(pi) if 0 < x < n else 0.0
    lr = -2 * (ll0 - ll1); return lr, 1 - stats.chi2.cdf(lr, 1)

def christoffersen(hit):
    h0, h1 = hit[:-1], hit[1:]
    n00, n01 = np.sum(~h0 & ~h1), np.sum(~h0 & h1); n10, n11 = np.sum(h0 & ~h1), np.sum(h0 & h1)
    p01, p11 = n01 / (n00 + n01), n11 / max(n10 + n11, 1); p = (n01 + n11) / len(h1)
    ll = lambda a, b, q: a * np.log(1 - q) + b * np.log(q) if 0 < q < 1 else 0.0
    lr = -2 * (ll(n00 + n10, n01 + n11, p) - ll(n00, n01, p01) - ll(n10, n11, p11))
    return lr, 1 - stats.chi2.cdf(lr, 1)

def traffic_light(hit, window=250):
    """巴塞尔交通灯:最近 250 天 99% VaR 突破次数 ≤4 绿,5–9 黄,≥10 红"""
    cnt = np.array([hit[i-window:i].sum() for i in range(window, len(hit) + 1)])
    return (cnt <= 4).mean(), ((cnt >= 5) & (cnt <= 9)).mean(), (cnt >= 10).mean()

def acerbi_z2(Ls, var_es, alpha):
    """Acerbi–Szekely (2014) Z2:Z2 = 1 - sum(L_t·I_t / ES_t) / (T·alpha),I_t 为突破 VaR_alpha 的指示。
    ES 估计正确时 E[Z2]=0;Z2 显著为负说明 ES 低估了尾部损失。"""
    v, e = var_es[:, 0], var_es[:, 1]
    I = Ls > v
    return 1 - np.sum(Ls * I / e) / (len(Ls) * alpha)

# Z2 的临界值:在"模型完全正确"的原假设下模拟(t4 分布、T=250),取 5% 分位
sim = []
nu0 = 4; sc = np.sqrt((nu0 - 2) / nu0)
q975 = stats.t.ppf(p_es, nu0)
es0 = stats.t.pdf(q975, nu0) / (1 - p_es) * (nu0 + q975**2) / (nu0 - 1)
for _ in range(5000):
    x = rng.standard_t(nu0, 250)
    sim.append(1 - np.sum(x * (x > q975) / es0) / (250 * (1 - p_es)))
z2_crit = np.quantile(sim, 0.05)
print(f"Z2 原假设下 5% 临界值(模拟){z2_crit:.2f}")

print("方法        Kupiec p   独立性 p   绿/黄/红灯占比          Z2(全样本)  Z2<临界的年份")
for k, v in out.items():
    hit = L[idx] > v[idx, 0]
    _, pk = kupiec(hit, 1 - p_var); _, pc = christoffersen(hit)
    g_, y_, r_ = traffic_light(hit)
    z2_all = acerbi_z2(L[idx], v[idx][:, [2, 1]], 1 - p_es)
    yearly = [acerbi_z2(L[idx][i:i+250], v[idx][i:i+250][:, [2, 1]], 1 - p_es)
              for i in range(0, len(idx) - 249, 250)]
    print(f"{k:10s}  {pk:8.3f}   {pc:8.3f}   {g_:.2f}/{y_:.2f}/{r_:.2f}        {z2_all:+.2f}      "
          f"{sum(z < z2_crit for z in yearly)}/{len(yearly)}")

输出:

Z2 原假设下 5% 临界值(模拟)-0.75
方法        Kupiec p   独立性 p   绿/黄/红灯占比          Z2(全样本)  Z2<临界的年份
HS             0.066      0.096   0.73/0.17/0.09        -0.18      2/10
EWMA-正态        0.000      0.580   0.19/0.80/0.00        -0.59      4/10
GARCH-t        0.593      0.428   0.86/0.14/0.00        -0.00      0/10
FHS            0.644      0.516   0.86/0.14/0.00        +0.14      0/10
GARCH-EVT      0.886      0.462   0.86/0.14/0.00        +0.12      0/10

模拟得到的 \(Z_2\) 在 250 天样本下的 5% 临界值约为 −0.75,与 Acerbi–Szekely 报告的约 −0.7 接近。

EWMA-正态被所有检验拒绝:Kupiec p 值为 0,80% 的时间处于黄灯,\(Z_2\) 在 10 年中有 4 年低于临界值。它的独立性检验却通过了,说明它对波动变化反应及时,问题只在尾部形状。HS 的情况相反:Kupiec 勉强通过(p=0.066),独立性检验接近拒绝(p=0.096),9% 的时间亮红灯,问题出在反应速度。三种 GARCH 类方法全部通过,绿灯占 86%。

这说明三种检验看的是不同的失效方式,必须一起用。另外要注意检验功效:10 年 99% VaR 只有约 25 次期望突破,一个突破率真实为 1.5% 的模型也很可能通过 Kupiec 检验。这就是监管同时使用交通灯和资本乘数,而不是单纯依赖 p 值的原因。

11.4 压力测试与情景分析

11.4.1 为什么 VaR 不够

VaR 和 ES 基于近期分布估计,回答的是"正常的坏日子"。压力测试回答的是"假如发生了某件事",它不需要概率,但需要情景。实务中有三类:

  • 历史情景:重放 1987 年 10 月、2008 年秋、2020 年 3 月等时期的因子变动。优点是可信,缺点是历史不会简单重复,并且新产品没有历史。
  • 假设情景:由风险委员会设定,例如"指数下跌 20%、隐含波动上升 30 个点、价值因子下跌 4%"。可以针对当前组合的薄弱环节设计。
  • 反向压力测试:先设定不可接受的损失,再找最可能导致它的情景。设风险因子变动为 \(x\),协方差为 \(\Sigma\),求
    \[\min_x\ x^\top\Sigma^{-1}x\quad\text{s.t.}\quad \text{P\&L}(x)\le -L^*.\]
    目标函数是马氏距离的平方,代表情景的"不可能程度"。对线性账户,解析解沿着 \(\Sigma g\) 的方向(\(g\) 为敞口向量);含期权时需要数值优化,并且必须用完全重估的 P&L。

推导拆解:线性账户的解析解。线性账户的盈亏是 \(g^\top x\),问题变成 \(\min_x x^\top\Sigma^{-1}x\),约束 \(g^\top x=-L^*\)(最可能的情景恰好落在约束边界上)。 第一步,写拉格朗日函数 \(\mathcal L=x^\top\Sigma^{-1}x+\lambda(g^\top x+L^*)\),对 \(x\) 求梯度并令其为零:\(2\Sigma^{-1}x+\lambda g=0\),所以 \(x=-\tfrac{\lambda}{2}\Sigma g\)。最优情景与 \(\Sigma g\) 同方向,这就是文中说的"沿着 \(\Sigma g\)"。 第二步,代回约束定出 \(\lambda\):\(x^*=-\frac{L^*}{g^\top\Sigma g}\Sigma g\)。 第三步,算出它的马氏距离:\(d=\sqrt{x^{*\top}\Sigma^{-1}x^*}=L^*/\sqrt{g^\top\Sigma g}\)。反过来,给定距离 \(d\),线性账户的最大亏损是 \(d\sqrt{g^\top\Sigma g}\),代码 11.3 的"对照"一行就是用这个式子算的。 金融上 \(\sqrt{g^\top\Sigma g}\) 就是组合的波动(CFA 的组合方差公式写成矩阵形式),所以对线性账户,反向压力测试只是"组合波动乘以 \(d\) 倍";\(d=2.33\) 时正好是正态假设下的 99% VaR。期权账户的 P&L 不是线性的,才需要数值优化。

对含期权的账户,第 08 册第 22 章 22.5 节给出了 delta-gamma 近似:\(\Delta P\approx\delta\Delta S+\tfrac12\gamma\Delta S^2+\nu\Delta\sigma\)。小行情下它很准,大行情下误差很大,因为卖出虚值看跌的 gamma 会随下跌急剧变化,而且下跌时偏斜变陡,虚值看跌的隐含波动率上升得比平值更多。压力测试的价值恰恰在大行情,所以应当用完全重估。

金融直觉:delta-gamma 近似和债券的"修正久期 + 凸性"是同一个二阶泰勒展开:\(\Delta P/P\approx-D\,\Delta y+\tfrac12C\,\Delta y^2\)。你知道收益率变动 300bp 时久期凸性估算会明显偏离实际价格,原因是三阶及更高阶项被截掉了。卖出虚值看跌的情况更糟:下跌过程中期权从虚值走向平值,gamma 本身急剧变大(三阶导数很大),二阶近似必然低估;再叠加偏斜变陡带来的 vega 损失,误差就会翻倍。

白话解释:代码 11.3 的几个设计。book_value 是"完全重估":给定新的指数点位和波动变化,逐个期权用 Black–Scholes 重新定价再求和,下跌时对执行价低于现价的看跌期权额外加 50% 的波动冲击,模拟偏斜变陡。delta、gamma、vega 用有限差分算:把指数上下各挪万分之一,\((V_+-V_-)/2h\) 是一阶导数,\((V_+-2V_0+V_-)/h^2\) 是二阶导数。这样算出的希腊字母和定价函数完全一致,不需要另写解析公式,是风险系统里的常见做法。反向压力测试用 scipy 的 SLSQP 求解带约束的最小化,约束 target - pnl_full(x) >= 0 即"完全重估亏损至少 1500 万",并给每个因子设了合理的取值范围,防止优化器跑到不现实的角落。协方差乘 10 是把日度协方差按平方根法则放大到 10 天(方差按时间线性增长)。

11.4.2 代码:一个股票加期权的账户

账户由两部分组成:1 亿元资本的股票多空组合,对市场、价值、动量三个因子的元暴露分别为 0.15、0.4、0.6 亿;以及一个指数期权账户,卖出两档虚值看跌、买入少量虚值看涨。风险因子是指数收益、价值因子收益、动量因子收益和平值隐含波动的变化,下跌时虚值看跌的隐含波动额外上升 50%。

# 代码 11.3  股票加期权账户的压力测试、反向压力测试与相关性压力
import numpy as np
from scipy.stats import norm
from scipy.optimize import minimize

def bs(S, K, T, r, sig, cp):
    s = sig * np.sqrt(T); d1 = (np.log(S / K) + (r + 0.5 * sig**2) * T) / s
    return cp * (S * norm.cdf(cp * d1) - K * np.exp(-r * T) * norm.cdf(cp * (d1 - s)))

# ---------- 账户:股票多空组合(因子暴露)+ 指数期权账户 ----------
# 风险因子:x = [指数收益, 价值因子收益, 动量因子收益, 平值隐含波动率变化(绝对值)]
S0, r = 4000.0, 0.03
equity_expo = np.array([0.15, 0.40, 0.60]) * 1e8      # 1 亿资本的股票账户:市场 0.15、价值 0.4、动量 0.6 的元暴露
options = [  # (数量, 执行价, 期限, 类型, 当前 IV) —— 卖出虚值看跌、买入少量虚值看涨
    (-600, 3600, 0.25, -1, 0.24), (-400, 3400, 0.25, -1, 0.28), (300, 4400, 0.25, 1, 0.17)]
mult = 50                                              # 每点 50 元
def book_value(spot, dvol, dt=0.0):
    v = 0.0
    for q, K, T, cp, iv in options:
        skew_bump = dvol * (1.0 + (K < S0) * 0.5)      # 下跌时虚值看跌的 IV 涨得更多(偏斜变陡)
        v += q * mult * bs(spot, K, T - dt, r, max(iv + skew_bump, 0.01), cp)
    return v
V0 = book_value(S0, 0.0)
def pnl_full(x):
    eq = equity_expo @ x[:3]
    return eq + book_value(S0 * (1 + x[0]), x[3]) - V0
# Delta / Gamma / Vega(有限差分),用于近似
h = 1e-4
delta = (book_value(S0 * (1 + h), 0) - book_value(S0 * (1 - h), 0)) / (2 * h)            # 每单位指数收益
gamma = (book_value(S0 * (1 + h), 0) - 2 * V0 + book_value(S0 * (1 - h), 0)) / h**2
vega = (book_value(S0, 1e-4) - book_value(S0, -1e-4)) / 2e-4
def pnl_delta(x):  return equity_expo @ x[:3] + delta * x[0]
def pnl_dg(x):     return pnl_delta(x) + 0.5 * gamma * x[0]**2 + vega * x[3]
print(f"期权账户:现值 {V0/1e6:.2f} 百万;delta {delta/1e6:.1f} 百万/100%;"
      f"gamma {gamma/1e6:.0f} 百万/(100%)²;vega {vega/1e6:.2f} 百万/100 波动率点")

# ---------- 情景:历史型与假设型 ----------
scen = {
    "温和回调 -5%,IV +5点":            [-0.05, 0.00, 0.00, 0.05],
    "1987 型单日 -20%,IV +30点":       [-0.20, 0.00, 0.00, 0.30],
    "2008 型 -12%,价值 -4% 动量 +3%":   [-0.12, -0.04, 0.03, 0.15],
    "动量崩溃:指数 +8%,动量 -12%":    [0.08, 0.02, -0.12, -0.04],
    "2007 年 8 月量化地震:价值 -3% 动量 -6%": [-0.01, -0.03, -0.06, 0.02],
}
print(f"{'情景':34s} {'完全重估':>10s} {'delta近似':>10s} {'delta-gamma-vega':>17s}  (百万元)")
for k, x in scen.items():
    x = np.array(x)
    print(f"{k:34s} {pnl_full(x)/1e6:10.2f} {pnl_delta(x)/1e6:10.2f} {pnl_dg(x)/1e6:17.2f}")

# ---------- 反向压力测试:找到"最可能"导致亏损 ≥ 1500 万的因子组合 ----------
vol = np.array([0.012, 0.004, 0.006, 0.010])                 # 日波动:指数、价值、动量、IV(绝对)
corr = np.array([[1, 0.1, -0.2, -0.75], [0.1, 1, -0.3, -0.05], [-0.2, -0.3, 1, 0.1], [-0.75, -0.05, 0.1, 1]])
Sigma = np.outer(vol, vol) * corr * 10                         # 10 天持有期(平方根法则)
Si = np.linalg.inv(Sigma)
target = -15e6
res = minimize(lambda x: x @ Si @ x, x0=np.array([-0.05, 0, 0, 0.05]), method="SLSQP",
               bounds=[(-0.5, 0.5), (-0.2, 0.2), (-0.2, 0.2), (-0.15, 0.6)],
               constraints=[{"type": "ineq", "fun": lambda x: target - pnl_full(x)}])
x_star = res.x
print("反向压力测试:亏损 1500 万的最可能情景 = 指数 %+.1f%%,价值 %+.1f%%,动量 %+.1f%%,IV %+.1f 点"
      % tuple(100 * x_star), f";马氏距离 {np.sqrt(x_star @ Si @ x_star):.2f},完全重估 P&L {pnl_full(x_star)/1e6:.1f} 百万")
# 同样的马氏距离下,只看股票账户(不含期权)最多亏多少:线性情形的解析解 = 距离 × sqrt(g'Σg)
g_eq = np.r_[equity_expo, 0]
print(f"对照:同一马氏距离下,仅股票账户的最大亏损 {np.sqrt(x_star @ Si @ x_star) * np.sqrt(g_eq @ Sigma @ g_eq)/1e6:.1f} 百万")
# 相关性压力:危机中相关性趋向 ±1 时,10 天 99% 线性 VaR 的变化
g_lin = np.r_[equity_expo + np.array([delta, 0, 0]), vega]
corr_crisis = np.array([[1, .5, -.6, -.9], [.5, 1, -.6, -.3], [-.6, -.6, 1, .3], [-.9, -.3, .3, 1]])
for name, c, k in (("正常相关、正常波动", corr, 1.0), ("危机相关、正常波动", corr_crisis, 1.0),
                   ("危机相关、波动×1.8", corr_crisis, 1.8)):
    S_ = np.outer(vol * k, vol * k) * c * 10
    print(f"{name}: 10 天 99% delta-vega 线性 VaR {2.326 * np.sqrt(g_lin @ S_ @ g_lin)/1e6:.1f} 百万")

输出:

期权账户:现值 -1.41 百万;delta 36.6 百万/100%;gamma -169 百万/(100%)²;vega -24.93 百万/100 波动率点
情景                                       完全重估    delta近似  delta-gamma-vega  (百万元)
温和回调 -5%,IV +5点                         -4.98      -2.58             -4.04
1987 型单日 -20%,IV +30点                  -32.60     -10.33            -21.19
2008 型 -12%,价值 -4% 动量 +3%              -16.04      -6.00            -10.96
动量崩溃:指数 +8%,动量 -12%                     -2.53      -2.27             -1.81
2007 年 8 月量化地震:价值 -3% 动量 -6%            -5.92      -5.32             -5.82
反向压力测试:亏损 1500 万的最可能情景 = 指数 -13.0%,价值 -0.6%,动量 +0.0%,IV +9.6 点 ;马氏距离 3.58,完全重估 P&L -15.0 百万
对照:同一马氏距离下,仅股票账户的最大亏损 4.1 百万
正常相关、正常波动: 10 天 99% delta-vega 线性 VaR 6.2 百万
危机相关、正常波动: 10 天 99% delta-vega 线性 VaR 5.8 百万
危机相关、波动×1.8: 10 天 99% delta-vega 线性 VaR 10.4 百万

几点观察。

第一,近似误差随行情放大。温和回调时 delta-gamma-vega 近似低估亏损 20%;1987 型情景下完全重估亏 3260 万,delta 近似只报 1030 万,delta-gamma-vega 报 2120 万。卖出虚值看跌的风险是"越跌越大",任何截断的泰勒展开都会低估。

第二,反向压力测试找到的情景是"指数 10 天跌 13%、隐含波动升 9.6 个点",马氏距离 3.6。也就是说,在正常协方差下这是一个罕见但并非不可想象的情景。对照组说明了问题所在:同样不可能程度的情景下,股票账户最多亏 410 万,1500 万的风险几乎全部来自期权账户。反向压力测试的价值就在这里:它把"哪个头寸最危险"变成了一个具体的情景,便于风险委员会讨论。

第三,相关性压力的结果和直觉相反:把相关性设成危机水平、波动不变时,线性 VaR 从 620 万降到 580 万。原因是在这个账户中,危机相关性加强了价值与动量的负相关,股票账户的两个因子暴露互相对冲得更好。危机中真正伤害这个账户的是波动放大(乘 1.8 后 VaR 到 1040 万)以及期权的非线性。这说明"相关性趋于 1"不是一个通用的压力情景,要根据账户的暴露结构判断哪些相关性的变化有害。

与真实数据的差别:真实压力测试要覆盖几百个风险因子,情景需要通过因子映射传递到每个头寸;流动性不足时平仓成本本身就是压力损失的一部分(2007 年 8 月"量化地震"中,同类策略同时去杠杆造成了主要损失,见 Khandani 与 Lo 2007)。

11.5 回撤控制

11.5.1 先知道"正常"的回撤有多大

最大回撤 \(\text{MDD}=\min_t\big(V_t/\max_{s\le t}V_s-1\big)\) 是投资者最在意的指标,但它是路径依赖的极值统计量,样本误差很大(本册第 06 章 6.7 节说明了它随样本长度增长、需要用块 bootstrap 给出置信区间;本节转向如何据此设定限额和控制规则)。在设定回撤限额之前,应当先算出一个没有任何问题的策略在给定 Sharpe 和期限下"正常"会有多大回撤。否则,限额设得太紧,好策略也会被频繁触发止损;设得太松,又失去意义。

常见的回撤控制规则有:

  • 波动率目标:仓位 \(w_t=\sigma^*/\hat\sigma_t\)。它控制的是波动,不直接控制回撤。
  • 回撤线性降仓:\(w_t=\max(0,1-\text{DD}_t/D_{\max})\)。Grossman 与 Zhou(1993)证明,在连续交易下按回撤成比例降低风险资产仓位,可以保证回撤不超过 \(D_{\max}\)。代价是在回撤中仓位低,恢复变慢。
  • 止损加冷却:回撤超过阈值后清仓,冷却一段时间后以较低仓位重新开始。

这些规则能否改善风险调整收益,取决于收益是否有持续性。如果策略收益是 iid 的,过去的亏损不包含未来的信息,任何根据回撤降仓的规则都只是在随机时点降低平均仓位,收益和回撤同比例下降。如果策略有"失效期"(例如因子拥挤、市场状态切换),亏损会延续,回撤规则才能避开一部分亏损。

11.5.2 代码

# 代码 11.4  最大回撤的基准分布与三种回撤控制规则
import numpy as np

def max_dd(eq):
    return (eq / np.maximum.accumulate(eq) - 1).min()

# ---------- 1) 先建立基准:无技能变化的 iid 策略,最大回撤本来就有多大 ----------
rng = np.random.default_rng(8)
def mdd_dist(sr, vol=0.10, years=5, n_sim=4000):
    mu = sr * vol / 252; sd = vol / np.sqrt(252)
    R = rng.normal(mu, sd, (n_sim, 252 * years))
    eq = np.cumprod(1 + R, axis=1)
    return (eq / np.maximum.accumulate(eq, axis=1) - 1).min(axis=1)
print("年化波动 10% 的策略,5 年内最大回撤的分布(iid 正态):")
for sr in (0.5, 1.0, 2.0):
    d = mdd_dist(sr)
    print(f"  Sharpe {sr}: 中位数 {100*np.median(d):.1f}%  10% 分位 {100*np.quantile(d,0.1):.1f}%  "
          f"P(回撤超过 15%) {100*np.mean(d < -0.15):.0f}%")

# ---------- 2) 两个世界:iid 与"有状态"(策略失效期持续存在) ----------
def gen(world, n=252 * 20, seed=0):
    g = np.random.default_rng(seed)
    if world == "iid":
        return g.normal(0.8 * 0.10 / 252, 0.10 / np.sqrt(252), n)
    # 两状态马尔可夫:正常期 Sharpe 1.5;失效期 Sharpe -1.0、波动 1.5 倍;平均失效期约 60 天
    s, out = 0, np.empty(n)
    for t in range(n):
        if s == 0 and g.random() < 1 / 400: s = 1
        elif s == 1 and g.random() < 1 / 60: s = 0
        mu, sd = (1.5 * 0.10 / 252, 0.10 / np.sqrt(252)) if s == 0 else (-1.0 * 0.15 / 252, 0.15 / np.sqrt(252))
        out[t] = g.normal(mu, sd)
    return out

def run(R, rule, cost=0.0005):
    eq, peak, w, ws, cool = 1.0, 1.0, 1.0, [], 0
    var = R[:20].var(); path = []
    for t, x in enumerate(R):
        # 仓位 w 在 t 日开盘前决定,只用 t-1 及以前的信息
        dd = eq / peak - 1
        if rule == "无":
            w_new = 1.0
        elif rule == "波动率目标":
            w_new = min(0.10 / np.sqrt(252 * var), 2.0)
        elif rule == "回撤线性降仓":             # Grossman–Zhou 型:回撤 0→20%,仓位 1→0
            w_new = max(0.0, 1 + dd / 0.20)
        elif rule == "止损+冷却":                 # 回撤超 10% 清仓,冷却 40 天后以半仓重启并重置高点
            if cool > 0:
                cool -= 1; w_new = 0.0
                if cool == 0: peak = eq; w_new = 0.5
            elif dd < -0.10:
                cool = 40; w_new = 0.0
            else:
                w_new = 1.0 if dd > -0.05 else w
        eq *= 1 + w_new * x - cost * abs(w_new - w)
        w = w_new; ws.append(w); peak = max(peak, eq); path.append(eq)
        var = 0.94 * var + 0.06 * x**2
    path = np.array(path)
    cagr = path[-1] ** (252 / len(R)) - 1
    return cagr, max_dd(path), np.mean(ws)

for world in ("iid", "有状态"):
    print(f"—— {world} 世界(20 年,50 条路径的中位数)——")
    for rule in ("无", "波动率目标", "回撤线性降仓", "止损+冷却"):
        res = np.array([run(gen(world, seed=s), rule) for s in range(50)])
        med = np.median(res, axis=0)
        calmar = np.median(res[:, 0] / -res[:, 1])
        print(f"  {rule:8s} 年化 {100*med[0]:5.2f}%  最大回撤 {100*med[1]:6.1f}%  Calmar {calmar:4.2f}  平均仓位 {med[2]:.2f}")

输出:

年化波动 10% 的策略,5 年内最大回撤的分布(iid 正态):
  Sharpe 0.5: 中位数 -16.8%  10% 分位 -26.7%  P(回撤超过 15%) 63%
  Sharpe 1.0: 中位数 -12.9%  10% 分位 -19.7%  P(回撤超过 15%) 31%
  Sharpe 2.0: 中位数 -9.2%  10% 分位 -13.2%  P(回撤超过 15%) 4%
—— iid 世界(20 年,50 条路径的中位数)——
  无        年化  8.32%  最大回撤  -21.7%  Calmar 0.41  平均仓位 1.00
  波动率目标    年化  8.13%  最大回撤  -22.6%  Calmar 0.39  平均仓位 1.02
  回撤线性降仓   年化  5.87%  最大回撤  -14.4%  Calmar 0.40  平均仓位 0.75
  止损+冷却    年化  7.85%  最大回撤  -20.3%  Calmar 0.39  平均仓位 0.94
—— 有状态 世界(20 年,50 条路径的中位数)——
  无        年化 11.09%  最大回撤  -24.6%  Calmar 0.44  平均仓位 1.00
  波动率目标    年化 10.82%  最大回撤  -20.4%  Calmar 0.52  平均仓位 0.97
  回撤线性降仓   年化  8.09%  最大回撤  -15.3%  Calmar 0.52  平均仓位 0.74
  止损+冷却    年化 10.85%  最大回撤  -19.6%  Calmar 0.59  平均仓位 0.94

第一部分给出了基准:年化波动 10%、Sharpe 1 的策略,5 年内最大回撤的中位数约 13%,有 31% 的概率超过 15%;Sharpe 0.5 时这一概率是 63%。如果把这样一个策略的回撤限额设为 10%,大概率会在没有任何问题的情况下被止损。

第二部分验证了上面的论断。在 iid 世界中,回撤线性降仓把最大回撤从 21.7% 降到 14.4%,年化收益也从 8.3% 降到 5.9%,Calmar 比率几乎不变(0.41 对 0.40),相当于直接把仓位降到 75%。在有状态的世界中,三种规则都提高了 Calmar:止损加冷却从 0.44 提到 0.59,回撤降仓和波动率目标都到 0.52。

回撤线性降仓在两个世界中的最大回撤都没有超过 20%,这正是 Grossman–Zhou 规则的保证。但它也有一个工程上的隐患:回撤越接近上限,仓位越接近零,资金曲线越难恢复。真实使用时通常会设置重置规则(例如季度末重置高水位)或仓位下限。

白话解释:代码 11.4 分两部分,各自有明确的设计意图。 第一部分 mdd_dist 是"先算基准":生成 4000 条没有任何问题的 iid 收益路径,np.maximum.accumulate 求出每条路径上截至每天的历史最高净值(高水位),净值除以高水位减 1 就是每天的回撤,取最小值即最大回撤。这和私募基金按高水位计提业绩报酬时用的是同一条"高水位线"。 第二部分 run 是一个逐日的仓位模拟器。关键是顺序:每天先用昨天收盘的净值和高水位算回撤、决定仓位 w_new,再乘以今天的收益,最后才更新高水位和 EWMA 方差。如果顺序反了,就等于用今天的收益决定今天的仓位,回撤规则会显得神奇地有效。调仓成本按 \(|w_{\text{新}}-w_{\text{旧}}|\times5\)bp 扣除,所以频繁进出的规则会被如实惩罚。 线性降仓为什么能保证回撤不超过 20%:仓位 \(w=1-\text{DD}/0.2\) 恰好等于"离 20% 底线还剩多少"占 20% 的比例。回撤越深仓位越小,剩余空间越小亏得越少,净值只会无限接近底线而不会穿过(只要单日跌幅不超过 20%)。这和 CPPI(固定比例组合保险)的思路相同:风险资产仓位与"净值高于保底线的部分"成正比。 Calmar 比率 = 年化收益 / 最大回撤的绝对值,是回撤版的"收益风险比"。iid 世界中降仓规则的 Calmar 不变,正是因为收益和回撤被同比例缩小了。

与真实数据的差别:真实策略的"失效期"不会像马尔可夫模型那样有固定的平均长度,事前无法知道它属于哪个世界。合理的做法是用策略自身的历史检验收益的持续性(例如亏损月之后的收益分布),并把回撤规则当作风险预算的一部分写进投资政策,而不是在回撤发生之后临时决定。

11.6 绩效评估与归因

11.6.1 先承认统计误差,再对照实盘

第 07 册第 22b 章说明了一个令人沮丧的事实:在月度数据上,分辨中等水平的技能需要几十年。本册第 06 章 6.6、6.7 节已经给出了日常需要的工具:Sharpe 比率的标准误及其偏度、峰度修正,收益自相关时的 Lo(2002)年化调整,块 bootstrap 置信区间,以及多次试验后的 PSR 与 Deflated Sharpe。这里不再重复,只强调两条用法。其一,负偏、厚尾的策略(例如本册第 09 章的卖方差),同样的 Sharpe 可信度更低,标准误要用含偏度、峰度的公式;其二,估值平滑的资产(第 07 册第 22a 章)会让观测到的 Sharpe 虚高,要先做自相关修正再比较。

策略上线后,绩效评估还多了一项日常工作:把实盘收益和"同期回测收益"逐日对照。两者之差可以拆成几部分:信号差异(实盘数据与研究数据不一致、延迟)、成交差异(执行差额、滑点高于成本模型,见本册第 07 章)、持仓差异(风控拒单、涨跌停无法成交、手工干预)和费用差异。差异的均值说明回测是否系统性乐观,差异的波动说明回测能否代表实盘的风险。差异突然扩大,往往是数据或系统出了问题,而不是市场变了。

11.6.2 归因的三种方法

  • 收益型因子归因:用策略收益对因子收益做时间序列回归,\(r_t=\alpha+\beta^\top f_t+\varepsilon_t\),每个因子的贡献为 \(\hat\beta_k\bar f_k\)。它只需要收益序列,适合评估外部经理;缺点是暴露被假定为常数,择时会被错误地计入 alpha(第 07 册第 22a 章的三分解讨论了这一点)。标准误用 Newey–West(第 05 册第 16 章)。
  • 持仓型因子归因:每天用组合的因子暴露 \(x_t\) 乘以当天的因子收益 \(f_t\),剩下的是特质收益:\(r_t=x_t^\top f_t+u_t\)。它需要持仓和风险模型(本册第 04 章),能处理时变暴露,是内部风控和交易员考核的标准做法。
  • Brinson 归因:按行业(或资产类别)把相对基准的超额收益拆成配置效应、选股效应和交互效应。Brinson–Fachler 形式为
    \[A_j=(w_j^P-w_j^B)(r_j^B-R^B),\quad S_j=w_j^B(r_j^P-r_j^B),\quad I_j=(w_j^P-w_j^B)(r_j^P-r_j^B),\]
    三者对行业求和等于 \(R^P-R^B\)。

推导拆解:为什么三者之和恰好等于超额。记 \(R^P=\sum_jw_j^Pr_j^P\),\(R^B=\sum_jw_j^Br_j^B\)。 第一步,选股与交互合并:\(S_j+I_j=w_j^B(r_j^P-r_j^B)+(w_j^P-w_j^B)(r_j^P-r_j^B)=w_j^P(r_j^P-r_j^B)\)。 第二步,配置效应求和时,\(\sum_j(w_j^P-w_j^B)R^B=R^B(1-1)=0\),因为两套权重之和都是 1,所以 \(\sum_jA_j=\sum_j(w_j^P-w_j^B)r_j^B\)。 第三步,相加:\(\sum_j(w_j^P-w_j^B)r_j^B+\sum_jw_j^P(r_j^P-r_j^B)=\sum_jw_j^Pr_j^P-\sum_jw_j^Br_j^B=R^P-R^B\)。 Fachler 形式减去 \(R^B\) 的作用只在单个行业上:超配一个跑输整体基准的行业,即使该行业收益为正,配置效应也记为负。代码输出中能源行业低配 5 个百分点、行业收益 −3%(远低于基准 1.775%),所以配置效应为正(+23.9bp),这正是"低配差行业"应得的奖励。

金融直觉:这就是 CFA 三级业绩评估里的 Brinson 归因,和 GIPS 要求的业绩呈报配套使用:GIPS 规定收益怎么算、怎么报,归因回答收益为什么是这个数。配置效应对应"行业判断"(通常是投资总监或资产配置委员会的决策),选股效应对应"行业内选股"(通常是行业研究员的决策),交互效应是两者的叠加。很多机构为了让责任清楚,把交互效应并入选股效应,这和上面第一步合并的式子一致。

多期归因有一个技术问题:各期的超额收益可以相加,但年度的几何超额收益不等于各月超额之和,所以各月的归因效应直接相加对不上年度超额。Carino(1999)的对数链接法用系数 \(k_t/k\) 缩放每期效应,其中

\[k_t=\frac{\ln(1+R^P_t)-\ln(1+R^B_t)}{R^P_t-R^B_t},\qquad k=\frac{\ln(1+R^P)-\ln(1+R^B)}{R^P-R^B},\]

缩放后各期效应之和恰好等于年度几何超额。

推导拆解:Carino 链接为什么"恰好"。核心是对数可以相加。 第一步,按定义 \(k_t(R_t^P-R_t^B)=\ln(1+R_t^P)-\ln(1+R_t^B)\)。每月的归因效应之和等于当月超额 \(R_t^P-R_t^B\),乘以 \(k_t\) 后就等于当月的"对数超额"。 第二步,对数超额可以跨月相加:\(\sum_t[\ln(1+R_t^P)-\ln(1+R_t^B)]=\ln(1+R^P)-\ln(1+R^B)\),因为 \(1+R^P=\prod_t(1+R_t^P)\),乘积取对数就是对数之和。 第三步,年度上同样有 \(k(R^P-R^B)=\ln(1+R^P)-\ln(1+R^B)\)。把第二步的和除以 \(k\),就回到年度几何超额 \(R^P-R^B\)。所以每期效应乘以 \(k_t/k\) 再相加,总和一定等于年度超额。 \(k_t\) 本身是"对数差 ÷ 算术差",月度收益不大时接近 \(1/(1+R_t)\),一般在 0.9–1.1 之间,所以缩放只是微调各月权重。代码中 k_t 在 \(R_t^P=R_t^B\) 时会出现 0/0,真实系统里要单独处理这种情形(取极限值 \(1/(1+R_t^B)\))。

11.6.3 代码

# 代码 11.5  收益型因子归因、Brinson–Fachler 与 Carino 多期链接
import numpy as np
import pandas as pd
import statsmodels.api as sm

rng = np.random.default_rng(21)
# ================= A) 收益型因子归因:时间序列回归 + Newey–West =================
n_m = 120
fac = pd.DataFrame({"市场": rng.normal(0.006, 0.045, n_m), "规模": rng.normal(0.002, 0.025, n_m),
                    "价值": rng.normal(0.002, 0.030, n_m), "动量": rng.normal(0.006, 0.040, n_m)})
true_beta = np.array([0.3, 0.5, 0.0, 0.6])
strat = 0.001 + fac.values @ true_beta + rng.normal(0, 0.012, n_m)
m = sm.OLS(strat, sm.add_constant(fac)).fit(cov_type="HAC", cov_kwds={"maxlags": 3})
contrib = m.params[fac.columns] * fac.mean() * 12
print(f"策略年化收益 {100*strat.mean()*12:.1f}%,其中:" + ",".join(f"{k} {100*v:.1f}%" for k, v in contrib.items()) +
      f",alpha {100*m.params['const']*12:.1f}%(NW t={m.tvalues['const']:.2f},真值 1.2%);R²={m.rsquared:.2f}")

# ================= B) Brinson–Fachler 归因(单期)与 Carino 多期链接 =================
sectors = ["金融", "科技", "消费", "工业", "能源"]
def brinson(wp, wb, rp, rb):
    Rb = wb @ rb
    alloc = (wp - wb) * (rb - Rb); select = wb * (rp - rb); inter = (wp - wb) * (rp - rb)
    return alloc, select, inter
wb = np.array([0.25, 0.30, 0.20, 0.15, 0.10]); wp = np.array([0.15, 0.40, 0.25, 0.15, 0.05])
rb = np.array([0.010, 0.040, 0.020, 0.015, -0.030]); rp = np.array([0.012, 0.050, 0.015, 0.020, -0.020])
a_, s_, i_ = brinson(wp, wb, rp, rb)
print(pd.DataFrame({"配置": a_, "选股": s_, "交互": i_}, index=sectors).mul(1e4).round(1).T.to_string())
print(f"超额 {1e4*(wp@rp - wb@rb):.1f}bp = 配置 {1e4*a_.sum():.1f} + 选股 {1e4*s_.sum():.1f} + 交互 {1e4*i_.sum():.1f}")

# 12 个月:各月效应直接相加 ≠ 年度几何超额;Carino 用对数系数把各月效应缩放后再相加
months = []
for _ in range(12):
    rb_t = rng.normal(0.008, 0.03, 5); rp_t = rb_t + rng.normal(0.001, 0.005, 5)
    wp_t = np.clip(wp + rng.normal(0, 0.02, 5), 0, None); wp_t /= wp_t.sum()
    months.append((wp_t @ rp_t, wb @ rb_t, *[x.sum() for x in brinson(wp_t, wb, rp_t, rb_t)]))
Mo = pd.DataFrame(months, columns=["Rp", "Rb", "配置", "选股", "交互"])
Rp_y, Rb_y = np.prod(1 + Mo.Rp) - 1, np.prod(1 + Mo.Rb) - 1
k = (np.log1p(Rp_y) - np.log1p(Rb_y)) / (Rp_y - Rb_y)
k_t = (np.log1p(Mo.Rp) - np.log1p(Mo.Rb)) / (Mo.Rp - Mo.Rb)
linked = Mo[["配置", "选股", "交互"]].mul(k_t / k, axis=0).sum()
print(f"年度超额 {1e4*(Rp_y-Rb_y):.1f}bp;各月效应简单相加 {1e4*Mo[['配置','选股','交互']].sum().sum():.1f}bp;"
      f"Carino 链接后 " + " + ".join(f"{c} {1e4*v:.1f}" for c, v in linked.items()) + f" = {1e4*linked.sum():.1f}bp")

输出:

策略年化收益 10.1%,其中:市场 2.4%,规模 -1.7%,价值 -0.0%,动量 6.2%,alpha 3.2%(NW t=3.06,真值 1.2%);R²=0.86
     金融    科技    消费   工业    能源
配置  7.8  22.3   1.1 -0.0  23.9
选股  5.0  30.0 -10.0  7.5  10.0
交互 -2.0  10.0  -2.5  0.0  -5.0
超额 98.0bp = 配置 55.0 + 选股 42.5 + 交互 0.5
年度超额 141.5bp;各月效应简单相加 128.6bp;Carino 链接后 配置 -41.6 + 选股 214.3 + 交互 -31.3 = 141.5bp

A 部分:策略年化收益 10.1%,其中市场和动量暴露分别贡献 2.4 和 6.2 个百分点,规模暴露因为样本期规模因子收益为负而拖累 1.7 个百分点;回归的 alpha 为 3.2%,NW t 值 3.06。模拟中真实的 alpha 只有 1.2%:10 年月度数据下 alpha 的标准误约 1.0%,估计值偏离真值约两个标准误,这在单次样本中完全可能发生。所以 alpha 的点估计不应直接用于资金分配,至少要给出置信区间,并与其他证据结合(第 03 册第 11 章的贝叶斯收缩是一种做法)。

白话解释:A 部分的代码就是 CFA 里基于收益的风格分析:用 statsmodels 把策略月收益对四个因子回归,cov_type="HAC" 指定 Newey–West 标准误(对残差的自相关和异方差稳健,maxlags=3 表示考虑最多 3 个月的自相关)。每个因子的年化贡献 = 回归系数 × 该因子样本期月均收益 × 12,剩下的截距 × 12 就是年化 alpha。 标准误可以手算验证:残差月波动 1.2%,120 个月,截距的月度标准误约 \(1.2\%/\sqrt{120}\approx0.11\%\),年化约 1.3%;输出给出的 NW 标准误是 \(3.2\%/3.06\approx1.05\%\),量级一致(样本残差波动和 HAC 修正会让它略有不同)。95% 置信区间大约是点估计 ±2 个百分点,即从 1% 左右到 5% 左右,这就是"宽得惊人"的含义。

B 部分:单期 Brinson 中,超额 98bp 由配置 55bp、选股 42.5bp、交互 0.5bp 组成,三者相加精确等于超额。12 个月的例子中,年度几何超额 141.5bp,各月效应直接相加只有 128.6bp,差额约 13bp 没有归属;Carino 链接后三项之和精确等于 141.5bp。这个例子里选股贡献为正,配置和交互为负,如果只看年度超额,会掩盖"行业判断在亏钱"这一信息。

与真实数据的差别:真实的 Brinson 归因要处理现金、汇率、交易日内买卖和基准成分变化;因子归因的结果依赖所选风险模型,同一组合在两个风险模型下 alpha 可以差很多。

11.7 风控体系与事故教训

11.7.1 事故的共同模式

第 08 册第 36 章(Hull)整理了衍生品历史上的重大亏损,第 07 册第 28 章(Harris)讨论了泡沫、崩盘和熔断。把这些案例与量化交易相关的部分放在一起,可以看到几种反复出现的模式:

  • 限额形同虚设:巴林银行(1995)、法国兴业银行(2008)、UBS(2011)的巨亏都来自单个交易员。共同点是授权的业务是低风险的套利或对冲,实际持有巨大的方向性头寸;前台同时控制了交易记录和结算,限额从未被真正执行。教训是前台、中台、后台分离,风险由独立部门基于实际持仓计算。
  • 模型和杠杆:LTCM(1998)的收敛套利在模型上是正确的,许多头寸后来也确实收敛了,但高杠杆让它撑不到收敛的那天;同类机构持有相似头寸,被迫同时平仓。2007 年 8 月的"量化地震"是同一机制在股票统计套利上的重演。教训是警惕所有人采用同一策略,压力测试要包含流动性和同行去杠杆。
  • 对冲变成投机:Metallgesellschaft(1993)用滚动短期期货对冲长期供货合约,油价下跌带来巨额保证金追缴,曲线转为升水又让展期持续亏损(展期机制见本册第 09 章 9.6 节);Amaranth(2006)在天然气价差上集中持仓。教训是对冲方案要做融资流动性压力测试,并限制集中度。
  • 软件和运维:Knight Capital(2012)在新代码部署时,八台服务器中有一台残留旧代码,被新的标志位重新激活后在约 45 分钟内发出大量错误订单,亏损约 4.4 亿美元。2010 年 5 月 6 日的"闪电崩盘"中,美股指数在几分钟内下跌约 9% 后迅速反弹。教训是事前风控必须在交易系统内部以代码运行,并有不依赖策略本身的自动熔断。
  • 组合保险与正反馈:1987 年 10 月的股灾中,组合保险按规则在下跌时卖出期货,加剧了下跌(第 08 册第 19a 章 19.14 节)。动态对冲策略在跳空和流动性枯竭时失效,这一点在本册第 09 章的尾部对冲中也看到了。

11.7.2 风控系统的结构

一个量化交易系统的风控通常分三道:

  1. 事前(pre-trade):每一笔订单发出前在交易系统内部检查。必须快(微秒到毫秒级),必须独立于策略代码,策略不能绕过。检查项包括单笔金额、价格带、持仓、总敞口与净敞口、行业或因子暴露、下单频率、重复订单。
  2. 盘中(intraday):实时盯市,计算盈亏、希腊字母和 VaR,触及日内亏损上限或异常行为时触发 kill switch,停止新订单,必要时撤销所有挂单。kill switch 的恢复需要人工确认。
  3. 事后(post-trade):日终的 VaR、压力测试、归因、限额使用率报告,以及成交与持仓的对账。

代码层面有几个容易忽视的细节:频率和重复订单的统计要包括被拒的订单,因为失控程序被拒后往往会更快地重试;检查顺序从最便宜的到最贵的,先做简单比较,后做需要汇总全部持仓的计算;限额参数集中配置并有版本记录;风控模块本身要有单元测试,覆盖每一种拒单路径。

11.7.3 代码:一个最小的事前风控引擎

下面模拟一个交易日约 6000 笔订单尝试:正常算法单略偏买方,使净多头逐渐累积;第 1500 笔价格多打一个零,第 2500 笔数量多打两个零;第 3000–3400 笔是一个每 10 毫秒重复同一订单的失控循环;第 4500 笔时全市场闪崩 8%。

# 代码 11.6  最小事前风控引擎与 kill switch
import numpy as np
from collections import deque, Counter
from dataclasses import dataclass, field

@dataclass
class Limits:
    max_order_notional: float = 2e6        # 单笔金额上限
    price_band: float = 0.05               # 限价偏离最新价不超过 5%(防"胖手指")
    max_position_notional: float = 8e6     # 单票持仓上限
    max_gross: float = 1.2e8               # 总敞口上限
    max_net: float = 1.5e7                 # 净敞口上限
    max_sector_net: float = 1.0e7          # 单行业净敞口上限
    max_orders_per_sec: int = 20           # 下单频率上限
    max_dup_orders: int = 5                # 同一 (股票, 方向, 数量) 在 1 秒内的重复次数上限
    daily_loss_limit: float = 1.0e6        # 日内亏损达到即触发 kill switch

@dataclass
class RiskEngine:
    lim: Limits
    sector: dict
    pos: dict = field(default_factory=dict)            # 股数
    last_px: dict = field(default_factory=dict)
    pnl: float = 0.0
    killed: bool = False
    recent: deque = field(default_factory=deque)       # (时间, 订单签名)
    log: Counter = field(default_factory=Counter)

    def _expo(self, pos):
        g = sum(abs(q) * self.last_px[s] for s, q in pos.items())
        n = sum(q * self.last_px[s] for s, q in pos.items())
        sec = Counter()
        for s, q in pos.items(): sec[self.sector[s]] += q * self.last_px[s]
        return g, n, sec

    def check(self, t, sym, qty, limit_px):
        """事前风控:任何一条不过即拒单,并记录原因。顺序:从最便宜的检查到最贵的检查。"""
        L = self.lim
        if self.killed: return self._rej("kill switch 已触发")
        # 频率与重复检查统计的是"所有下单尝试",包括被拒的:失控程序被拒后往往会更快地重试
        while self.recent and t - self.recent[0][0] > 1.0: self.recent.popleft()
        sig = (sym, np.sign(qty), abs(qty))
        n_recent, n_dup = len(self.recent), sum(1 for _, s in self.recent if s == sig)
        self.recent.append((t, sig))
        if n_recent >= L.max_orders_per_sec: return self._rej("下单频率超限")
        if n_dup >= L.max_dup_orders: return self._rej("重复订单(疑似失控循环)")
        px = self.last_px[sym]
        if abs(limit_px / px - 1) > L.price_band: return self._rej("价格偏离超限(胖手指)")
        if abs(qty) * px > L.max_order_notional: return self._rej("单笔金额超限")
        new = dict(self.pos); new[sym] = new.get(sym, 0) + qty
        if abs(new[sym]) * px > L.max_position_notional: return self._rej("单票持仓超限")
        g, n, sec = self._expo(new)
        if g > L.max_gross: return self._rej("总敞口超限")
        if abs(n) > L.max_net: return self._rej("净敞口超限")
        if abs(sec[self.sector[sym]]) > L.max_sector_net: return self._rej("行业净敞口超限")
        self.log["通过"] += 1
        return True

    def _rej(self, why):
        self.log[why] += 1; return False

    def fill(self, sym, qty):
        self.pos[sym] = self.pos.get(sym, 0) + qty

    def mark(self, new_px):
        """盯市:更新价格与当日盈亏;触及日内亏损上限就熔断"""
        self.pnl += sum(q * (new_px[s] - self.last_px[s]) for s, q in self.pos.items())
        self.last_px.update(new_px)
        if self.pnl < -self.lim.daily_loss_limit and not self.killed:
            self.killed = True; self.log["触发 kill switch"] += 1

# ---------- 模拟一个交易日:正常算法单 + 两次"胖手指" + 一段失控循环 + 午后闪崩 ----------
rng = np.random.default_rng(0)
syms = [f"S{i:02d}" for i in range(40)]
sector = {s: ["金融", "科技", "消费", "工业"][i % 4] for i, s in enumerate(syms)}
eng = RiskEngine(Limits(), sector, last_px={s: float(rng.uniform(10, 100)) for s in syms})
t = 0.0
for step in range(6000):
    t += rng.exponential(0.5)                                       # 平均每 0.5 秒一笔
    s = syms[rng.integers(40)]; px = eng.last_px[s]
    qty = int(rng.choice([-1, 1], p=[0.4, 0.6]) * rng.integers(1, 20) * 100)   # 买单略多:净多头逐渐累积
    lpx = px * (1 + rng.normal(0, 0.002))
    if step == 1500: lpx = px * 10                                  # 胖手指:价格多打一个 0
    if step == 2500: qty = 100_000                                  # 胖手指:数量多打两个 0
    if 3000 <= step < 3400:                                         # 失控循环:同一订单每 10 毫秒重复
        s, qty, lpx = "S07", 500, eng.last_px["S07"]; t += 0.01 - 0.5
    if eng.check(t, s, qty, lpx): eng.fill(s, qty)
    shock = -0.08 if step == 4500 else 0.0                          # 午后闪崩:全市场下跌 8%
    eng.mark({k: v * np.exp(rng.normal(0, 0.0005) + shock) for k, v in eng.last_px.items()})
    if step == 4499:
        g0, n0, _ = eng._expo(eng.pos); print(f"闪崩前:总敞口 {g0/1e6:.1f} 百万,净敞口 {n0/1e6:+.1f} 百万,日内盈亏 {eng.pnl/1e6:+.2f} 百万")
g, n, sec = eng._expo(eng.pos)
print("风控日志:", dict(eng.log))
print(f"收盘:总敞口 {g/1e6:.1f} 百万,净敞口 {n/1e6:+.1f} 百万,日内盈亏 {eng.pnl/1e6:+.2f} 百万,kill switch={eng.killed}")
print("各行业净敞口(百万):", {k: round(float(v) / 1e6, 2) for k, v in sec.items()})

输出:

闪崩前:总敞口 20.3 百万,净敞口 +14.9 百万,日内盈亏 +0.01 百万
风控日志: {'通过': 3631, '净敞口超限': 507, '价格偏离超限(胖手指)': 1, '单笔金额超限': 1, '重复订单(疑似失控循环)': 113, '下单频率超限': 248, '触发 kill switch': 1, 'kill switch 已触发': 1499}
收盘:总敞口 18.9 百万,净敞口 +13.9 百万,日内盈亏 -1.08 百万,kill switch=True
各行业净敞口(百万): {'消费': 4.64, '金融': 2.44, '工业': 1.1, '科技': 5.7}

两次胖手指各被价格带和单笔金额检查拦截。失控循环先被重复订单检查拦截 113 次,再被频率检查拦截 248 次;如果频率统计只计已通过的订单,这段循环会持续到撞上净敞口上限才停,而那时已经积累了不必要的持仓。正常订单中有 507 笔因净敞口达到 1500 万上限被拒,这是限额在约束策略的方向性漂移。闪崩时净多头 1490 万,损失 106 万,超过 100 万的日内亏损上限,kill switch 触发,此后 1499 笔订单全部被拒。

白话解释:代码 11.6 的设计要点。 Limits 用一个数据类集中存放全部限额参数,对应 11.7.2 说的"限额集中配置、有版本记录":改限额只改这一处,也便于审计留痕。RiskEngine.check 按"从便宜到昂贵"的顺序检查:kill switch 状态是一次布尔判断;频率和重复订单只看最近 1 秒的队列;价格带、单笔金额只需当前订单;最后才复制一份持仓、汇总总敞口、净敞口和行业敞口,这一步要遍历全部持仓,最慢。任何一项不过就立即返回,后面的检查不再执行。 最近 1 秒的订单用 deque(双端队列)保存:新订单从右边加入,超过 1 秒的旧订单从左边弹出,每次只需处理队首,代价很小。注意新订单在判断之前就已经加入队列,所以被拒的尝试也会计入频率,这就是正文强调的"失控程序被拒后会更快重试"的对策。 mark 每次盯市后检查日内盈亏,跌破上限就把 killed 置为真,此后 check 第一行就拒单;代码中没有任何把它恢复为假的路径,对应"自动触发、人工恢复"。 对照 CPA 的内部控制:这几行代码就是"授权审批 + 限额控制"的自动化版本,而且不依赖交易员自觉,策略代码无法绕过。

白话解释:一处模拟细节。失控循环段写的是 t += 0.01 - 0.5,本意是抵消前面加上的平均 0.5 秒间隔,使每笔间隔约 10 毫秒。但前面加的是随机的指数分布间隔,所以这一段里的时间增量有时为负,即时间戳偶尔会倒退,只是平均间隔为 10 毫秒。这不影响本例的结论(循环仍然被重复检查和频率检查拦下),但 deque 按队首弹出的逻辑默认时间单调递增;在真实系统里时间戳来自交易系统时钟,不会出现这个问题。练习 6 写单元测试时,可以顺便把模拟改为固定的 10 毫秒间隔。

这个引擎只是骨架。生产系统还需要:撤单和改单的检查;按账户、策略、交易员分层的限额;保证金和融资约束;与交易所风控和券商风控的衔接;所有拒单和限额变更的审计日志。

11.8 常见陷阱与检查清单

VaR 与 ES

  • 用条件模型(FHS、条件 EVT、GARCH-t);单纯的历史模拟在波动转折点会成串突破。
  • 参数重估与滤波分开,检查 \(\sigma_t\) 是否只用了 \(t-1\) 及以前的信息。
  • 同时输出 VaR、ES 和 ES 回测所需的 VaR 分位。
  • EVT 的 \(\xi\) 做阈值敏感性分析并设上下限。

回测

  • Kupiec、独立性、交通灯和 ES 回测一起用;记住样本少时检验功效低。
  • 回测用的 P&L 要与 VaR 的定义一致:是否包含日内交易、手续费、新开仓。

压力测试

  • 含期权的账户用完全重估;情景中包含隐含波动和偏斜的变化。
  • 做反向压力测试,找出最危险的头寸。
  • 相关性压力要根据暴露结构设计,"相关性趋于 1"不一定是坏情景。

回撤控制

  • 先用模拟算出"正常"回撤的分布,再设限额。
  • 回撤规则只在收益有持续性时有帮助;写进投资政策,不要事后临时决定。
  • 线性降仓规则要有重置机制或仓位下限。

绩效与归因

  • Sharpe 报告标准误,并做非正态和自相关修正;多次试验后用 Deflated Sharpe(本册第 06 章)。
  • 实盘与同期回测逐日对照,拆分信号、成交、持仓和费用差异。
  • alpha 给置信区间;收益型归因注意时变暴露。
  • 多期 Brinson 用对数链接,保证各项之和等于几何超额。

风控体系

  • 事前风控在交易系统内部运行,策略不能绕过;频率统计包含被拒订单。
  • kill switch 自动触发、人工恢复;限额集中配置、有版本、有审计。
  • 前中后台分离;风险由独立部门基于实际持仓计算。

本章小结

本章把风险度量、风险控制和绩效评估组装成一个可以每天运行的流程。VaR 和 ES 要用条件模型估计,过滤历史模拟和条件 EVT 在本例的危机中表现最好;回测要同时检查突破频率、突破的独立性和尾部损失的大小。压力测试弥补了 VaR 只看"正常坏日子"的不足,含期权的账户必须做完全重估,反向压力测试能把最危险的头寸变成一个具体情景。回撤规则的价值取决于策略收益是否有持续性,设限额之前要先知道正常回撤有多大。绩效评估的核心是承认统计误差:Sharpe 和 alpha 都有很宽的置信区间;归因要拆出因子暴露,多期效应要正确链接,实盘还要与同期回测对照。最后,历史上的重大亏损大多来自限额、流程和软件的失效,事前风控和 kill switch 应当是交易系统里的代码。

练习

  1. 阈值敏感性。 在 11.2.2 的代码中,把 GPD 阈值分位从 0.90 改为 0.85、0.95、0.975,记录 \(\xi\) 和 99.9% VaR 的变化,并画出标准化损失的平均超出函数。提示:阈值太高时超出点太少,\(\xi\) 的估计方差急剧增大。

  2. 检验功效。 生成一个"真实"突破率为 1.5% 的独立突破序列,长度分别为 250、1000、2500 天,各重复 5000 次,计算 Kupiec 检验在 5% 水平的拒绝率。再对 \(Z_2\) 做同样的试验:让真实损失分布的尾部比模型厚(例如真实为 t3、模型为 t6)。提示:结论应当是短样本下两者功效都很低。

  3. 多期压力情景。 把 11.4.2 的单期情景改为 10 天路径:指数每天下跌 2%,隐含波动每天上升 3 个点,期权每天按剩余期限重新定价,期间不调整头寸。比较路径情景与"一次性下跌 18%"的完全重估损失。提示:时间流逝让期权的 theta 和 gamma 都在变化。

  4. 回撤规则的过拟合。 在 11.5.2 的有状态世界中,对止损阈值(5%–20%)和冷却期(10–80 天)做网格搜索,在前 10 年选最优参数,在后 10 年检验。重复 50 条路径,比较样本内和样本外的 Calmar。提示:这与本册第 06 章的多重检验问题相同。

  5. 持仓型归因。 在 11.6.3 的代码中,让策略的动量暴露随时间变化(例如前 5 年 0.6、后 5 年 −0.2),比较收益型回归和持仓型归因(\(x_t^\top f_t\) 逐月相加)对动量贡献和 alpha 的估计。提示:收益型回归会把时变暴露的影响混进 alpha。

  6. 风控引擎的单元测试。 为 11.7.3 的 RiskEngine 写测试:每一种拒单原因至少一个用例;kill switch 触发后所有订单被拒;频率窗口跨越 1 秒边界时计数正确。再加入"撤销所有挂单"和"人工恢复"两个接口。

延伸阅读

  • 第 08 册第 22 章(VaR 与 ES、历史模拟、模型构建法、delta-gamma 近似、压力测试与回测)、第 23 章(EWMA 与 GARCH)、第 36 章(衍生品事故及其教训)、第 19a 章 19.14 节(组合保险与 1987 年股灾)。
  • 第 06 册第 07a 章(RiskMetrics、计量方法、VaR 回测)、第 07b 章(极值理论与 POT)、第 10 章(多元波动率与多资产 VaR)。
  • 第 07 册第 22a、22b 章(绩效度量、技能与运气的统计检验)、第 28 章(泡沫、崩盘与熔断机制)。
  • 第 05 册第 16 章(HAC 标准误);第 03 册第 10b 章(多重检验)。
  • 本册第 04 章(风险模型)、第 06 章(回测方法论与 Deflated Sharpe)、第 09 章(尾部风险对冲)。
  • McNeil, A., & Frey, R. (2000). Estimation of tail-related risk measures for heteroscedastic financial time series: an extreme value approach. Journal of Empirical Finance, 7.
  • Christoffersen, P. (1998). Evaluating interval forecasts. International Economic Review, 39(4).
  • Kupiec, P. (1995). Techniques for verifying the accuracy of risk measurement models. Journal of Derivatives, 3(2).
  • Acerbi, C., & Szekely, B. (2014). Backtesting expected shortfall. Risk.
  • Artzner, P., Delbaen, F., Eber, J.-M., & Heath, D. (1999). Coherent measures of risk. Mathematical Finance, 9(3).
  • Grossman, S., & Zhou, Z. (1993). Optimal investment strategies for controlling drawdowns. Mathematical Finance, 3(3).
  • Lo, A. (2002). The statistics of Sharpe ratios. Financial Analysts Journal, 58(4).
  • Brinson, G., Hood, L. R., & Beebower, G. (1986). Determinants of portfolio performance. Financial Analysts Journal, 42(4).
  • Carino, D. (1999). Combining attribution effects over time. Journal of Performance Measurement.
  • Khandani, A., & Lo, A. (2007). What happened to the quants in August 2007? Journal of Investment Management, 5(4).
  • Grinold, R., & Kahn, R. Active Portfolio Management. McGraw-Hill.