
简介投资组合优化从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_col0, parse_datesTrue) 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_returnNone): 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, methodSLSQP, boundsbounds, constraintsconstraints) 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_returntr) 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(rotation45) plt.show()如果前沿曲线出现明显缺口是目标收益不可行后被continue跳过造成的属于数据的可行域问题。若横坐标 20 个点挤在一起就用plt.xticks(rotation45)旋转刻度避免“横坐标太密集”影响阅读。下面这张表总结了影响结果最直接的三类参数参数作用常见设置调整误区收益率口径决定 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_assets6, penalty1.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, alpha1.0): return np.random.default_rng().dirichlet( np.ones(n_assets) * alpha, sizepop_size )Dirichlet 分布的参数 alpha1 时退化为均匀分布alpha 大于 1 时生成的权重更平均alpha 小于 1 时更集中于少数资产。先取 alpha1 起步如果收敛后极端权重比例过高再把 alpha 提高到 1.5 左右限制个股集中。4.2 完整可运行的遗传算法 python 代码选择、交叉、变异与精英保留import numpy as np def fitness_sharpe(w, mu, cov, rf0.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, k3): idx np.random.choice(len(pop), k, replaceFalse) return pop[idx[np.argmax(fit[idx])]].copy() def arithmetic_crossover(p1, p2, alpha0.5): child alpha * p1 (1 - alpha) * p2 return child / child.sum() def gaussian_mutation(w, rate0.1, sigma0.02): if np.random.random() rate: return w.copy() wc np.abs(w np.random.normal(0, sigma, sizelen(w))) return wc / wc.sum() def ga_optimize(mu, cov, pop_size200, n_gen300, cross_alpha0.5, mut_rate0.1): rng np.random.default_rng() n len(mu) pop rng.dirichlet(np.ones(n), sizepop_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, k3) p2 tournament_select(pop, fit, k3) child arithmetic_crossover(p1, p2, cross_alpha) child gaussian_mutation(child, ratemut_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 个个体取适应度最高者参与交叉k3 是平衡选择压力和多样性的常见起点。arithmetic_crossover是权重向量之间的线性插值子代必然满足权重和为 1。gaussian_mutation对需要变异的个体加高斯噪声取绝对值后再归一化保证权重非负且和为 1。best_hist记录每一代最优适应度精英个体直接复制进下一代避免全局最优解在交叉变异中被破坏。4.3 算子参数与代码逻辑的几点说明算子作用关键参数推荐范围tournament_select选优交配个体k 竞赛规模2~5k 越大选择压力越大arithmetic_crossover权重向量混合alpha 混合系数0.4~0.6gaussian_mutation扰动后归一化rate、sigmarate 0.05~0.2sigma 0.01~0.05注意fitness_sharpe里vol 0的边界处理迭代早期个别个体权重极端集中可能让波动率近似为零此时夏普计算出现除零错误返回-np.inf会让该个体直接失去选择竞争力。5. 遗传算法五个核心参数的调优基线从种群规模到收敛判据代码能跑通只是第一步GA 的参数设置决定最终解的质量。这一章给出一组可以直接当起点的参数基线以及判断是否收敛的检查方法。5.1 参数参考表与首次运行基线对 10 到 20 只股票的常见组合建议从下面这组数值起步参数建议起点可调范围判断依据pop_size20050~500太小选择压力不足太大单代计算变慢n_gen300150~800观察适应度曲线是否持续上升cross_alpha0.50.3~0.7太大子代接近父代多样性下降mut_rate0.10.03~0.2目标函数崎岖时提高变异率elite 数量11~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(fMarkowitz 夏普: {sr_mv:.4f}) print(fGA 夏普: {sr_ga:.4f})通常 GA 的夏普略低于 Markowitz因为后者直接优化同一个目标函数的精确解。但看权重分布会发现明显差异Markowitz 可能把 60% 权重压在两只资产上GA 的解更分散。这不代表 GA 更差而是在没有做约束处理时Markowitz 的集中度来源于对协方差矩阵的精确拟合换样本后这种集中度会变成隐患。实际对比时看三个数字夏普比率差距、极端权重占比、换样本后权重的欧氏距离。GA 的权重稳定性普遍优于 Markowitz这是它值得作为备选方案的原因。6. 从回测到实战验证滑动窗口、交易成本与双轨对照把优化器放进回测框架里才能验证是否经得起换样本的考验。这一章以一个滚动窗口回测为主体给出直接可用的操作步骤。6.1 滑动窗口重估每 20 个交易日重跑一次遗传算法滚动窗口的做法是每 20 个交易日用过去 252 天收益数据重估 mu 和 cov再调用一次优化器得到新权重。def rolling_ga(returns, rebalance20, window252): 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, axis0)), axis0) print(平均单次换手率:, turnover)np.diff(w_records, axis0)计算相邻两次调仓的权重差换手率是衡量调仓成本的核心指标。GA 每次输出的权重因为随机性会有微小波动但两个相邻周期之间如果换手率超过 30%要考虑是不是pop_size太小导致解不稳定。6.2 交易成本惩罚与净值曲线模拟成本最直接的方式是在适应度函数里扣除换手成本。常见做法是每次调仓时按新旧权重差绝对值乘以一个成本系数。def fitness_with_cost(w_new, w_old, mu, cov, cost_rate0.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 的稳定性优势才真正落地。本文还有配套的精品资源点击获取