第四章:Monte Carlo模拟投资组合——随机权重生成、组合收益率与风险计算、模拟次数选择

好,咱们进入正题。Monte Carlo模拟在投资组合优化里,说白了就是“暴力枚举”。你想想看,市场有几千只股票,权重组合几乎是无限的。传统优化方法(比如均值-方差)需要解方程,但Monte Carlo的思路很简单——随机生成一大堆权重,算算每个组合的表现,然后挑好的。

我个人习惯把这一步叫做“用计算量换洞察”。别小看这个笨办法,我在项目中遇到过好几次,当协方差矩阵接近奇异时,传统优化直接崩了,反而是Monte Carlo稳稳地给出了可行解。

4.1 随机权重生成——别让代码坑了你

生成随机权重,第一反应可能是:

import numpy as np
weights = np.random.random(n_assets)
weights /= weights.sum()  # 归一化到1

嗯,这里要注意。这段代码确实能生成一组和为1的权重,但它的分布并不均匀。为什么?因为Dirichlet分布才是正确的打开方式。

核心要点:np.random.dirichlet(alpha, size) 生成权重。alpha取全1向量时,每个权重向量在单纯形上均匀分布。

def generate_random_weights(n_assets, n_portfolios):
    """
    生成n_portfolios组随机权重
    每组权重和为1,且非负
    """
    alpha = np.ones(n_assets)
    weights = np.random.dirichlet(alpha, size=n_portfolios)
    return weights

# 示例:5只股票,生成10000组权重
weights = generate_random_weights(5, 10000)
print(weights.shape)  # (10000, 5)
print(weights[0].sum())  # 1.0

我曾经犯过一个低级错误——直接用均匀分布生成权重然后归一化。结果发现,当资产数量超过10个时,大部分权重都集中在0附近,极端值很少。这会导致模拟出的有效前沿“缺角”。后来改用Dirichlet,问题就解决了。

避坑指南:我曾经在生成权重时忘了检查负值。虽然Dirichlet默认非负,但如果你自己写生成逻辑,一定要加 np.clip 或断言。负权重意味着做空,这在某些场景下是不允许的。

4.2 组合收益率与风险计算——公式很简单,细节要命

有了权重,下一步就是算每个组合的收益率和风险。公式其实就两行:

  • 组合收益率: \( R_p = w^T \cdot \mu \)
  • 组合方差: \( \sigma_p^2 = w^T \cdot \Sigma \cdot w \)

其中 \(\mu\) 是预期收益率向量,\(\Sigma\) 是协方差矩阵。代码实现:

def portfolio_stats(weights, mean_returns, cov_matrix):
    """
    计算组合的收益率和标准差
    weights: (n_assets,) 或 (n_portfolios, n_assets)
    """
    if weights.ndim == 1:
        weights = weights.reshape(1, -1)
    
    returns = np.dot(weights, mean_returns)
    # 向量化计算方差:对每个权重向量计算 w^T @ cov @ w
    variances = np.einsum('ij,jk,ik->i', weights, cov_matrix, weights)
    stds = np.sqrt(variances)
    
    return returns, stds

这里我用了 np.einsum,而不是循环。为什么?因为10000组权重,用for循环要跑几秒,而einsum是毫秒级。我在项目中遇到过性能瓶颈,当时模拟10万次,循环跑了半分钟,改成向量化后瞬间出结果。

小技巧:如果你不熟悉einsum,也可以用 np.diag(weights @ cov_matrix @ weights.T)。但注意,这会把整个矩阵乘出来,内存消耗大。einsum更高效。

算完所有组合的收益率和风险后,你会得到一个散点图。每个点代表一个随机组合。有效前沿就是这些点的上包络线。

4.3 模拟次数选择——不是越多越好

这是新手最容易纠结的问题。模拟1000次够吗?10000次?还是100万次?

我的经验是:看你的资产数量

资产数量 推荐模拟次数 说明
2-5只 5000 - 10000 维度低,收敛快
6-15只 20000 - 50000 中等维度,需要更多采样
16-50只 100000 - 500000 高维空间,采样稀疏
50只以上 100万+ 建议改用优化算法

为什么会有这个规律?你想想看,在N维空间中,随机采样要覆盖整个单纯形,需要的点数随维度指数增长。这就是所谓的“维度灾难”。

我曾经帮一个客户做50只股票的模拟,他坚持要100万次。结果跑了10分钟,有效前沿还是坑坑洼洼的。后来我改用遗传算法,只算了2万次就找到了不错的解。

判断收敛的方法:跑两次模拟,看有效前沿是否稳定。如果两次结果差异很大,说明次数不够。我个人习惯用 np.percentile 检查最大夏普比率的波动范围。

def check_convergence(n_assets, n_trials_list):
    """
    检查不同模拟次数下的稳定性
    """
    for n in n_trials_list:
        weights = generate_random_weights(n_assets, n)
        returns, stds = portfolio_stats(weights, mean_returns, cov_matrix)
        sharpe = returns / stds
        max_sharpe = np.max(sharpe)
        print(f"模拟{n}次,最大夏普比率: {max_sharpe:.4f}")

# 实际运行会发现,从5万次到10万次,结果变化很小
check_convergence(10, [1000, 5000, 10000, 50000, 100000])

嗯,这里要注意。模拟次数不是唯一因素。如果你的协方差矩阵估计不准,模拟1亿次也没用。数据质量永远比计算量重要。

4.4 完整流程与可视化

把上面所有步骤串起来,就是一个完整的Monte Carlo模拟流程:

# 完整模拟流程
n_assets = 10
n_portfolios = 50000

# 1. 生成随机权重
weights = generate_random_weights(n_assets, n_portfolios)

# 2. 计算收益率和风险
returns, risks = portfolio_stats(weights, mean_returns, cov_matrix)

# 3. 计算夏普比率(假设无风险利率为0)
sharpe_ratios = returns / risks

# 4. 找到最优组合
best_idx = np.argmax(sharpe_ratios)
best_weights = weights[best_idx]
best_return = returns[best_idx]
best_risk = risks[best_idx]

print(f"最优组合: 收益率={best_return:.4f}, 风险={best_risk:.4f}, 夏普={sharpe_ratios[best_idx]:.4f}")

可视化部分,我习惯用散点图展示所有组合,然后用红色标记最优组合。这样一眼就能看出有效前沿的位置。

个人习惯:我会把模拟结果保存下来,方便后续分析。比如用 np.savez 存成压缩文件,包含权重、收益率、风险三组数据。下次想调整参数时,不用重新跑模拟。

4.5 本章知识体系

下面这张图概括了Monte Carlo模拟投资组合的核心逻辑:

Monte Carlo模拟投资组合核心流程 输入数据 预期收益率 μ 协方差矩阵 Σ 随机权重生成 Dirichlet分布 权重和为1,非负 计算组合指标 收益率 = wᵀμ 风险 = √(wᵀΣw) 重复N次(N=模拟次数) 结果分析 有效前沿:风险-收益散点图 最优组合:最大夏普比率 收敛性检查 增加模拟次数 检查结果稳定性 输出 最优 权重 关键参数:资产数量、模拟次数、协方差矩阵估计方法 输出:有效前沿、最优权重、最大夏普比率

这张图把整个流程串起来了。从输入数据开始,经过随机权重生成、指标计算,再到结果分析和收敛检查,最后输出最优权重。注意那个循环箭头——模拟次数决定了你要跑多少轮。

好了,Monte Carlo模拟的核心部分就这些。下一章我们会讨论如何从模拟结果中提取有效前沿,以及如何用这些结果做投资决策。记住,模拟只是工具,真正的价值在于你对结果的理解和判断。


公众号:蓝海资料掘金营,微信deep3321