34. 从相关性到 GMVP:样本外组合风险验证

本章概要

  • 学习材料:训练/验证收益矩阵、等权基线和GMVP约束。
  • 本章任务:在本章示例中估计训练协方差、GMVP,并评估验证/压力期方差与60/120日敏感性。
  • 完成后你将得到:两组权重、样本外方差差、最大权重和暂不采用模型表。
  • 自我检查:检查权重和为1并用等权同一时间段对照;权重超70%或样本外不改进时先检查原因。
  • 拓展练习:把同一模型采用条件拓展应用到另一组三资产。

第21章无提示复习题(3题)

  1. 为什么必须用同一时间段的收益而不是价格计算资产相关?
  2. 相关系数为 −0.4 时,还需哪三类依据才能讨论对冲?
  3. 常量列或无有效配对观测时,相关矩阵对角线为何可能是 NaN

三题全对者直接进入协方差与 GMVP;未通过者回看第21章“收益率—有效样本—滚动/压力期”指定页后再答。本章不重复把相关定义作为新能力考核。

相关性复习|只作第21章检索

本章不再教授相关系数定义、热力图解读或价格转收益步骤。请用第21章能力回答三题复习题;下方受保护平台块只保留为 API 对照,不计入本章核心内容。

  • 已知输入:同一时间段的收益矩阵与有效样本数;价格相关不得进入优化。
  • 本章新增:由训练期协方差求等权与 GMVP,随后进入独立验证期。
  • 需要重新检查的情况:常量列、有效观测不足、协方差不可用、权重过度集中或验证期方差不优于等权。

运行前预测|平台任务:获取指数数据并绘制热力图

  • 输入预测:运行前先写出 proshszcyb 的业务含义、数据类型或取值范围,并判断哪一个输入最可能改变结果。
  • 结果预测:不展开答案,先预测将得到cmap 的结果;同时写出方向、数量级或表格/图形结构。
  • 完成要求:能独立说明本任务从输入到“平台任务:获取指数数据并绘制热力图”结果的关键步骤,原样录入平台代码并得到可核对的运行结果。

⭐ 平台任务:获取指数数据并绘制热力图

任务复盘|平台任务:获取指数数据并绘制热力图

使用提醒:平台固定答案中含有类似访问密钥的文字,只能在指定教学平台内按要求输入,不要复制到公开网页或其他程序中。后文使用课程本地数据完成分析,不需要这类访问密钥。

拓展练习:把数据替换为一个中国企业或市场序列,改变一个分组或时间窗口;说明图形结论是否改变,以及为何。

平台对照的使用边界

平台块依赖外部 API 与固定 token,只用于识别字段和热力图接口;无可访问输出时不得报告指数相关数值。完成本章依据从下一页的等权—GMVP训练/验证过程开始。

马科维茨投资组合理论

投资组合方差的核心公式:

\[ \large \sigma_p^2 = \mathbf{w}^T \mathbf{\Sigma} \mathbf{w} = \sum_{i=1}^{n}\sum_{j=1}^{n} w_i w_j \sigma_i \sigma_j \rho_{ij} \]

  • \(\mathbf{w}\):权重向量(各资产配置比例)
  • \(\mathbf{\Sigma}\):协方差矩阵
  • \(\rho_{ij}\):资产 \(i\)\(j\) 的相关系数

分散化效应的关键洞见

  • 对两项同口径收益、非负权重且权重和为 1、波动率有限时,\(\rho<1\) 且两项权重/波动率均非零,组合波动率才严格低于波动率加权平均。
  • 在上述 long-only 条件与固定权重下,相关系数越低,组合方差越低;这不是收益改善或样本外稳定性的保证。
  • \(\rho=-1\) 时,只有权重满足 \(w_1\sigma_1=w_2\sigma_2\) 才能把两资产组合方差降为零;任意权重不能完全消除风险。

约束边界:后文闭式 GMVP 只施加“权重和为 1”,允许负权重,属于无约束做空扩展;它与本页 long-only 分散化条件不同。若任务禁止做空,须另用非负约束求解并单独验证。

不同相关性场景下的组合风险对比

Listing 1: 不同相关性下的投资组合风险
展开完整参考实现
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 使用课程规定且已安装的思源宋体显示中文
plt.rcParams['axes.unicode_minus'] = False  # 保证负号正常显示

# ==================== 三种相关性场景 ====================
scenarios = [
    {'name': '低相关(ρ=0.3)', 'corr': 0.3},
    {'name': '中度相关(ρ=0.7)', 'corr': 0.7},
    {'name': '高相关(ρ=0.95)', 'corr': 0.95}
]

# ==================== 资产参数 ====================
sigma1 = 0.20  # 资产1年化波动率20%
sigma2 = 0.25  # 资产2年化波动率25%
w1 = 0.5       # 等权配置
w2 = 0.5

# ==================== 计算组合风险(结果由下图呈现) ====================
results = []  # 收集各场景结果
展开完整参考实现
for scenario in scenarios:  # 逐场景计算
    rho = scenario['corr']  # 相关系数
    portfolio_var = (w1**2) * (sigma1**2) + (w2**2) * (sigma2**2) + 2 * w1 * w2 * sigma1 * sigma2 * rho  # 组合方差
    portfolio_std = np.sqrt(portfolio_var)  # 组合波动率
    weighted_avg_std = w1 * sigma1 + w2 * sigma2  # 加权平均波动率
    risk_reduction = (weighted_avg_std - portfolio_std) / weighted_avg_std  # 风险降低幅度
    results.append({'场景': scenario['name'], '相关系数': rho, '组合波动率': portfolio_std, '加权平均波动率': weighted_avg_std, '风险降低': risk_reduction})  # 记录结果
print('风险降低:' + ','.join(f"{r['场景']} {r['风险降低']:.1%}" for r in results))  # 一行结论输出

# 将结果留给下一发图单元,避免跨单元复用已关闭的 Figure
展开完整参考实现
fig, ax = plt.subplots(figsize=(7.9, 3.6))  # 在实际发图单元创建画布与坐标轴
scenarios_names = [row['场景'] for row in results]  # 提取三种相关性情景标签
portfolio_stds = [row['组合波动率'] for row in results]  # 提取考虑协方差的波动率
weighted_stds = [row['加权平均波动率'] for row in results]  # 提取不考虑相关性的对照波动率
x = np.arange(len(scenarios_names))  # 生成情景横轴位置
width = 0.35  # 设置组内柱宽
bars1 = ax.bar(x - width/2, weighted_stds, width, label='加权平均波动率', color='coral', alpha=0.7)  # 绘制未考虑相关性的对照柱
bars2 = ax.bar(x + width/2, portfolio_stds, width, label='组合波动率', color='steelblue', alpha=0.7)  # 绘制组合波动率柱
ax.set_title('相关性对投资组合风险的影响', fontsize=18,pad=18)  # 增加标题内边距并与柱顶标签分离
ax.set_ylabel('年化波动率', fontsize=16)  # 标注风险量纲并满足投影字号
ax.set_xticks(x)  # 固定情景刻度位置
ax.set_xticklabels(scenarios_names,fontsize=16)  # 显示相关性情景名称并满足投影字号
ax.tick_params(axis='y',labelsize=16)  # 保证纵轴刻度投影可读
ax.legend(fontsize=16)  # 显示两类风险口径并满足投影字号
ax.grid(axis='y', alpha=0.3)  # 添加风险比较网格
ax.set_ylim(0,max(weighted_stds)*1.35)  # 为柱顶数值与图内标题保留明确垂直间距
for bars in [bars1, bars2]:  # 逐组处理柱形标签
    for bar in bars:  # 逐柱提取高度
        height = bar.get_height()  # 读取柱形风险值
        ax.text(bar.get_x() + bar.get_width()/2., height,
                f'{height:.2%}', ha='center', va='bottom', fontsize=16,clip_on=False)  # 在柱顶标出百分比且不侵入标题区
plt.tight_layout()  # 收紧图形布局
plt.show()  # 在当前单元发出完整图形
分组柱状图对比低、中、高相关情景下的组合波动率与加权平均波动率
Figure 1: 三种相关性情景下的组合波动率对比

组合风险对比图解读

  • 珊瑚色柱:不考虑相关性的加权平均波动率(恒为 22.5%)
  • 钢蓝色柱:考虑相关性后的实际组合波动率
  • 在本页固定非负权重、同口径波动率与相关情景下,两者差距越大表示样本内方差降低更多;不外推为样本外收益或风险保证
场景 风险降低幅度
低相关 (\(\rho=0.3\)) 约 19%
中度相关 (\(\rho=0.7\)) 约 8%
高相关 (\(\rho=0.95\)) 约 1%

本章分析 1|真实快照与训练/验证切分

Listing 2
from pathlib import Path  # 导入路径工具以定位课程数据快照
import numpy as np  # 导入矩阵运算工具以求解组合权重
import pandas as pd  # 导入表格工具以对齐同一时间段的收益
stock_path=Path('/home/ubuntu/r2_data_mount/data/stock/stock_price_post_adjusted.parquet')  # 指定可追溯的复权收盘价快照
if not stock_path.exists(): raise FileNotFoundError('未找到课程数据文件,请从课程数据下载入口获取并核对文件位置')  # 缺少规定输入时停止计算
asset_codes=['600104.XSHG','600009.XSHG','600018.XSHG']  # 固定三个上海市场资产及其顺序
price_rows=pd.read_parquet(stock_path,columns=['order_book_id','date','close'])  # 按实际字段结构读取证券、日期与收盘价
price_rows=price_rows.loc[price_rows['order_book_id'].isin(asset_codes)].copy()  # 仅保留事先设定资产样本
price_panel=price_rows.pivot(index='date',columns='order_book_id',values='close').sort_index()  # 把长表转为同日价格面板
return_panel=price_panel.pct_change(fill_method=None).dropna(how='any')  # 从同一时间段的价格计算不填零收益
split_row=int(len(return_panel)*.70)  # 事先锁定前70%作为训练期
train_returns=return_panel.iloc[:split_row].copy()  # 仅用较早样本估计协方差
validation_returns=return_panel.iloc[split_row:].copy()  # 保留较晚样本做独立验证
if len(train_returns)<120 or len(validation_returns)<20: raise ValueError('输入文件、字段、样本量或数值不符合当前分析要求,请按本页说明检查')  # 样本不足时终止
print({'train':train_returns.shape,'validation':validation_returns.shape,'cutoff':train_returns.index.max()})  # 输出切分依据
{'train': (3264, 3), 'validation': (1400, 3), 'cutoff': Timestamp('2020-03-26 00:00:00')}

本章分析 2|约束、GMVP权重与等权基线

Listing 3
training_covariance=train_returns.cov().to_numpy()*252  # 仅用训练期收益估计年化协方差
inverse_covariance=np.linalg.pinv(training_covariance)  # 用伪逆降低近共线矩阵的求解风险
constraint_vector=np.ones(len(asset_codes))  # 构造权重和为1的约束向量
gmvp_weights=inverse_covariance@constraint_vector/(constraint_vector@inverse_covariance@constraint_vector)  # 求解全局最小方差权重
equal_weights=np.repeat(1/len(asset_codes),len(asset_codes))  # 构造不估计权重的等权基线
assert np.isclose(gmvp_weights.sum(),1) and np.isclose(equal_weights.sum(),1)  # 独立核验两组权重和都为1
weight_table=pd.DataFrame({'asset':asset_codes,'gmvp':gmvp_weights,'equal':equal_weights})  # 形成可检查的权重对照表
print(weight_table)  # 输出资产顺序与两组权重
         asset      gmvp     equal
0  600104.XSHG  0.420469  0.333333
1  600009.XSHG  0.308881  0.333333
2  600018.XSHG  0.270651  0.333333

本章分析 3|样本外方差与压力日

Listing 4
gmvp_validation=validation_returns.mul(gmvp_weights,axis=1).sum(axis=1)  # 合成GMVP验证期收益
equal_validation=validation_returns.mul(equal_weights,axis=1).sum(axis=1)  # 合成等权验证期收益
gmvp_variance=float(gmvp_validation.var()*252)  # 计算GMVP样本外年化方差
equal_variance=float(equal_validation.var()*252)  # 计算等权样本外年化方差
stress_days=equal_validation.abs().ge(equal_validation.abs().quantile(.90))  # 以等权绝对收益前10%锁定压力日
gmvp_stress_variance=float(gmvp_validation.loc[stress_days].var()*252)  # 计算GMVP在同一压力日集合的方差
validation_result={'gmvp_variance':gmvp_variance,'equal_variance':equal_variance,'difference':gmvp_variance-equal_variance,'gmvp_stress_variance':gmvp_stress_variance}  # 汇总基线、差值与压力结果
print(validation_result)  # 输出样本外比较依据
{'gmvp_variance': 0.04549562254078787, 'equal_variance': 0.044059500535239424, 'difference': 0.0014361220055484486, 'gmvp_stress_variance': 0.2712683277172948}

本章分析 4|比较 60 日与 120 日结果

Listing 5
window_records=[]  # 初始化窗口敏感性记录
for estimation_days in [60,120]:  # 按事先设定的两个估计窗口重算
    window_covariance=train_returns.tail(estimation_days).cov().to_numpy()*252  # 仅使用训练尾窗估计协方差
    window_inverse=np.linalg.pinv(window_covariance)  # 求取当前窗口协方差伪逆
    window_weights=window_inverse@constraint_vector/(constraint_vector@window_inverse@constraint_vector)  # 求解当前窗口GMVP权重
    window_validation=validation_returns.mul(window_weights,axis=1).sum(axis=1)  # 在同一独立验证期应用权重
    window_records.append({'window_days':estimation_days,'max_abs_weight':float(np.abs(window_weights).max()),'validation_variance':float(window_validation.var()*252),'stress_variance':float(window_validation.loc[stress_days].var()*252)})  # 保存集中度与两类方差
window_sensitivity=pd.DataFrame(window_records)  # 组装窗口敏感性对照表
model_stop=bool(window_sensitivity['max_abs_weight'].gt(.70).any() or window_sensitivity['validation_variance'].ge(equal_variance).any())  # 执行集中度和样本外基线双检查要求
model_action={'status':'暂不采用模型' if model_stop else '仅作课堂比较','owner':'组合风险负责人','review':'下一月末'}  # 绑定状态、负责人员与再次检查日
print(window_sensitivity,model_action)  # 输出窗口结果与处理建议
   window_days  max_abs_weight  validation_variance  stress_variance
0           60        0.785972             0.065424         0.332086
1          120        0.667210             0.052083         0.285405 {'status': '暂不采用模型', 'owner': '组合风险负责人', 'review': '下一月末'}

合格结论只能来自后30%验证期;训练期最优不能替代样本外依据。

本章核心总结

概念 关键要点
等权基线 不估计权重,作为样本外方差比较基准
GMVP 仅用训练期协方差求和为1的最小方差权重
独立验证 在后续时间窗比较GMVP与等权年化方差
压力核对 在等权组合绝对收益前10%的日期重算风险
窗口敏感性 用60/120日训练窗重估权重并比较验证期结果
暂不采用 权重过度集中、验证方差不优于等权或所用字段改变

随堂练习

  • 问题 1|基础结果是什么?:在相同训练/验证切分下写出等权与GMVP权重、验证期年化方差和两者差值;检查两组权重和均为1。
  • 问题 2|改变条件后,结果是否稳定?:用训练期末60日和120日分别估计GMVP,写出最大绝对权重、验证期方差及压力期方差;不得用训练期最优代替样本外依据。
  • 问题 3|哪些情况需要重新检查?:如果任一最小方差组合权重的绝对值超过 70%、验证期方差不低于等权组合、观测不足或字段发生变化,应先检查模型设置和数据,再决定是否采用结果。
  • 作答提示:请完成三道题,并在回答中引用实际运行结果;拓展练习中不要把课堂示例写成普遍规律或因果结论。

教师参考解答

教师参考解答|答案与说明 1

  • 所用数据与字段:规定资产 stock/stock_price_post_adjusted.parquet;长三角样本 600104.XSHG/600009.XSHG/600018.XSHG;字段 order_book_id/date/close
  • 解答示例|同一时间段的收益与时间切分(平台保护块之外):

教师参考解答|代码 1

展开代码(代码区可独立滚动)
from pathlib import Path  # 导入路径工具
import numpy as np  # 导入矩阵计算库
import pandas as pd  # 导入表格库
path=Path('/home/ubuntu/r2_data_mount/data/stock/stock_price_post_adjusted.parquet')  # 指定复权价格
if not path.exists(): raise FileNotFoundError('未找到课程数据文件,请从课程数据下载入口获取并核对文件位置')  # 缺失即停止
selected_codes=['600104.XSHG','600009.XSHG','600018.XSHG']  # 选择上海代表性非金融公司
price_rows=pd.read_parquet(path,columns=['order_book_id','date','close'])  # 按实际字段结构读取复权价格字段
price_rows=price_rows.loc[price_rows['order_book_id'].isin(selected_codes)].copy()  # 筛选事先设定公司集合
price_rows['date']=pd.to_datetime(price_rows['date'])  # 统一交易日期
price_panel=price_rows.pivot(index='date',columns='order_book_id',values='close').sort_index()  # 按共同交易日对齐
return_panel=price_panel.pct_change(fill_method=None).dropna(how='any')  # 计算同一时间段简单收益率
split_index=int(len(return_panel)*.70)  # 固定前70%为训练期
train_returns=return_panel.iloc[:split_index].copy()  # 只用早期同一时间段的收益估计权重
validation_returns=return_panel.iloc[split_index:].copy()  # 用后续同一时间段的收益独立验证
if len(train_returns)<120 or len(validation_returns)<20: raise ValueError('输入文件、字段、样本量或数值不符合当前分析要求,请按本页说明检查')  # 样本不足即停止
print({'all':return_panel.shape,'train':train_returns.shape,'validation':validation_returns.shape,'cutoff':train_returns.index.max()})  # 输出切分依据

教师参考解答|答案与说明 2

  • 比较答案|训练期GMVP与等权验证

教师参考解答|代码 2

展开代码(代码区可独立滚动)
covariance_matrix=train_returns.cov()*252  # 仅用训练期估计年化协方差
inverse_covariance=np.linalg.pinv(covariance_matrix.to_numpy())  # 用伪逆处理近共线情形
unit_vector=np.ones(len(selected_codes))  # 构造全1约束向量
gmvp_weights=inverse_covariance@unit_vector/(unit_vector@inverse_covariance@unit_vector)  # 计算最小方差权重
equal_weights=np.repeat(1/len(selected_codes),len(selected_codes))  # 构造等权基线
gmvp_validation=validation_returns.mul(gmvp_weights,axis=1).sum(axis=1)  # 生成GMVP验证期收益
equal_validation=validation_returns.mul(equal_weights,axis=1).sum(axis=1)  # 生成等权验证期收益
gmvp_validation_variance=float(gmvp_validation.var()*252)  # 计算GMVP样本外年化方差
equal_validation_variance=float(equal_validation.var()*252)  # 计算等权样本外年化方差
stress_mask=equal_validation.abs().ge(equal_validation.abs().quantile(.90))  # 以等权绝对收益前10%定义压力日
gmvp_stress_variance=float(gmvp_validation.loc[stress_mask].var()*252)  # 计算GMVP压力期方差
assert np.isclose(gmvp_weights.sum(),1) and np.isclose(equal_weights.sum(),1)  # 核对两组权重和
print(pd.DataFrame({'gmvp':gmvp_weights,'equal':equal_weights},index=selected_codes),{'gmvp_validation_variance':gmvp_validation_variance,'equal_validation_variance':equal_validation_variance,'gmvp_stress_variance':gmvp_stress_variance})  # 输出样本外比较

教师参考解答|答案与说明 3

  • 拓展应用答案|窗口敏感性与需要重新检查的情况

教师参考解答|代码 3

展开代码(代码区可独立滚动)
window_rows=[]  # 初始化窗口敏感性结果
for estimation_window in [60,120]:  # 分别用60日与120日训练尾窗
    window_covariance=train_returns.tail(estimation_window).cov().to_numpy()*252  # 重估窗口协方差
    window_inverse=np.linalg.pinv(window_covariance)  # 求协方差伪逆
    window_weights=window_inverse@unit_vector/(unit_vector@window_inverse@unit_vector)  # 求窗口GMVP权重
    window_validation=validation_returns.mul(window_weights,axis=1).sum(axis=1)  # 生成同一验证期收益
    window_rows.append({'window':estimation_window,'max_abs_weight':float(np.abs(window_weights).max()),'validation_variance':float(window_validation.var()*252),'stress_variance':float(window_validation.loc[stress_mask].var()*252)})  # 保存集中度与两类方差
window_sensitivity=pd.DataFrame(window_rows)  # 形成两窗口比较表
stop_model=bool(window_sensitivity['max_abs_weight'].gt(.70).any() or window_sensitivity['validation_variance'].ge(equal_validation_variance).any())  # 执行集中度与基线双检查要求
governance={'status':'暂不采用模型' if stop_model else '仅作课堂比较','owner':'组合风险负责人','review':'下一月末'}  # 绑定状态、负责人员与再次检查日
print(window_sensitivity,governance)  # 输出敏感性与停止动作

教师参考解答|答案与说明 4

  • 解释答案:GMVP 只在训练期协方差给定时最小化估计方差;如果样本外结果不优于等权组合,或权重过度集中,说明估计误差可能已经超过优化带来的好处,此时暂不采用该模型。
  • 参考结果:训练期和验证期的样本量与截止日、等权组合与最小方差组合的权重、验证期和压力期方差,以及 60 日、120 日窗口的集中度比较。所需股票行情可从课程数据下载入口取得。
  • 边界 / 局限:相关时变;GMVP 对估计误差敏感
  • 常见错误:价格相关;缺失日填0;资产顺序错位