Python实战:相关性矩阵与组合波动率计算
做量化交易或者资产配置的人,基本都会遇到同一个问题:手里拿了五六只甚至十几只股票,想知道它们到底是不是在同一个节奏上波动,以及如果按某个权重买入,整个组合的风险到底有多大。直接看单只股票的波动率根本不够,因为股票之间会联动——两只股票各自看着都还行,放在一起却可能因为高度相关而放大风险。这个时候,相关性矩阵和组合波动率就是两个绕不开的工具。
这篇东西我打算用一个可以直接复现的 Python 项目把整个计算流程串起来,从数据获取、预处理,到相关性矩阵的估计,再到用线性代数和蒙特卡洛两种方式算组合波动率,最后再聊聊实际操作中比教科书更麻烦的几个细节。适合刚入门量化分析、或者已经在用 Excel 做组合但想转到 Python 的人参考。
1. 为什么“相关性矩阵”和“组合波动率”要放在一起算
1.1 相关性矩阵:一眼看穿资产之间的联动关系
相关性矩阵说白了就是一张表格,表格里第 i 行第 j 列的数字,表示第 i 只股票和第 j 只股票的收益率序列之间的相关系数,取值范围在 -1 到 1 之间。1 表示完全同涨同跌,-1 表示完全反向,0 表示没什么线性关系。
为什么不能只看 K 线图的走势来判断?因为两只股票可能都处于上升趋势,但上涨的节奏完全不同:一只提前一周启动,另一只滞后一周才跟涨。这种错位的联动在图上很难量化,但用相关系数一算就非常清楚。相关性矩阵的另一个好处是它可以一次性展示所有两两组合,5 只股票就是 5×5 的矩阵,20 只股票就是 20×20,肉眼扫一遍就能发现哪些资产放在一起是重复持仓。
我自己的习惯是用颜色把矩阵可视化出来,色块越红代表正相关越强,越蓝代表负相关越强,这样哪里存在高相关聚集区一眼就能定位。后面我会给出具体的 seaborn 绘图代码,一步到位。
1.2 组合波动率:为什么不能简单加权平均
很多刚接触组合分析的人会有一个直觉:组合的波动率等于各股票波动率的加权平均。这个直觉是错的,而且错得离谱。
组合方差的计算公式涉及协方差矩阵:
σ²_p = wᵀ Σ w
其中 w 是权重向量,Σ 是资产的协方差矩阵。展开来看,组合方差 = 所有资产方差的加权和 + 所有资产两两之间的协方差加权和。协方差项的存在,意味着即使你对单只股票的波动率控制得很好,如果资产之间高度正相关,组合的整体波动率仍然会居高不下。反过来说,如果找到相关性低甚至为负的资产,就能在不降低预期收益的情况下把波动率压下去——这就是资产配置里常说的“分散化是唯一的免费午餐”背后的数学原理。
1.3 这个项目的核心目标
整个项目可以拆解成四条链路:
- 数据链路:从公开数据源拉取多只股票的历史行情
- 预处理链路:把价格序列转成收益率序列,处理停牌、缺失值、不同交易日对齐
- 计算链路:估计协方差矩阵,归一化得到相关性矩阵,再算组合波动率
- 应用链路:给定一组权重直接算波动率,或者反过来用优化算法找到最小方差组合
这篇文章的实操部分会完整覆盖这四条链路,代码全部基于 Python 的 pandas、numpy 和 scipy,不需要额外安装复杂的量化框架。
2. 数据准备与预处理的三个关键细节
2.1 数据源选型和代码实现
行情数据我用过 tushare、baostock、akshare、yfinance 这几个库,各自特点不同。tushare 积分门槛对新手不太友好;baostock 免费但接口比较老;akshare 数据源杂、接口多,需要一定筛选能力。
我自己在个人项目里最常用的是 yfinance,主要是简单,不需要注册 token,代码三行就能拉数据:
这里用 auto_adjust=True 是为了拿到复权后的价格,避免分红拆股导致的价格跳变影响收益率计算。如果是 A 股数据,我会换成 akshare 的接口,逻辑完全一样,只是数据源不同。
2.2 用对数收益率还是简单收益率
计算收益率有两种方式:简单收益率 (P_t - P_{t-1}) / P_{t-1} 和对数收益率 ln(P_t / P_{t-1})。
短线分析我用简单收益率,因为直观;但做相关性分析和波动率建模我几乎总是用对数收益率。原因是两个:
- 对数收益率在数学上具有时间可加性,一段时间的总对数收益率是每日对数收益率的累加,这在计算多周期波动率时非常方便。
- 在实际数据中,对数收益率更接近正态分布,这对后续做蒙特卡洛模拟和风险价值(VaR)估算都有好处。
代码实现:
注意 dropna() 这一步不能省。第一行因为前一天没有数据,算出来一定是 NaN,如果不删掉,后面计算协方差矩阵时会产生一堆问题。
2.3 交易日对齐与缺失值处理——90% 的坑都在这里
多只股票放在一起计算,最核心的前提是它们在同一个日期上有价格数据。但现实情况是:美股和 A 股假日不同,A 股和港股节假日也常常错开。比如圣诞期间 A 股正常交易而港股休市,如果数据源返回的是自然日索引而不是交易日索引,就会在 DataFrame 里留下 NaN。
处理策略有三种:
- 直接
dropna():把任一股票缺失的日期整行删掉。简单干净,但如果数据源覆盖的股票很多,行数会急剧减少,浪费数据。 ffill()向前填充:用上一交易日的价格填补缺失值。适合处理停牌情况,但要注意停牌太久会失真。- 用
inner合并日期索引:只保留所有股票都有数据的交易日的并集。
我更推荐最后一种思路,配合 reindex() 把索引统一到共同交易日的并集上,然后按需填充。代码长这样:
如果某只股票停牌导致中间一大段都缺失,那基本没法救,要么剔除这只股票,要么用插值强行填充但心里要有数:结果只能参考,不能当精确结论。
3. 核心实现:从协方差矩阵到相关性矩阵再到组合波动率
3.1 相关性矩阵计算:教科书公式的直接实现
有了收益率序列,计算协方差矩阵和相关性矩阵就是几行代码的事:
pandas 的 corr() 默认计算 Pearson 相关系数,公式是:
ρ_ij = Cov(R_i, R_j) / (σ_i × σ_j)
如果想让计算过程透明一点,也可以手动算相关矩阵,先对收益率做标准化,再求标准化数据的协方差:
这个技巧在做滚动相关性窗口的时候特别有用,因为可以复用底层矩阵运算,效率高很多。
可视化代码:
annot=True 会在格子里标出数值,矩阵规模在 6×6 以内时建议打开,超过 10 只股票就别标了,全是数字根本看不清,用颜色深浅就够了。
3.2 组合波动率的解析解:权重向量 × 协方差矩阵 × 权重向量
假设手头有 5 只股票,权重向量为 w = [0.2, 0.2, 0.2, 0.2, 0.2],组合方差就是:
σ²_p = wᵀ Σ w
代码实现:
这里乘 sqrt(252) 是因为我们用的是日收益率数据,一年大约有 252 个交易日,日波动率年化要乘以交易日数量的平方根。为什么是开方而不是直接乘 252?因为方差具有时间可加性,日方差加起来就是年方差,所以年波动率 = sqrt(252 × 日方差) = sqrt(252) × 日波动率。
用 @ 符号做矩阵乘法是 numpy 的写法,等价于 np.dot(weights.T, np.dot(cov_matrix, weights)),但可读性更高。我自己在代码里更习惯用 @,一目了然。
3.3 最小方差组合:让权重自己从数据里“长”出来
给定一组权重能算波动率,但更实际的需求是:我不想凭感觉定权重,想让 Python 直接算出“波动率最小”的那组权重应该是什么。
这是一个带约束的优化问题:目标函数是最小化 wᵀ Σ w,约束条件是所有权重之和等于 1。scipy 的 minimize 函数可以直接解:
这里我给权重加了一个 0 到 1 的边界约束,也就是不允许做空。如果想允许做空,把 bounds 删掉即可。最小方差组合是 Markowitz 均值-方差框架里最基础的一个应用——不猜预期收益,只追求风险最低,实际操作中比“最大化夏普比率”要稳健得多,因为预期收益的估计误差通常远大于协方差矩阵的估计误差。
3.4 蒙特卡洛模拟:用随机权重试出可行域边界
除了解析解法,还可以用蒙特卡洛模拟生成大量随机权重组合,把这些组合的风险和收益画在散点图上,会得到一张经典的“可行域”图。每个点代表一个可能的投资组合,横轴是波动率,纵轴是期望收益,散点图的最左侧边界就是有效前沿。
模拟的次数我设了 5 万次,在个人电脑上一两秒就能跑完。为什么不用解析方法直接画有效前沿?因为模拟散点图能直观展示“不同权重下风险和收益的分布范围”,对理解组合分散化的本质特别有帮助——你会看到散点图是一团向左凸出的弧形,而不是一个圆形或者矩形区域,这就是分散化带来的“福利”。
4. 进阶内容:样本协方差的局限与更稳健的估计方法
4.1 为什么直接用样本相关系数会翻车
如果上面这套代码拿去做真实投资决策,你会发现一个尴尬的问题:用历史数据算出来的相关性矩阵并不稳定,往往过了一个季度,原本看起来不相关的两只股票突然变得高度相关,或者在市场大跌时几乎所有股票的相关性同时飙高。
这不是代码的问题,而是样本协方差估计本身的局限。样本协方差矩阵是用有限的历史数据估算的,当股票数量 N 相对样本天数 T 较大时,估计误差会很大——极端情况下 N > T,样本协方差矩阵甚至可能是奇异的,根本没法求逆,更别说做矩阵分解。
这就解释了为什么行业内会使用结构性估计方法,比如收缩估计(Shrinkage)和因子模型。Ledoit-Wolf 收缩估计的思路很简单:把样本协方差矩阵向单位矩阵方向“拉”一点。因为单位矩阵是理想化的“所有资产不相关”状态,通过混合系数来平衡样本信息与结构化先验,能在估计误差和偏差之间取得更好的折中。
4.2 指数加权协方差与动态相关性
另一个常用的改进是给历史数据设置衰减权重——时间越近的收益率对当前协方差估计的影响越大。这就是 RiskMetrics 里的 EWMA 模型,只需要一个衰减参数 λ(通常取 0.94),计算极其轻量,却能在市场波动聚集的时期快速反应。
代码实现:
这个模型背后的直觉是:金融数据有波动聚集现象,最近一段时间的相关性比半年前的相关性更贴近当前的联动状态。对日频数据,我习惯把 λ 设成 0.94——这也是 RiskMetrics 的标准参数。如果要更精细,可以用极大似然去估计 λ,但对大多数组合分析场景,0.94 已经足够好。
4.3 用相关性矩阵做层次聚类:找出真正独立的资产
相关性矩阵除了用来算组合波动率,还有一个非常实用的场景:资产聚类。如果矩阵里有一组股票两两相关性都在 0.8 以上,说明它们基本在重复暴露于同一个风险源,配置再多也起不到分散作用。
用 scipy 对相关性矩阵做层次聚类只需要几行代码:
距离矩阵我取的是 1 - |相关系数|,这样相关性越高的股票距离越小,聚成一类。这个办法在股池筛选阶段特别有用——从全市场几千只股票里先按行业聚类,再从每个类别中挑一两只,能保证组合的分散度。
要提醒的是,层次聚类对输入特征比较敏感。如果你用不同时间窗口的数据,聚类结果可能会有差异,建议同时跑 60 天、120 天、250 天三个窗口,取结果中反复出现的分组作为稳定分组。
5. 常见问题与排查技巧实录
5.1 相关系数矩阵出现 NaN 或者其他非数值项
这个问题十有八九是原始价格序列里有缺失值。有几种可能:
- 新股上市时间短,数据长度不足
- 停牌时间过长,导致某段时间完全没有交易记录
- 数据源返回的价格是字符串格式,算收益率时转类型失败
排查方法:先对原始价格序列做 isna().sum() 统计,找出缺失严重的列;再看日期索引是否对齐。如果只有零星缺失,可以用 ffill() 填充;如果缺失比例超过 20%,我个人建议直接把这只股票从样本里剔除,因为填充出来的数据对相关性的影响已经不可控了。
5.2 计算出来的相关性都是 1 或者接近 1
当出现所有相关系数都几乎等于 1 时,大概率是价格序列没有做复权处理,且数据窗口内发生过大比例分红或拆股。特别是拆股,价格会瞬间变成原来的几分之一,如果不复权,收益率序列会出现一个离谱的异常值,把所有相关系数都拉向 1。
解决办法是重新拉取复权后的价格数据。yfinance 的 auto_adjust=True 参数就是干这个的。如果用的是国产数据源,一般会有一个 adjust="qfq" 或者类似的参数勾选前复权。
5.3 组合波动率算出来比单只股票还小,是不是算错了
没算错,这就是分散化的效果。如果两只股票相关系数小于 1,组合波动率一定小于单只股票波动率的加权平均。相关系数越低,降低效果越明显。很多第一次接触组合分析的人看到这个结果会怀疑代码写错了,其实恰恰是代码写对了。
不过要留意一个例外:如果相关系数小于 1 是因为数据窗口太短导致估计不准,那这个“分散化收益”很可能是虚假的。实际应用中,我一般会要求至少 1 年的日频数据,也就是大约 250 个交易日以上,才会把协方差矩阵用于组合优化。
5.4 权重优化结果不稳定——每次调数据窗口权重都大变
这个属于组合优化的经典问题:样本协方差矩阵的估计误差被优化器捕捉并放大了。稍微改变数据窗口,权重就出现很大变化。
解药有三个:
- 增加约束条件。比如限制单只股票最大权重不超过 20%,这会显著缩小优化器的搜索空间。
- 换成前面说的 Ledoit-Wolf 收缩估计或者 EWMA 协方差,降低协方差矩阵对最近一段时间的敏感度。
- 减少资产数量。股票池越小,协方差矩阵要估计的参数就越少,结果越稳定。10 只以内的股票组合不需要太花哨的估计方法,直接用样本协方差就有不错的稳定性。
5.5 一个必须注意的“未来函数”问题
计算滚动相关性或者滚动波动率时,每一步只能用到当前时刻已经发生的数据。如果你用了整个样本区间来估计协方差,再去做历史回测,那就是一种前视偏差——相当于考试的时候先看了答案再做题,结果会乐观得不真实。
正确的做法是用 expanding() 或 rolling() 窗口:
每次计算只使用过去 60 个交易日的收益数据,不带未来信息。这一点在做任何策略验证时都要绷紧一根弦。
6. 一套可以直接上手的完整示例代码
最后给一个完整的示例代码,从拉数据到算组合波动率,你可以直接复制到 Jupyter Notebook 或者 VS Code 里跑通,再根据自己的股票池修改参数。
这段代码里我把协方差矩阵直接年化了,也就是算日协方差矩阵后乘 252,这样后续组合波动率就不需要再额外乘 sqrt(252)。为什么可以这样操作?因为协方差矩阵呈线性性质,乘以时间长度就是年化协方差,取平方根后自然得到年化波动率。
跑完这段代码以后,你还可以做两件事:把股票池替换成不同行业的股票,对比相关性和组合波动率的变化;或者把权重改成自己实际的持仓比例,看看当前组合的风险暴露到底有多大。
我在实际使用中还有一个习惯是:拿到相关性矩阵以后先不要急着做优化,先扫一眼有没有大于 0.9 的配对。如果有,这两只股票大概率属于同一行业或者同一风格因子,权重优化出来的结果会把它们当作一个资产处理。做好这一步检查,再跑最小方差优化,得到的权重才是有意义的。