资讯中心

Python量化投资:用遗传算法解决Markowitz组合优化的真实约束

📅 2026/9/28 8:41:27
Python量化投资:用遗传算法解决Markowitz组合优化的真实约束
简介作为量化投资与金融科技方向的学习资料资料包围绕“如何优化投资组合”这一核心问题系统梳理了从Markowitz均值-方差理论到遗传算法的完整路径。面向有一定Python基础的开发者、量化研究人员或金融专业学生解决多资产配置中收益与风险权衡、有效前沿构建及全局寻优等实际难题。压缩包共59个文件、约18.68MB包含28个Python脚本、17个PDF文档、多个CSV历史价格数据及URL链接等脚本覆盖收益率计算、协方差矩阵估计、最小方差组合求解并引入DEAP库实现遗传算法寻优PDF可作为理论对照与报告参考。目前已有1218人学习下载。读者可直接运行代码复现实验结果也可基于现有模块替换自有数据快速搭建自己的组合优化流程是一份兼具理论深度和工程实操价值的参考资料。1. 从 Markowitz 到遗传算法投资组合优化为什么值得用 Python 重做一遍你用 scipy 把 Markowitz 均值-方差模型跑通之后兴冲冲地拿真实行情去算结果最优权重 70% 押在一只票上几个月后回测显示换手率高得吓人。这不是你的代码写错了而是投资组合优化在真实约束下本来就是一道带整数、带上限、带交易成本的复杂问题经典二次规划处理不干净。遗传算法不要求目标函数可导能直接把权重上限、行业暴露、整手约束塞进适应度函数里是处理这类问题的实用替代方案。这篇文章面向用 Python 做投研和量化的工程师从 Markowitz 的 Python 实现讲起再拆遗传算法的编码、适应度、算子设计最后落到可复现的代码和五个高频踩坑点。2. Markowitz 均值-方差模型用 scipy 在 Python 里跑通最小方差组合2.1 模型先立住为什么最大化夏普比率比最小化方差更适合做目标Markowitz 的核心想法是把组合的收益和风险同时放进来用历史收益率均值代表收益用协方差矩阵代表风险。数学上是给定一个目标收益最小化组合方差或者反过来给定可承受的方差最大化收益。这里有个容易被新手忽略的点直接做“最小方差组合”虽然代码最简单但它完全不考虑收益结果经常是一个收益低到没法看的组合。所以实践中我一般把目标设成最大化夏普比率公式是 (组合年化收益 - 无风险利率) / 组合年化波动率让优化器自己去权衡收益和风险。用 scipy 做这个优化时要注意scipy.optimize.minimize只能做最小化所以最大化夏普比率要写成“最小化负的夏普比率”。组合权重的常规约束有两条权重之和等于 1这代表资金全部投出去每个权重在 0 到 1 之间这代表不允许卖空。这两个约束在真实投研里已经能过滤掉大量不合理的数学解也是后续和遗传算法对比时的基准。2.2 把收益率和协方差喂给优化器核心代码与参数说明import numpy as np import pandas as pd from scipy.optimize import minimize def markowitz_max_sharpe(rets, risk_free0.0): # rets: DataFrame每列是一只股票的日收益率序列 mean_daily rets.mean() cov_daily rets.cov() # 按 252 个交易日做年化 mean_annual mean_daily * 252 cov_annual cov_daily * 252 n len(mean_annual) def neg_sharpe(w): p_ret np.dot(w, mean_annual) p_vol np.sqrt(np.dot(w.T, np.dot(cov_annual, w))) return -(p_ret - risk_free) / p_vol # 权重和必须等于 1 constraints ({type: eq, fun: lambda w: np.sum(w) - 1.0}) # 每只股票权重在 0~1 之间即不允许卖空 bounds [(0.0, 1.0)] * n # 等权组合作为起点减少 SLSQP 陷入明显局部解的概率 w0 np.ones(n) / n result minimize(neg_sharpe, w0, methodSLSQP, boundsbounds, constraintsconstraints) return result这段代码的逻辑很直白先算年化收益率和年化协方差矩阵再把负夏普比率封装成目标函数最后交给 SLSQP 算法求解。这里选 SLSQP 而不是单纯的无约束方法是因为它同时支持等式约束和边界约束正好覆盖权重和等于 1、单票权重上限这两个最常用的条件。起点 w0 用等权组合而不是全零是因为全零权重的夏普比率计算会出现 0 除以 0 的问题而且 SLSQP 对起点敏感等权是一个信息量适中的起点。需要微调的参数主要是 risk_free 和 bounds。无风险利率在回测里通常取一年期国债收益率或直接填 0 简化对比bounds 里的上限改成 0.05 或 0.1就变成了常见的“单票不超过 5%/10%”的约束。注意不要把 bounds 改成 (0.01, 0.2) 之类的非零下限因为如果某只股票的收益特征不好优化器会希望给它一个极小的权重强行设置下限会得出失真解。2.3 画出有效前沿的可行做法网格扫描与组合点采样有效前沿是 Markowitz 理论里最直观的一张图横轴是年化波动率纵轴是年化收益每个点代表一个最优组合。画图的核心不是调用一行绘图函数而是怎么在目标收益区间里均匀采样并且保证每个点都能优化成功。def efficient_frontier(rets, grid20): mean rets.mean() * 252 cov rets.cov() * 252 n len(mean) # 先求最小方差组合确定目标收益下界 w_minvar minimize(lambda w: w cov w, np.ones(n) / n, methodSLSQP, bounds[(0, 1)] * n, constraints{type: eq, fun: lambda w: np.sum(w) - 1}) min_ret mean w_minvar.x # 目标收益上界取单只股票里的最大年化收益 max_ret mean.max() target_rets np.linspace(min_ret, max_ret, grid) frontier [] for r in target_rets: cons [ {type: eq, fun: lambda w: np.sum(w) - 1}, {type: eq, fun: lambda w: mean w - r}, ] res minimize(lambda w: w cov w, np.ones(n) / n, methodSLSQP, bounds[(0, 1)] * n, constraintscons) if res.success: vol np.sqrt(res.fun) frontier.append((vol, r)) return frontier这段代码的思路是用最小方差组合的收益作为下界单只股票的最大年化收益作为上界从中间均匀切出 grid 个目标收益值对每个值做一次“最小化方差”的二次规划。这里有个技术细节值得注意lambda 表达式里用了mean w - rr 是循环变量如果不把 r 绑定到默认参数上Python 闭包会共享同一个 r 的最终值导致每个约束都指向同一个目标收益。实际写的时候建议用funlambda w, rr: mean w - r显式绑定。上界取max_ret会让最后一个点恰好等于单只股票全仓这在允许单票权重 100% 时才成立。如果 bounds 里给的是 0.2 这种上限那么网格上界要改成“在单票权重上限约束下能达到的最大年化收益”方法是额外跑一次最大化收益的线性规划否则靠近上界的几个点会优化失败被跳过。3. 遗传算法凭什么能替代二次规划它解决的是 Markowitz 的三个硬约束3.1 二次规划做不到的事整数手数、行业上限、交易成本Markowitz 用 scipy 能跑通靠的是目标函数和约束都是连续可导的。但真实交易里第一A 股下单按手100 股的整数倍而你算出来的权重可能是 0.0377映射到整手会产生尾差第二组合通常有行业暴露上限比如某个行业不超过总仓位的 30%这是线性约束之外的业务规则第三调仓有手续费和冲击成本换手越频繁收益被侵蚀得越厉害。这些约束叠加起来问题从二次规划变成了混合整数非线性规划scipy 的 SLSQP 对这种问题要么收敛慢要么直接报错。遗传算法属于元启发式算法它的核心优势在于优化过程只依赖适应度函数的具体取值不依赖梯度信息。你把整数手数、行业上限、交易成本写成惩罚项塞进适应度函数剩下的搜索交给选择、交叉、变异算子。这是投资组合优化从 Markowitz 走向遗传算法最实际的动机不是因为遗传算法更“智能”而是因为它能处理任意形状的约束。3.2 编码方式选择实数向量比二进制编码更适合权重优化遗传算法里的“染色体”就是一组决策变量。投资组合优化的决策变量是各资产的权重天然是连续的实数。虽然教科书里经常用二进制编码举例但二进制在权重优化场景有两个明显问题交叉点切在二进制串的中段会产生不连续的实数变化Hamming 悬崖会让相邻数值在编码空间里隔得很远变异一位二进制位可能让权重从 0.49 跳成 1.01修复边界难度大。实数和算术交叉就没有这个问题。def init_population(pop_size, n_assets, rng): # 用 Dirichlet(alpha1) 生成均匀随机权重向量 # 每个个体自动满足权重和等于 1 pop rng.dirichlet(alphanp.ones(n_assets), sizepop_size) return pop用 Dirichlet 分布做种群初始化是我比较推荐的做法。alpha 取 1 时生成的向量在单纯形上均匀分布天然满足权重和等于 1 且每个分量非负。比起“生成随机数再除以总和”的做法Dirichlet 不会出现某个权重恰好为 0 或某些点过于聚集的情况种群多样性更有保证。如果你希望初始种群更偏向等权组合附近可以把 alpha 取成 2 或 2.5这样生成的点会向单纯形中心集中适合从等权附近开始精细搜索。3.3 适应度函数设计把目标函数、惩罚项和约束都收进来适应度函数是遗传算法里唯一和业务直接相关的部分。在投资组合优化里基础目标仍然用夏普比率但要把约束转成惩罚项。常见做法是对超限权重做平方惩罚因为线性惩罚会让优化器把超限权重压到边界上产生大量刚好压线的解平方惩罚的梯度更大能更快把超限权重拉回允许范围。def fitness(w, mean_annual, cov_annual, risk_free0.0, max_weight0.2, penalty_lambda10.0): # 组合年化收益和波动率 p_ret w mean_annual p_vol np.sqrt(w cov_annual w) # 基础目标夏普比率 sharpe (p_ret - risk_free) / p_vol # 约束惩罚单只股票权重不能超过 max_weight over np.maximum(w - max_weight, 0.0) penalty penalty_lambda * np.sum(over ** 2) return sharpe - penalty这段代码把单票权重上限直接做成了惩罚项罚掉的是超过 max_weight 部分的平方和。penalty_lambda 是权衡系数太小约束形同虚设太大遗传算法会牺牲收益换合规。我一般从 10 开始调观察满足约束的个体比例低于 60% 就往上加。如果想加行业暴露约束把权重向量按行业分组映射成行业暴露向量再用同样的平方惩罚逻辑即可想加换手约束就把新权重和当前持仓权重的差值绝对值求和乘上换手惩罚系数。3.4 选择、交叉与变异的参数设定锦标赛选择 算术交叉 高斯变异遗传算法的三个算子里选择压力决定收敛速度交叉决定搜索范围变异决定跳出局部解的能力。我用得最顺手的组合是锦标赛选择、算术交叉和高斯变异。def tournament_select(pop, fit_vals, k3, rngNone): # 随机抽 k 个个体取适应度最高的 idx rng.integers(0, len(pop), sizek) best idx[np.argmax(fit_vals[idx])] return pop[best] def arithmetic_crossover(p1, p2, alpha0.5): # 两个父代的线性组合子代权重自然满足和为 1 child alpha * p1 (1 - alpha) * p2 return child def mutate(w, mutation_rate0.1, scale0.05, rngNone): # 以 probability 概率对权重向量加高斯噪声 if rng.random() mutation_rate: w w rng.normal(0, scale, sizelen(w)) w np.clip(w, 0.0, None) # 不允许卖空 w w / w.sum() # 重新归一化到和为 1 return w这三个算子的参数设置有一个组合经验锦标赛的 k 取 3太小选择压力不足进化慢太大会让适应度最高的个体反复胜出种群提前收敛。交叉概率一般设 0.8低于 0.5 时种群主要靠变异搜索收敛明显变慢。变异概率 0.1 和变异尺度 0.05 适合资产数量在 10 到 20 之间的组合资产数量超过 30 时变异尺度可以升到 0.1因为每个权重被扰动的绝对值更小。变异之后必须重新归一化否则权重和会偏离 1。4. 把完整流程串起来一个可直接运行的 Python 遗传算法组合优化脚本4.1 主流程设计从数据到最优权重的四个阶段完整流程分成四个阶段数据准备、基准求解、遗传算法迭代、结果对比。数据准备阶段把日收益率转成年化均值和协方差矩阵基准求解阶段调用 scipy 跑 Markowitz作为对照遗传算法阶段按种群初始化、适应度评估、锦标赛选择、交叉、变异、精英保留的顺序循环迭代最后把遗传算法结果和 Markowitz 结果放在一起看权重分布和夏普比率。精英保留是我比较坚持的做法每一代把适应度最高的前 5% 个体原样复制到下一代保证最优解不会因为变异和交叉而丢失。代码实现里新种群先放精英再用选择交叉变异补齐剩余个体。4.2 核心代码遗传算法主循环与完整参数清单import numpy as np import pandas as pd from scipy.optimize import minimize def genetic_optimize(rets, pop_size100, generations300, mutation_rate0.1, max_weight0.2, seed42): mean rets.mean() * 252 cov rets.cov() * 252 n len(mean) rng np.random.default_rng(seed) pop rng.dirichlet(np.ones(n), sizepop_size) # 精英保留数量 elite_num max(2, int(pop_size * 0.05)) # 记录每代最优适应度用于观察收敛情况 best_hist [] for gen in range(generations): # 变异尺度随代数从 0.05 衰减到接近 0 scale 0.05 * (1 - gen / generations) fit np.array([fitness(w, mean, cov, max_weightmax_weight) for w in pop]) best_hist.append(fit.max()) # 精英个体直接进入下一代 elite_idx np.argsort(fit)[-elite_num:] new_pop list(pop[elite_idx]) # 用选择、交叉、变异补齐剩余个体 while len(new_pop) pop_size: p1 tournament_select(pop, fit, k3, rngrng) p2 tournament_select(pop, fit, k3, rngrng) child arithmetic_crossover(p1, p2, alpha0.5) child mutate(child, mutation_rate, scale, rng) new_pop.append(child) pop np.array(new_pop) # 返回最优权重和每代最优适应度 final_fit np.array([fitness(w, mean, cov, max_weightmax_weight) for w in pop]) best_w pop[np.argmax(final_fit)] return best_w, best_hist这段代码把前面章节的各个函数拼成了完整的优化流程。注意几个连贯的细节rng 是全局共享的随机数生成器锦标赛和变异都使用同一个对象保证整个实验可复现变异尺度随代数衰减让算法前期探索范围大后期收敛精细适应度函数在循环里被调用了两次一次在迭代中一次在最终结果选择时如果嫌慢可以把最后一代的 fit 缓存下来复用。参数清单建议这样设股票数量小于 10 时pop_size 取 50 就够股票数量 20 到 50 时pop_size 需要 150 到 200。generations 取 300 是基准值判断依据是 best_hist 曲线是否在最后 50 代内仍有明显上升如果没有就可以提前停。max_weight 设 0.2 是单票上限 20% 的常见风控约束做实验对照时可以设 0.5 或 1.0观察集中度差异。4.3 跑起来看结果对比遗传算法与 scipy 的求解差异跑完遗传算法之后通常还要跑一次 scipy 的 Markowitz 作为对照。对比时先看两个结果是否都满足约束再看夏普比率的差距最后看权重集中度。一个典型模式是scipy 结果会有明显的高权重集中少数几只股票占了 60% 以上的仓位带 max_weight 约束的遗传算法结果则会把单票权重压到设定上限以内代价是夏普比率小幅下降。这不是遗传算法不如 scipy而是两者在解决不同的问题scipy 解的是无单票上限的连续优化遗传算法解的是带惩罚约束的组合优化。如果遗传算法结果连都没超过 scipy 的无约束 Markowitz不要急着调参数。先检查适应度函数里是不是忘了加 max_weight 惩罚项或者 penalty_lambda 设得太小导致超限权重没有实际被罚住。也可以把 best_hist 画出来看收敛曲线前 50 代应该快速上升后面进入缓慢爬坡状态如果整个曲线几乎平着走大概率是变异概率或者种群初始化那里出了问题。5. 投资组合优化的常见坑与排查从权重失衡到参数不收敛5.1 优化结果全押一只票收益项把惩罚项带偏了现象是遗传算法跑完最优权重里某一只股票的权重几乎贴着 max_weight 上限其余股票权重都趋近于 0组合持仓和满仓一只票没什么区别。原因通常是这只股票的历史收益远高于其他股票夏普比率的收益项占了绝对主导惩罚项的力度压不住。另一个常见原因是协方差矩阵里这只票的方差很小夏普比率算出来极高放大了它的吸引力。解决方法是先提高 penalty_lambda从 10 提到 30 或 50观察权重是否回落如果还是不回落检查 covariance 是不是用了原始样本协方差换成 Ledoit-Wolf 收缩估计量降低极端值的干扰。最直接的办法是给适应度函数加一个 Herfindahl 集中度指标把它作为惩罚项的一部分权重越集中罚得越重。5.2 遗传算法跑 500 代也不收敛早熟收敛和参数不变现象是 best_hist 曲线在前 30 代快速上升后陷入平台期后面几百代最优适应度几乎不变但种群并没有稳定在同一个权重向量上而是反复震荡。原因有两个方向种群规模太小或锦标赛选择压力太大让种群在早期就丢失了多样性陷入局部最优或者是变异概率太低无法在局部最优附近产生足够扰动跳出陷阱。解决方法是先看种群规模pop_size 小于 50 就先提到 100然后调变异参数把 mutation_rate 从 0.1 提到 0.2 或 0.3变异尺度保持 0.05。还可以实现一个简单的自适应策略如果连续 50 代 best 值变化小于千分之一就把变异尺度临时调大两倍扰动一轮后再恢复相当于给算法一次“重启”的机会。5.3 卖空约束在代码里失效归一化顺序写错了现象是最终给出来的最优权重里有负数明明在代码里设了不允许卖空结果却出现短仓。原因是变异函数里先做了归一化再做的 clip或者交叉产生的子代出现了负权重而在整个流程里没有在最后环节强制做非负处理。算术交叉本身不会产生负数问题几乎都出在变异环节先除以总和再 clip负数权重在归一化时会被其他正权重撑住导致最终权重里夹着负的小数值。解决方法是把顺序固定成先 clip 到 0 及以上再除以总和重新归一化。另外在最终结果输出前加一道终检assert (best_w 0).all() and abs(best_w.sum() - 1) 1e-6把能想到的边界错误在交付前拦住。5.4 协方差矩阵算出来的有效前沿全是锯齿日收益率和对数收益率的差别现象是用网格法画有效前沿时相邻点的波动率变化不光滑画出来的前沿像是锯齿状和教科书上的平滑曲线差很远。原因是对收益率的处理方式有问题。直接用日收益率 pct_change 在回测里做协方差会放大极端波动日的影响特别是在股票数量多、样本天数少的区间样本协方差的极端特征值会让前沿出现跳跃。用对数收益率算协方差在日频数据上更接近正态分布假设极端值的影响更平滑。解决方法是把收益率统一成对数收益率np.log(prices / prices.shift(1))尤其是做有效前沿绘图和回测时对数收益率的协方差矩阵更稳定。注意年化方式仍然按 252 乘这一点不受影响。5.5 随机种子不固定导致结果不可复现现象是同一个脚本、同一份数据连续跑两次遗传算法得到的最优权重不完全一样甚至夏普比率有明显差距。原因是遗传算法内部依赖随机数生成器而代码里每调用一次mutate就新建一个default_rng()没有用统一的随机数对象。每个随机数生成器从系统熵获取种子导致整个优化过程不可复现。解决方法是把所有随机数操作集中在同一个default_rng(seed)上作为参数传入 tournament、mutate 和种群初始化不在任何函数内部新建生成器。这样固定 seed 之后任何一次运行结果都完全一致调参时才能对照优化效果。6. 用回测和参数扫描验证优化结果别让遗传算法变成黑匣子6.1 滚动回测怎么设计才不算过拟合遗传算法优化出来的权重只是在历史样本上表现好真正要看的是样本外表现。常见做法是把价格数据切成训练窗口和持有窗口在每个持有窗口开始前用前面的训练数据重新优化权重持有期间按优化权重持有持有结束再滚动重算。def rolling_backtest(prices, window252, step21, top_n5): # prices: 收盘价 DataFramewindow 是训练窗口天数step 是再平衡周期 rets_all np.log(prices).diff().dropna() records [] for start in range(0, len(rets_all) - window, step): train rets_all.iloc[start:start window] w, _ genetic_optimize(train.dropna(), generations200) # 取 top_n 权重排序计算换手率变化 records.append(w) # 可以根据 records 计算平均换手率、平均夏普比率 return records这个回测框架里window 取 252 天是基准代表用一年的历史数据来优化step 取 21 天代表每月调仓一次。看回测结果时不要只盯着最终收益率先看权重序列的稳定性相邻两次优化结果的权重变化如果超过 30%说明优化对历史数据非常敏感样本外大概率不稳。只看总收益最容易把“运气好”误判成“策略有效”。6.2 参数敏感性判断遗传算法是否稳定的最低标准遗传算法的参数不少种群大小、变异概率、惩罚系数、锦标赛 k每个都可能影响最终结果。我常用的验证方法是固定其他参数只动某一个参数各跑 10 次取最优夏普的均值。假如种群大小从 50 提到 200最优夏普均值变化在 5% 以内说明算法对种群大小不敏感模型核心结构是稳的如果变化超过 20%说明这个参数处在敏感区需要重新确定默认值。这个检查至少要在 max_weight 和 mutation_rate 两个参数上各做一轮。另外一个实用习惯是把 Markowitz 的 scipy 结果当成下限基准。遗传算法带额外约束夏普比率比无约束的 Markowitz 低是正常的但如果低太多或者换手率反而比 Markowitz 还高就要回头检查惩罚项和约束设计。我现在做优化第一件事就是固定随机种子和协方差估计方式这两步省掉的排查时间远比写回测花得多。希望帮到你。本文还有配套的精品资源点击获取

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案