Skip to content

16.3 压力测试与情景分析

名词解释:压力测试(Stress Testing)

压力测试是评估投资组合在极端但合理(Severe but Plausible)的市场条件下表现的技术。与 VaR 关注"正常市场条件下的最大损失"不同,压力测试关注"当一切同时出问题时会发生什么"。监管机构(如美联储的 CCAR/DFAST)要求大型金融机构定期进行系统性压力测试。

一、历史情景 vs 假设情景

1.1 两类情景的比较

维度历史情景假设情景
构建方式回放历史上发生过的事件基于经济逻辑和判断构建的"未发生过"的事件
优点客观、可验证、市场联动真实灵活、可覆盖未知风险、可针对特定脆弱性
缺点不一定重复、忽略结构性变化主观性强、难以验证合理性
典型例子2008金融危机、2020新冠崩盘、1997亚洲危机台海冲突、欧元解体、美国债务违约、大流行病变种

1.2 历史情景的因子映射

PYTHON158 行 · 6.0 KB
📄此处有展示代码158 行 · 6.0 KB展开 ▼
python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# 关键历史危机情景的定义
HISTORICAL_SCENARIOS = {
    '2008_Financial_Crisis': {
        'name': '2008 全球金融危机',
        'period': ('2008-09-01', '2009-03-09'),
        'description': '雷曼兄弟破产引发的系统性危机'
    },
    '2010_Flash_Crash': {
        'name': '2010 闪崩',
        'period': ('2010-05-06', '2010-05-06'),
        'description': '算法交易引发的盘中闪崩'
    },
    '2011_EU_Debt_Crisis': {
        'name': '2011 欧债危机',
        'period': ('2011-07-01', '2011-10-04'),
        'description': '希腊债务危机蔓延,意大利西班牙国债收益率飙升'
    },
    '2015_China_Crash': {
        'name': '2015 中国股灾',
        'period': ('2015-06-12', '2015-08-24'),
        'description': 'A股去杠杆引发的暴跌'
    },
    '2020_COVID': {
        'name': '2020 新冠崩盘',
        'period': ('2020-02-19', '2020-03-23'),
        'description': '新冠疫情全球蔓延引发的流动性危机'
    },
    '2022_Rate_Hike': {
        'name': '2022 加息冲击',
        'period': ('2022-01-01', '2022-06-30'),
        'description': '美联储激进加息,股债双杀'
    }
}


def historical_scenario_analysis(portfolio_holdings: dict,
                                  market_data: pd.DataFrame,
                                  scenarios: list = None) -> pd.DataFrame:
    """
    将当前持仓映射到历史危机情景中,计算如果历史重演的组合损失。

    参数:
        portfolio_holdings: {资产名称: 持仓市值}
        market_data: 历史市场数据(价格序列)
        scenarios: 要测试的情景列表
    返回:
        各情景下的组合损失
    """
    if scenarios is None:
        scenarios = list(HISTORICAL_SCENARIOS.keys())

    results = []

    for scenario_key in scenarios:
        scenario = HISTORICAL_SCENARIOS[scenario_key]
        start, end = scenario['period']

        # 提取该期间的资产收益
        scenario_returns = market_data.loc[start:end]

        if len(scenario_returns) < 2:
            continue

        # 累计收益
        cumulative_returns = (scenario_returns.iloc[-1] /
                              scenario_returns.iloc[0] - 1)

        # 计算组合损失
        portfolio_loss = 0
        asset_contributions = {}
        for asset, position in portfolio_holdings.items():
            if asset in cumulative_returns.index:
                asset_loss = position * cumulative_returns[asset]
                portfolio_loss += asset_loss
                asset_contributions[asset] = asset_loss

        total_portfolio_value = sum(portfolio_holdings.values())

        results.append({
            'Scenario': scenario['name'],
            'Period': f"{start} to {end}",
            'Portfolio_Loss': portfolio_loss,
            'Loss_Pct': portfolio_loss / total_portfolio_value * 100,
            'Worst_Asset': max(asset_contributions,
                               key=lambda x: abs(asset_contributions[x])),
            'Asset_Contributions': asset_contributions
        })

    return pd.DataFrame(results).sort_values('Loss_Pct')


# ===== 演示:用合成的历史价格序列跑一遍情景映射 =====
# 真实使用时把 market_data 换成 Wind/Tushare/yfinance 拉下来的真实收盘价面板
np.random.seed(42)
dates = pd.date_range('2008-01-01', '2022-12-31', freq='B')
assets = ['A_Share_Equity', 'US_Equity', 'Gold', 'Treasury_Bond', 'Credit_Bond']

# 每个资产的正常期漂移与波动(年化)
params = {
    'A_Share_Equity': (0.06, 0.25),
    'US_Equity':      (0.08, 0.18),
    'Gold':           (0.04, 0.15),
    'Treasury_Bond':  (0.03, 0.05),
    'Credit_Bond':    (0.04, 0.08),
}
# 危机期的日度额外冲击(体现"股债金"在不同危机中的分化)
crisis_shock = {
    ('2008-09-01', '2009-03-09'): {'A_Share_Equity': -0.0035, 'US_Equity': -0.0050,
                                   'Gold': 0.0004, 'Treasury_Bond': 0.0006, 'Credit_Bond': -0.0018},
    ('2015-06-12', '2015-08-24'): {'A_Share_Equity': -0.0090, 'US_Equity': -0.0012,
                                   'Gold': 0.0002, 'Treasury_Bond': 0.0004, 'Credit_Bond': -0.0006},
    ('2020-02-19', '2020-03-23'): {'A_Share_Equity': -0.0040, 'US_Equity': -0.0090,
                                   'Gold': -0.0010, 'Treasury_Bond': 0.0010, 'Credit_Bond': -0.0030},
    ('2022-01-01', '2022-06-30'): {'A_Share_Equity': -0.0012, 'US_Equity': -0.0018,
                                   'Gold': -0.0002, 'Treasury_Bond': -0.0008, 'Credit_Bond': -0.0010},
}

price_data = {}
for asset in assets:
    mu, vol = params[asset]
    daily = np.random.normal(mu / 252, vol / np.sqrt(252), len(dates))
    ret = pd.Series(daily, index=dates)
    for (s, e), shocks in crisis_shock.items():
        mask = (ret.index >= s) & (ret.index <= e)
        ret[mask] += shocks[asset]
    price_data[asset] = 100 * (1 + ret).cumprod()
market_data = pd.DataFrame(price_data)

holdings = {
    'A_Share_Equity': 40_000_000,
    'US_Equity':      25_000_000,
    'Gold':           10_000_000,
    'Treasury_Bond':  15_000_000,
    'Credit_Bond':    10_000_000,
}

stress = historical_scenario_analysis(holdings, market_data)

print(f"组合总市值: {sum(holdings.values()):,.0f}\n")
print("历史情景重演下的组合损益(按损失从大到小):")
print("-" * 76)
print(f"{'情景':<18}{'区间':<26}{'损益(万元)':>14}{'损益%':>9}{'最大贡献资产':>16}")
print("-" * 76)
for _, row in stress.iterrows():
    print(f"{row['Scenario']:<18}{row['Period']:<26}"
          f"{row['Portfolio_Loss'] / 1e4:>13,.0f}{row['Loss_Pct']:>9.2f}{row['Worst_Asset']:>18}")

worst = stress.iloc[0]
print(f"\n最坏情景: {worst['Scenario']},损失 {abs(worst['Portfolio_Loss']) / 1e4:,.0f} 万元"
      f"({worst['Loss_Pct']:.2f}%)")
print("各资产贡献拆解:")
for asset, contrib in sorted(worst['Asset_Contributions'].items(), key=lambda kv: kv[1]):
    print(f"  {asset:<18}{contrib / 1e4:>12,.0f} 万元")
print("\n注:2010 闪崩只有单日数据(len<2),函数按设计自动跳过。")
点击展开可浏览运行结果
组合总市值: 100,000,000 元

历史情景重演下的组合损益(按损失从大到小):
----------------------------------------------------------------------------
情景                区间                                损益(万元)      损益%          最大贡献资产
----------------------------------------------------------------------------
2008 全球金融危机       2008-09-01 to 2009-03-09         -2,560   -25.60         US_Equity
2015 中国股灾         2015-06-12 to 2015-08-24         -1,498   -14.98    A_Share_Equity
2022 加息冲击         2022-01-01 to 2022-06-30         -1,373   -13.73         US_Equity
2020 新冠崩盘         2020-02-19 to 2020-03-23           -943    -9.43         US_Equity
2011 欧债危机         2011-07-01 to 2011-10-04              4     0.04    A_Share_Equity

最坏情景: 2008 全球金融危机,损失 2,560 万元(-25.60%)
各资产贡献拆解:
  US_Equity               -1,401 万元
  A_Share_Equity          -1,124 万元
  Credit_Bond               -129 万元
  Gold                       -70 万元
  Treasury_Bond              163 万元

注:2010 闪崩只有单日数据(len<2),函数按设计自动跳过。

二、系统性压力测试框架

2.1 CCAR/DFAST 框架

美联储的综合资本分析与审查(CCAR)和多德-弗兰克法案压力测试(DFAST)是全球最严格和系统的压力测试框架。其核心流程包括:

  1. 情景设计:美联储提供基准、不利和严重不利三种宏观经济情景
  2. 损失估计:9个季度前瞻性的贷款损失、交易损失、对手方损失
  3. 收入预测:PPNR(拨备前净收入)的预测
  4. 资本充足率:CET1、Tier1、总资本充足率在压力下的演变

2.2 简化版交易账户压力测试

PYTHON53 行 · 1.9 KB
📄此处有展示代码53 行 · 1.9 KB展开 ▼
python
def trading_book_stress_test(positions: dict,
                              risk_factors: dict,
                              stress_shocks: pd.DataFrame,
                              pricing_functions: dict = None) -> dict:
    """
    交易账户的系统性压力测试。

    参数:
        positions: {头寸ID: {risk_factors: sensitivity}}
            sensitivity是头寸对各风险因子的敏感性(PV01, delta, vega等)
        risk_factors: {因子名: 当前值}
        stress_shocks: (N_scenarios, M_factors) 各风险因子在各情景下的冲击
        pricing_functions: {头寸类型: 定价函数} 可选,用于非线性头寸
    返回:
        各情景下的P&L
    """
    results = []

    for scenario_name, shocks in stress_shocks.iterrows():
        scenario_pnl = 0
        position_pnls = {}

        for position_id, sensitivities in positions.items():
            position_pnl = 0

            for factor, sensitivity in sensitivities.items():
                if factor in shocks:
                    shock = shocks[factor]

                    # 线性近似
                    linear_pnl = sensitivity * shock * risk_factors.get(
                        factor, 1.0
                    )

                    # 如果有定价函数,加入非线性项(gamma等)
                    if pricing_functions and position_id in pricing_functions:
                        nonlinear_pnl = pricing_functions[position_id](
                            risk_factors, shocks
                        )
                        position_pnl += nonlinear_pnl
                    else:
                        position_pnl += linear_pnl

            scenario_pnl += position_pnl
            position_pnls[position_id] = position_pnl

        results.append({
            'Scenario': scenario_name,
            'Total_PnL': scenario_pnl,
            'Position_PnLs': position_pnls
        })

    return results
点击展开可浏览运行结果
📘 本段代码定义了 1 个函数/类:函数 `trading_book_stress_test`(交易账户的系统性压力测试)。该片段为教学展示(未包含独立运行的输入数据),可在实战练习中结合真实数据调用。

三、反事实情景生成

3.1 反事实情景的定义

反事实情景(Counterfactual Scenarios)回答"如果...会怎样"的问题。与历史情景不同,反事实情景是可能发生但尚未发生的事件组合。

生成反事实情景的方法:

  1. 专家判断法:由投资委员会、风险委员会定义特定的极端事件组合
  2. 统计生成法:基于历史数据的极端分位数组合(如所有资产的 99% VaR 同时触发)
  3. 逆向压力测试:先设定一个不可接受的损失水平,然后找出导致这种损失的情景组合

3.2 逆向压力测试

PYTHON47 行 · 1.6 KB
📄此处有展示代码47 行 · 1.6 KB展开 ▼
python
def reverse_stress_test(positions_sensitivities: np.ndarray,
                         max_acceptable_loss: float,
                         factor_bounds: list,
                         n_search: int = 10000) -> dict:
    """
    逆向压力测试:找出导致给定损失的最小因子冲击组合。

    优化问题:min ||Δf||_2, s.t. P&L(Δf) ≤ -max_acceptable_loss

    参数:
        positions_sensitivities: (M,) 对各风险因子的敏感度向量
        max_acceptable_loss: 最大可接受损失(正值)
        factor_bounds: [(min, max), ...] 各因子的合理变动范围
        n_search: 搜索次数
    返回:
        最小范数冲击组合及其 P&L
    """
    M = len(positions_sensitivities)

    best_shock = None
    best_norm = np.inf

    for _ in range(n_search):
        # 随机生成因子冲击(在各因子的边界内均匀采样)
        shocks = np.array([
            np.random.uniform(low, high) for low, high in factor_bounds
        ])

        # 计算 P&L
        pnl = np.dot(positions_sensitivities, shocks)

        if pnl <= -max_acceptable_loss:
            norm = np.linalg.norm(shocks, 2)
            if norm < best_norm:
                best_norm = norm
                best_shock = shocks.copy()

    if best_shock is None:
        return {'found': False, 'message': '在搜索范围内未找到符合条件的冲击组合'}

    return {
        'found': True,
        'shocks': best_shock,
        'shock_norm': best_norm,
        'resulting_pnl': np.dot(positions_sensitivities, best_shock),
        'normalized_shocks': best_shock / np.linalg.norm(best_shock)
    }
点击展开可浏览运行结果
📘 本段代码定义了 1 个函数/类:函数 `reverse_stress_test`(逆向压力测试:找出导致给定损失的最小因子冲击组合)。该片段为教学展示(未包含独立运行的输入数据),可在实战练习中结合真实数据调用。

四、多资产联动压力测试

4.1 相关性破裂的情景设计

在极端压力下,资产间的相关性往往趋向于 1 或 -1(相关性破裂)。多资产压力测试需要模拟这种"一切同时崩坏"的情景:

PYTHON84 行 · 2.7 KB
📄此处有展示代码84 行 · 2.7 KB展开 ▼
python
def correlation_breakdown_stress(correlation_matrix: np.ndarray,
                                  volatilities: np.ndarray,
                                  crisis_multiplier: float = 3.0) -> np.ndarray:
    """
    生成相关性破裂的极端压力协方差矩阵。

    在极端压力下,所有相关性向 ±1 收缩,
    同时波动率放大。

    参数:
        correlation_matrix: 正常时期的相关系数矩阵
        volatilities: 正常时期的波动率
        crisis_multiplier: 波动率放大倍数
    返回:
        极端压力下的协方差矩阵
    """
    n = len(volatilities)

    # 相关性破裂:向 +1 收缩
    crisis_corr = np.ones((n, n))
    for i in range(n):
        for j in range(n):
            if i != j:
                # 相关性向 1 收缩 80%
                crisis_corr[i, j] = correlation_matrix[i, j] * 0.2 + 0.8

    # 波动率放大
    crisis_vol = volatilities * crisis_multiplier

    # 协方差矩阵
    D = np.diag(crisis_vol)
    crisis_cov = D @ crisis_corr @ D

    return crisis_cov


def multi_asset_stress_pnl(positions: np.ndarray,
                            normal_cov: np.ndarray,
                            confidence: float = 0.999,
                            n_simulations: int = 10000) -> dict:
    """
    多资产联动压力测试:模拟极端市场条件下的P&L分布。

    使用 Copula 或 t-Copula 捕捉极端事件间的尾部依赖。

    参数:
        positions: 各头寸的市场价值
        normal_cov: 正常时期的协方差矩阵
        confidence: 极端情景的置信水平
        n_simulations: 蒙特卡洛模拟次数
    返回:
        压力测试结果
    """
    n_assets = len(positions)

    # 使用 t-Copula 生成尾部相依的随机冲击
    df_t = 3  # 自由度越低,尾部相依性越强
    vol = np.sqrt(np.diag(normal_cov))
    corr = normal_cov / np.outer(vol, vol)

    # t-Copula 模拟
    np.random.seed(42)
    z = np.random.randn(n_simulations, n_assets)
    chi = np.random.chisquare(df_t, n_simulations) / df_t
    t_samples = z / np.sqrt(chi[:, np.newaxis])  # 多元 t 分布

    # 映射到收益率
    L = np.linalg.cholesky(corr)
    correlated_t = t_samples @ L.T

    # 极端情景:取 t 分布的极端分位数
    extreme_returns = np.percentile(correlated_t, (1 - confidence) * 100, axis=0)

    # P&L
    pnl_vector = positions * extreme_returns * vol
    total_pnl = np.sum(pnl_vector)

    return {
        'total_pnl_impact': total_pnl,
        'asset_level_impacts': dict(zip(
            [f'Asset_{i}' for i in range(n_assets)], pnl_vector
        )),
        'pct_of_portfolio': total_pnl / np.sum(np.abs(positions)) * 100
    }
点击展开可浏览运行结果
📘 本段代码定义了 2 个函数/类:函数 `correlation_breakdown_stress`(生成相关性破裂的极端压力协方差矩阵)、函数 `multi_asset_stress_pnl`(多资产联动压力测试:模拟极端市场条件下的P&L分布)。该片段为教学展示(未包含独立运行的输入数据),可在实战练习中结合真实数据调用。

压力测试是量化风险管理中连接模型与现实的关键桥梁。它不仅帮助我们量化极端事件的冲击,也为满足监管资本要求(巴塞尔协议)提供了方法论基础。