简介:投资组合优化从Markowitz资产组合理论到遗传算法的完整Python实现包,面向量化分析、金融工程和算法交易方向的开发者与学习者,解决如何在风险与收益之间寻找最优权重分配的核心问题。压缩包内含59个文件,以28个Python源码文件为主,覆盖资产收益率计算、协方差矩阵构建、最小方差组合及遗传算法优化等模块;另有17个PDF理论文档、4份CSV价格数据、6条URL参考链接及README、LICENSE等配套说明,整体大小约18.68MB,项目结构按功能分目录组织,便于阅读和二次开发。已有1217人学习下载,属于投资组合优化话题下较为聚焦的实战资源。通过这份代码包,读者可以对照源码理解经典均值-方差模型与遗传算法迭代进化在组合优化中的实际运用,利用样例数据复现有效前沿,并在此基础扩展自己的策略逻辑。
1. 当 Markowitz 的最优权重在实盘里失灵:遗传算法补的是哪块短板
同一份收益数据,跑均值-方差优化得到的权重,换一段样本区间再跑,结果会完全变一个样:前一次重仓的资产可能在后一次变成零仓位。这不是代码写错了,而是 Markowitz 模型对输入误差的过度反应。遗传算法不依赖梯度、不要求目标函数凸,适合真实组合里常见的基数约束、最小下单量这类非凸场景。对于已经开始接触 Python 量化回测、想给组合优化增加进化搜索能力的工程师,这篇文章提供一条从 Markowitz 到遗传算法的完整代码路径,可以直接在本机复现。
2. Markowitz 模型:用 Python 复现均值-方差优化的完整代码与参数
在引入遗传算法之前,先把基线跑通。均值-方差优化的输入只有历史收益矩阵,输出是资产权重向量,逻辑本身是一个带等式约束的二次规划。这一章给出的代码片段就是一个可运行的最小 python 量化交易策略代码骨架,后续所有遗传算法结果都会拿它做对照。
2.1 数据准备:收益率序列、年化期望与协方差矩阵
先从收盘价推导日频收益率。直接用pct_change()是最常见做法,但要注意第一行会变成 NaN,必须dropna()处理。
import numpy as np import pandas as pd prices = pd.read_csv("prices.csv", index_col=0, parse_dates=True) returns = prices.pct_change().dropna() mu = returns.mean() * 252 cov = returns.cov() * 252 print(mu) print(cov.iloc[:3, :3])代码说明:pct_change()计算的是相邻复利收益率,dropna()去掉首行缺失值。年化乘 252 个交易日,把日频期望收益和协方差换算成年化口径,后续夏普比率和有效前沿都按年化数值做对比。returns.cov()给出样本协方差矩阵,但当资产数量接近样本天数时矩阵会接近奇异,这直接影响第二章里优化器的稳定性。
注意:如果价格表里部分股票停牌产生 NaN,直接dropna()会丢掉整行有效数据。我一般先按列统计缺失比例,超过 20% 的剔除,剩下的用前向填充补最后一段缺失值。
2.2 最小方差组合的 scipy 实现及约束参数说明
目标函数是最小化组合波动率,数学形式是 w^T Σ w,约束是权重和等于 1;如果指定目标收益,再追加一个收益等式约束。
from scipy.optimize import minimize def min_variance(mu, cov, target_return=None): n = len(mu) def portfolio_vol(w): return np.sqrt(w @ cov @ w) constraints = [{"type": "eq", "fun": lambda w: 1 - w.sum()}] if target_return is not None: constraints.append({ "type": "eq", "fun": lambda w: w @ mu - target_return }) bounds = [(0.0, 1.0)] * n w0 = np.ones(n) / n res = minimize(portfolio_vol, w0, method="SLSQP", bounds=bounds, constraints=constraints) if not res.success: raise RuntimeError(res.message) return res.x w_mv = min_variance(mu, cov) print("最小方差组合权重:", np.round(w_mv, 4))代码逻辑解释:portfolio_vol用开方而不是直接用二次型,因为两者极值点等价,但开方后的梯度量级更稳定。约束写成1 - w.sum()而不是w.sum() - 1,对 SLSQP 的拉格朗日乘子初值更友好,能减少一部分数值警告。当target_return超过单个资产最高年化收益时,约束没有可行解,SLSQP 不会主动报错,而是返回一个停在边界上的解,所以res.success的检查不能省略。
2.3 画出有效前沿:三个必调参数与一张参数表
把target_return在一段区间内扫描,重复调用上面的函数,就能得到有效前沿。
import matplotlib.pyplot as plt targets = np.linspace(mu.min(), mu.max(), 20) frontier = [] for tr in targets: try: w = min_variance(mu, cov, target_return=tr) frontier.append((np.sqrt(w @ cov @ w), w @ mu)) except RuntimeError: continue fx = [p[0] for p in frontier] fy = [p[1] for p in frontier] plt.plot(fx, fy, "-o") plt.xlabel("年化波动率") plt.ylabel("年化收益") plt.xticks(rotation=45) plt.show()如果前沿曲线出现明显缺口,是目标收益不可行后被continue跳过造成的,属于数据的可行域问题。若横坐标 20 个点挤在一起,就用plt.xticks(rotation=45)旋转刻度,避免“横坐标太密集”影响阅读。
下面这张表总结了影响结果最直接的三类参数:
| 参数 | 作用 | 常见设置 | 调整误区 |
|---|---|---|---|
| 收益率口径 | 决定 mu 和 cov 的输入 | 对数收益或普通收益 | 混用两种口径,数值方向一致但夏普比率整体偏移 |
bounds权重边界 | 控制个股集中度 | (0, 1)或不设 | 改成(0, 0.3)后结果剧烈变化,不是 bug |
| 年化因子 | 统一时间尺度 | 252 | 用 365 会把波动率放大 1.2 倍 |
运行效果稳定后,再进入遗传算法部分,因为后面的 GA 代码会复用这里的mu、cov作为输入。
3. 从凸优化到进化:Markowitz 在实际约束下失效的三个场景与遗传算法的对应策略
Markowitz 框架在教科书上很干净,但把它放到真实组合里,优化器跑出来的权重往往让人不敢直接用。这一章拆开三个具体场景,解释为什么需要换一种搜索思路。
3.1 协方差估计误差传导:最优解对输入噪声的过度反应
均值-方差优化隐含假设输入的协方差矩阵是精确的。实际用三年日频数据估算,协方差矩阵中后段小特征值携带的噪声,会被求逆过程放大,反映到权重上就是剧烈抖动。
rng = np.random.default_rng(42) noise = rng.normal(0, 0.001, cov.shape) cov_noisy = cov + cov * noise w1 = min_variance(mu, cov) w2 = min_variance(mu, cov_noisy) print("原始权重差 ->", np.linalg.norm(w1 - w2))在这段代码里,cov + cov * noise引入的是 0.1% 量级的相对扰动,np.linalg.norm(w1 - w2)计算两组权重的欧氏距离。6 只资产的例子里,这个差值经常超过 0.3,意味着 30% 的仓位被改变了。遗传算法同样受输入噪声影响,但它不会顺着梯度方向滑向边缘,而是保留一簇适应度接近的解,输出稳定性更好。
3.2 离散交易与基数约束:非凸目标如何让 SLSQP 失效
真实组合经常附带这些约束:持仓资产数不超过 K 只、单只权重不低于 0.5%、交易费用分段计价。任何一条都会把光滑凸问题变成非凸问题:
| 约束类型 | 数学表达 | 对求解器的影响 |
|---|---|---|
| 基数约束 | Σ I(w_i > 0) ≤ K | 可行域变成若干不相连区域,SLSQP 无法跨越区域边界 |
| 最小下单量 | w_i = 0 或 w_i ≥ 0.005 | 可行域失去凸性 |
| 阶梯交易费 | 成本分段线性 | 目标函数出现折点,导数不连续 |
scipy.optimize.minimize遇到这类问题的典型表现是:返回一个满足数值容差的解,但组合明显不在同一可行区域内。原因是 SLSQP 的线性搜索基于局部泰勒展开,跨不过非凸的断裂点。
3.3 遗传算法应对非凸的机制:锦标赛选择与变异算子
GA 不计算梯度,因此非凸目标函数对它的搜索机制没有额外难度。变异算子的随机扰动让解有机会离开当前可行域块,跳跃到另一个块中。应对约束的常见做法是惩罚函数,把违反约束的个体拉低适应度。
def penalized_score(w, mu, cov, max_assets=6, penalty=1.5): sharpe = (w @ mu) / np.sqrt(w @ cov @ w) n_assets = (w > 1e-6).sum() if n_assets > max_assets: sharpe *= penalty * (1 - (n_assets - max_assets) / len(w)) return sharpe(w > 1e-6).sum()统计非零权重数量,阈值 1e-6 用来过滤浮点误差造成的伪持仓。惩罚系数 1.2~2.0 之间比较合适,太小约束形同虚设,太大会让种群把所有注意力放在满足约束上,忽略了收益目标。这个函数可以直接替换第四章适应度函数里的夏普计算。
4. 遗传算法 python 代码详解:投资组合权重编码、适应度函数与约束处理
进入遗传算法的核心实现。这一章给出的代码可以直接复制到本地 numpy 环境运行,目的是把编码、选择、交叉、变异和精英保留完整串起来。
4.1 编码设计:为什么用 Dirichlet 初始化而不是均匀随机数加归一化
染色体就是一组权重向量 w,它天然满足总和为 1 的要求。最容易想到的初始化是生成均匀随机数再除以总和,但这样会让大多数个体集中在均匀点附近,搜索初期多样性不足。用 Dirichlet 分布可以直接控制权重集中程度。
def init_pop(pop_size, n_assets, alpha=1.0): return np.random.default_rng().dirichlet( np.ones(n_assets) * alpha, size=pop_size )Dirichlet 分布的参数 alpha=1 时退化为均匀分布;alpha 大于 1 时生成的权重更平均,alpha 小于 1 时更集中于少数资产。先取 alpha=1 起步,如果收敛后极端权重比例过高,再把 alpha 提高到 1.5 左右,限制个股集中。
4.2 完整可运行的遗传算法 python 代码:选择、交叉、变异与精英保留
import numpy as np def fitness_sharpe(w, mu, cov, rf=0.0): ret = w @ mu vol = np.sqrt(w @ cov @ w) if vol == 0: return -np.inf return (ret - rf) / vol def tournament_select(pop, fit, k=3): idx = np.random.choice(len(pop), k, replace=False) return pop[idx[np.argmax(fit[idx])]].copy() def arithmetic_crossover(p1, p2, alpha=0.5): child = alpha * p1 + (1 - alpha) * p2 return child / child.sum() def gaussian_mutation(w, rate=0.1, sigma=0.02): if np.random.random() > rate: return w.copy() wc = np.abs(w + np.random.normal(0, sigma, size=len(w))) return wc / wc.sum() def ga_optimize(mu, cov, pop_size=200, n_gen=300, cross_alpha=0.5, mut_rate=0.1): rng = np.random.default_rng() n = len(mu) pop = rng.dirichlet(np.ones(n), size=pop_size) best_hist = [] for _ in range(n_gen): fit = np.array([fitness_sharpe(w, mu, cov) for w in pop]) best_idx = np.argmax(fit) best_hist.append(fit[best_idx]) new_pop = [pop[best_idx].copy()] while len(new_pop) < pop_size: p1 = tournament_select(pop, fit, k=3) p2 = tournament_select(pop, fit, k=3) child = arithmetic_crossover(p1, p2, cross_alpha) child = gaussian_mutation(child, rate=mut_rate) new_pop.append(child) pop = np.array(new_pop) final_fit = np.array([fitness_sharpe(w, mu, cov) for w in pop]) return pop[np.argmax(final_fit)], best_hist w_ga, hist = ga_optimize(mu, cov) print(np.round(w_ga, 4))代码逻辑说明:fitness_sharpe是夏普比率,只依赖权重、期望收益和协方差矩阵,不涉及任何导数计算。tournament_select每次从种群中随机抽 k 个个体,取适应度最高者参与交叉,k=3 是平衡选择压力和多样性的常见起点。arithmetic_crossover是权重向量之间的线性插值,子代必然满足权重和为 1。gaussian_mutation对需要变异的个体加高斯噪声,取绝对值后再归一化,保证权重非负且和为 1。
best_hist记录每一代最优适应度,精英个体直接复制进下一代,避免全局最优解在交叉变异中被破坏。
4.3 算子参数与代码逻辑的几点说明
| 算子 | 作用 | 关键参数 | 推荐范围 |
|---|---|---|---|
tournament_select | 选优交配个体 | k 竞赛规模 | 2~5,k 越大选择压力越大 |
arithmetic_crossover | 权重向量混合 | alpha 混合系数 | 0.4~0.6 |
gaussian_mutation | 扰动后归一化 | rate、sigma | rate 0.05~0.2,sigma 0.01~0.05 |
注意fitness_sharpe里vol == 0的边界处理:迭代早期个别个体权重极端集中,可能让波动率近似为零,此时夏普计算出现除零错误,返回-np.inf会让该个体直接失去选择竞争力。
5. 遗传算法五个核心参数的调优基线:从种群规模到收敛判据
代码能跑通只是第一步,GA 的参数设置决定最终解的质量。这一章给出一组可以直接当起点的参数基线,以及判断是否收敛的检查方法。
5.1 参数参考表与首次运行基线
对 10 到 20 只股票的常见组合,建议从下面这组数值起步:
| 参数 | 建议起点 | 可调范围 | 判断依据 |
|---|---|---|---|
| pop_size | 200 | 50~500 | 太小选择压力不足,太大单代计算变慢 |
| n_gen | 300 | 150~800 | 观察适应度曲线是否持续上升 |
| cross_alpha | 0.5 | 0.3~0.7 | 太大子代接近父代,多样性下降 |
| mut_rate | 0.1 | 0.03~0.2 | 目标函数崎岖时提高变异率 |
| elite 数量 | 1 | 1~5 | 太大优秀个体霸占种群,早熟 |
资产数超过 50 时,pop_size 需要跟着放大,建议按 n 的 3 到 5 倍量级设置,否则高维空间里的初始种群覆盖率不够,收敛会明显变慢。
5.2 收敛诊断三指标:历代最优、种群均值和极端权重占比
只看每一代的最优适应度不够,它可能是孤立高值。配合种群均值和权重分布才有完整画面:
last_fit = np.array([fitness_sharpe(w, mu, cov) for w in pop]) mean_fit = last_fit.mean() extreme_ratio = (w_ga > 0.2).sum() / len(w_ga) print(f"最优适应度: {hist[-1]:.4f}") print(f"种群均适应度: {mean_fit:.4f}") print(f"极端权重占比: {extreme_ratio:.1%}")运行后看两组数据:最优适应度与均值是否接近,以及极端权重比例是否可控。两者差距大,说明选择压力过强、多样性不足;极端权重占比超过三分之一,需要降低交叉 alpha 或者提高变异率。
hist里的每一代最优值画成折线图,横轴是代际,纵轴是适应度,如果末尾段还在明显上涨,就把n_gen加大;如果前 50 代就平坦,说明参数偏激进,可以适当缩小变异率。种群均值的走向比最优值更平缓,是判断是否稳定的更好指标。
5.3 与 Markowitz 的小规模对比实验:同一份数据、两个解
把两种方法放到同一份数据上对比:
w_mv = min_variance(mu, cov) sr_mv = (w_mv @ mu) / np.sqrt(w_mv @ cov @ w_mv) sr_ga = (w_ga @ mu) / np.sqrt(w_ga @ cov @ w_ga) print(f"Markowitz 夏普: {sr_mv:.4f}") print(f"GA 夏普: {sr_ga:.4f}")通常 GA 的夏普略低于 Markowitz,因为后者直接优化同一个目标函数的精确解。但看权重分布会发现明显差异:Markowitz 可能把 60% 权重压在两只资产上,GA 的解更分散。这不代表 GA 更差,而是在没有做约束处理时,Markowitz 的集中度来源于对协方差矩阵的精确拟合,换样本后这种集中度会变成隐患。
实际对比时看三个数字:夏普比率差距、极端权重占比、换样本后权重的欧氏距离。GA 的权重稳定性普遍优于 Markowitz,这是它值得作为备选方案的原因。
6. 从回测到实战验证:滑动窗口、交易成本与双轨对照
把优化器放进回测框架里才能验证是否经得起换样本的考验。这一章以一个滚动窗口回测为主体,给出直接可用的操作步骤。
6.1 滑动窗口重估:每 20 个交易日重跑一次遗传算法
滚动窗口的做法是:每 20 个交易日,用过去 252 天收益数据重估 mu 和 cov,再调用一次优化器得到新权重。
def rolling_ga(returns, rebalance=20, window=252): weight_records = [] for i in range(window, len(returns), rebalance): hist = returns.iloc[i-window:i] mu_i = hist.mean() * 252 cov_i = hist.cov() * 252 w_i, _ = ga_optimize(mu_i, cov_i) weight_records.append(w_i) return np.array(weight_records) w_records = rolling_ga(returns) turnover = np.mean(np.abs(np.diff(w_records, axis=0)), axis=0) print("平均单次换手率:", turnover)np.diff(w_records, axis=0)计算相邻两次调仓的权重差,换手率是衡量调仓成本的核心指标。GA 每次输出的权重因为随机性会有微小波动,但两个相邻周期之间如果换手率超过 30%,要考虑是不是pop_size太小导致解不稳定。
6.2 交易成本惩罚与净值曲线
模拟成本最直接的方式是在适应度函数里扣除换手成本。常见做法是每次调仓时,按新旧权重差绝对值乘以一个成本系数。
def fitness_with_cost(w_new, w_old, mu, cov, cost_rate=0.001): base = fitness_sharpe(w_new, mu, cov) turnover = np.abs(w_new - w_old).sum() return base - cost_rate * turnovercost_rate取 0.001 代表双边 0.1% 的交易成本,换成 ETF 或股票时按实际佣金水平调整。把这一段替换适应度函数后重跑滚动回测,净值曲线的实际收益会高于无成本版本,也比直接调高变异率更接近真实场景。验证的最终标准是:同一份数据、同一组约束,GA 和 Markowitz 同步跑完滚动回测,两者净值曲线和最大回撤差异在可解释范围内时,GA 的稳定性优势才真正落地。
本文还有配套的精品资源,点击获取