
简介面向量化投资学习者的 Python 项目资源包结合 Markowitz 均值-方差模型与遗传算法演示收益最大化与风险最小化之间的最优资产配置。资源共 59 个文件压缩包约 18.68MB包括 28 个 py 脚本、17 个 pdf 文档、csv 测试数据及 README、requirements 等配置覆盖数据读取、协方差计算、有效边界绘制与遗传算法优化便于直接运行和二次开发。已有 1218 人学习下载。读者可借助模块化项目结构复现最小方差组合求解掌握 scipy.optimize 与 DEAP 库在投资组合优化中的实际用法并对照文档理解从 Markowitz 理论到智能优化算法的完整落地过程。适合希望将金融理论转换为可执行代码的 Python 开发者、量化初学者及课程实践者。1. 投资组合优化的两个阶段Markowitz 定问题遗传算法来求解「投资组合优化从 Markowitz 到遗传算法(python代码)」看起来像在讲两个孤立的东西一边是 1952 年提出的均值-方差模型一边是模仿生物进化的随机搜索算法。但你真正上手做回测就会发现它们不是竞争对手而是「目标函数」和「求解器」的关系。Markowitz 把「怎么分配资金」变成一个有明确数学形式的问题而遗传算法是用来解这个问题的工具——尤其是当问题里加入现实约束之后。先说结论如果你的资产数量只有十几只权重连续、允许做空、没有交易成本用 scipy.optimize 几秒钟就能算出有效前沿根本不需要遗传算法。但当你面对的是「每只基金最小申购金额 1000 元」「单只股票仓位不能超过 15%」「换仓成本要计入收益」这类约束时目标函数瞬间变成非凸、带阶梯状离散项、还可能处处不可导。这时候传统优化器会直接翻车或给出根本没法执行的权重而遗传算法能扛住。这篇文章适合正在从教科书走向 python 落地的读者我会把 Markowitz 的标准实现、失效原因、遗传算法的完整代码、参数设置和避坑点一次讲透。2. 用 Python 复现 Markowitz协方差矩阵、蒙特卡洛散点与有效前沿2.1 数据准备用 pandas 读取行情并计算收益率矩阵所有组合优化的起点都不是「价格」而是「收益率」。价格序列非平稳直接用收盘价算协方差会被绝对价格水平带偏。我惯常的做法是把本地导出的日线数据读进来先做缺失值清洗再转成日收益率矩阵。如果你还没有装齐依赖常见做法是在 python 环境里执行pip install numpy pandas scipy matplotlib缺哪个补哪个不要用太老的 Python 版本3.8 起步。import numpy as np import pandas as pd # 假设本地有一份 daily_close.csv列名是资产代码行是日期值是收盘价 # 常见做法是从行情软件导出或用公开接口拉取后在本地存成 CSV df pd.read_csv(daily_close.csv, index_col0, parse_datesTrue) # 前向填充缺失值删除始终没有数据的列 df df.fillna(methodffill).dropna(axis1) # pct_change() 计算日收益率第一行因为前一日未知会被丢弃 returns df.pct_change().dropna() # 年化预期收益日收益率均值 * 252A股习惯用 250 个交易日差异不大 mu returns.mean().values * 252 # 年化协方差矩阵注意是乘 252不是乘 sqrt(252) cov returns.cov().values * 252 print(资产数量:, len(mu)) print(年化收益:, mu) print(协方差矩阵形状:, cov.shape)年化换算为什么这么写日收益率的均值乘以 252 得到年化收益方差乘以 252 得到年化方差所以协方差矩阵直接乘 252。很多新手在这里把协方差乘成 sqrt(252)波动率被低估一个量级后续所有结果都会失真。dropna(axis1)是针对那种某只标的整段缺失的情况如果只是一两天缺数据前向填充就够了。2.2 用蒙特卡洛随机权重画可行集先记住散点图的形状在写优化器之前我建议你先用蒙特卡洛随机权重把「可行集」画出来。这一步有两个目的一是直观看到收益和风险的关系边界在哪里二是验证你的数据清洗和年化换算有没有出错——如果散点图不成簇、形状怪异大概率是协方差矩阵里混入了异常值。rng np.random.default_rng(42) # 固定随机种子后续结果可复现 n_assets len(mu) n_samples 5000 # 生成 5000 组随机权重形状为 (5000, n_assets) weights rng.random((n_samples, n_assets)) # 关键一步归一化让每组权重加和等于 1 weights weights / weights.sum(axis1, keepdimsTrue) # 组合收益 权重 预期收益 port_returns weights mu # 组合波动率用循环写法更直白资产数多再换 einsum port_vols np.zeros(n_samples) for i, w in enumerate(weights): port_vols[i] np.sqrt(w cov w) # 随机权重加和检验 print(随机权重加和:, weights.sum(axis1)[:5])蒙特卡洛采样本质是「暴力穷举」它不保证找到最优但能勾勒出可行区域的轮廓。上边界那一簇点就是有效前沿的大致位置。如果你把散点图的横坐标范围拉得很宽导致刻度挤在一起画出来的图看不清边界可以用plt.xticks(np.arange(0, 0.5, 0.05))手动控制步长再放大保存。import matplotlib.pyplot as plt sharpe port_returns / port_vols # 粗略夏普先不扣无风险利率 plt.scatter(port_vols, port_returns, csharpe, cmapviridis, s8) plt.colorbar(labelSharpe) plt.xlabel(年化波动率) plt.ylabel(年化收益率) plt.title(蒙特卡洛随机权重下的可行集) plt.show()颜色越亮的点代表夏普比率越高它们通常聚集在散点图的左上沿。这个「左上沿」就是后面优化器要追的目标。2.3 用 scipy.optimize 精确求解最小方差组合与最大夏普组合蒙特卡洛只能看大致形状真要拿到精确权重得用数值优化器。scipy 自带的minimize配合 SLSQP 算法是教科书标准做法能处理权重加和为 1 的等式约束和权重非负的边界约束。from scipy.optimize import minimize def portfolio_vol(w): return np.sqrt(w cov w) def portfolio_ret(w): return w mu n_assets len(mu) # 等式约束权重加和为 1 cons ({type: eq, fun: lambda w: w.sum() - 1}) # 边界约束单只资产权重在 0 到 1 之间不允许做空 bounds [(0, 1) for _ in range(n_assets)] # 初值等权组合通常是个不错的起点 w0 np.ones(n_assets) / n_assets # 最小方差组合 res_min_vol minimize(portfolio_vol, w0, methodSLSQP, boundsbounds, constraintscons) min_vol_w res_min_vol.x print(最小方差组合权重:, min_vol_w) print(最小方差组合年化波动:, np.sqrt(min_vol_w cov min_vol_w)) # 最大夏普组合取负号因为 minimize 默认最小化 def neg_sharpe(w): r portfolio_ret(w) v portfolio_vol(w) rf 0.02 # 无风险利率按 2% 记 return -(r - rf) / v res_max_sharpe minimize(neg_sharpe, w0, methodSLSQP, boundsbounds, constraintscons) max_sharpe_w res_max_sharpe.x print(最大夏普组合权重:, max_sharpe_w) print(最大夏普组合年化收益:, portfolio_ret(max_sharpe_w))这里的核心是把「最大化夏普」转换成「最小化负夏普」否则 scipy 的minimize无法直接处理求最大值的问题。SLSQP 对初值敏感如果你是第一次跑等权初值基本够用如果结果出现权重集中在一只资产上的极端情况先检查协方差矩阵是不是有异常值或某列方差极大。到这里Markowitz 的标准路径就走通了。但你要记住一个前提这个解法要求目标函数是凸的、约束是线性的或凸的。下面马上会看到现实世界里的约束是怎么把这条路堵死的。3. 为什么 Markowitz 的标准解法会在实盘翻车3.1 参数估计是黑匣子历史均值当预期收益误差大得吓人Markowitz 模型本身没有错错的是输入参数。模型要求的「预期收益」和「预期协方差矩阵」在数学上是期望值但现实中你能拿到的只有历史收益率样本。用过去一段时间的均值当作未来预期收益本质上是在赌历史会重演而 A 股风格的切换速度往往让这种赌注输得很快。# 用前一半数据估计收益后一半数据当“实际结果” mid len(returns) // 2 mu_train returns.iloc[:mid].mean().values * 252 mu_realized returns.iloc[mid:].mean().values * 252 diff mu_train - mu_realized print(训练期预期收益:, mu_train) print(后验实际收益:, mu_realized) print(估计误差:, diff)跑完这段你会看到误差往往比「预期收益」本身还大。这就是 Markowitz 实战中的核心问题它不是输在数学上是输在参数估计的稳定性上。很多从业者把均值μ固定成历史均值后优化器给出的权重对μ的微小扰动极度敏感——μ一变动最优组合就从重仓白酒变成重仓银行。量化里管这个叫「参数敏感」它比模型本身的复杂度更致命。3.2 现实约束把凸问题变成非凸整手股数、仓位上限与交易成本Markowitz 原始模型里权重可以是任意实数允许卖空时甚至可以是负数。实盘里这就被现实打脸了股票最少买一手、基金有最低申购额、单票仓位有上限、调仓要付佣金和冲击成本。这些约束用数学语言描述全都是非线性或不连续的函数。约束类型Markowitz 标准形式实盘真实形式仓位范围0 ≤ w_i ≤ 1w_i ≤ 单票上限且 w_i 是 100 股的整数倍卖空允许w_i 可小于 0普通账户不允许或成本极高交易成本忽略佣金 滑点 冲击成本随交易额非线性变化现金余额始终满仓必须留现金且现金比例有下限基金申赎忽略最低申购金额、赎回费、到账周期 T1任何一个约束加入后可行集都可能从凸集变成非凸集合。scipy 的 SLSQP 本质是梯度类算法它假设目标函数光滑、约束满足约束品性KKT 条件。面对离散的整手股数约束优化器经常在不可行区域里反复震荡最终返回一个「数学上最优、实盘根本买不了」的权重。# 离散化的直观演示把连续权重映射成整手股数后权重和会偏离 1 def round_to_lot(w, prices, lot_size100, capital1e6): shares np.floor(capital * w / prices / lot_size) * lot_size w_actual shares * prices / capital return w_actual prices np.array([10.5, 23.2, 45.8, 8.9, 32.1]) # 五只股票的价格 w_cont np.array([0.1, 0.2, 0.3, 0.25, 0.15]) # scipy 给出的连续权重 w_real round_to_lot(w_cont, prices) print(离散化后实际权重:, w_real) print(实际权重加和:, w_real.sum())这段代码会输出一个明显偏离 1 的权重和。也就是说优化器告诉你的「最优」在真实交易里根本执行不出来两者的差异会直接吃掉收益。3.3 遗传算法为什么能接手它不要求目标函数光滑遗传算法能处理这些问题的原因很简单它不计算梯度。整个寻优过程只需要一件事——给任意一组权重打分让它返回一个数值。目标函数是不是凸的、可导的、连续的它全都不关心。只要你能把「仓位上限」「整手约束」「交易成本」写进打分逻辑里遗传算法就能在该约束下搜索。这意味着你可以把约束直接做成惩罚项权重不满足条件就扣分而不是试图让优化器「理解」约束。以交易成本为例你不需要把它建模成一个带符号的线性函数只需要在适应度计算时从收益里减去实际成交的佣金和滑点剩下的交给搜索算法去权衡。这几乎是「面向需求」的建模方式和量化实战中「先把约束写进仿真再让算法去找解」的思路完全一致。4. 遗传算法实现组合优化编码、适应度与三个算子的完整代码4.1 编码与初始种群用实数向量表示权重遗传算法的第一步是把「解」编码成染色体。组合优化问题里一个解就是一组权重向量直接用定长实数数组表示即可不需要二进制编码。网上搜「遗传算法python代码详解」出来的教程大多是用单变量函数演示这种编码思路套到组合上会出问题。组合的编码有两个特殊要求维度必须等于资产数量且任意个体加和必须为 1初始种群通常用 Dirichlet 分布生成比均匀分布再归一化更均匀。def random_weights_dirichlet(n_assets, pop_size, alpha, rng): 用 Dirichlet 分布生成初始种群。 alpha 控制权重分布的集中程度alpha1 时接近均匀分布。 return rng.dirichlet(alpha * np.ones(n_assets), sizepop_size) rng np.random.default_rng(42) pop random_weights_dirichlet(n_assets5, pop_size50, alpha1.0, rngrng) print(种群形状:, pop.shape) print(每个个体权重加和:, pop.sum(axis1)[:3])如果你用rng.random生成之后再除以总和也能得到加和为 1 的个体但均匀分布在高维空间里会产生大量接近 0 的极值种群多样性差。Dirichlet 分布天然支持「和恒为 1」且可以用 alpha 调节权重的集中度alpha 越小个体越偏向于押注少数资产alpha 越大越接近等权。建议初始种群用 alpha1.0留一段多样性给后面的交叉变异去消耗。4.2 适应度函数把约束和交易成本都塞进打分逻辑适应度函数是整个遗传算法唯一的目标函数你的所有约束都要在这里体现。通常用夏普比率作为基础分再用惩罚项修正不合规的个体。注意这里和 scipy 优化的本质区别scipy 把约束写在 constraints 参数里遗传算法则把约束写成「不满足就扣分」两种哲学完全不同。rf 0.02 # 无风险利率 def sharpe_ratio(w, mu, cov): 计算一组权重的夏普比率 r w mu vol np.sqrt(w cov w) return (r - rf) / vol def fitness(w, mu, cov, prev_wNone, turnover_penalty0.0): 适应度 夏普比率 - 换手惩罚。 prev_w 是上一期的权重换手大就扣分。 # 先做约束修复每个权重截断到 0~1再归一化成加和为 1 w np.clip(w, 0, 1) if w.sum() 0: return -999.0 w w / w.sum() base sharpe_ratio(w, mu, cov) if prev_w is not None: turnover np.abs(w - prev_w).sum() base - turnover_penalty * turnover return base这段代码里最重要的细节是变异后权重可能不满足「加和为 1」或出现负数所以进入适应度计算时先 clip 再归一化。这个修复操作放在适应度内部比在变异算子之后单独处理要省事得多也避免了对整个种群单独写一套修复逻辑。turnover_penalty是控制换手率的超参数设为 0 时完全不考虑交易成本搜索结果会频繁调仓实盘中如果想限制换手把它设为 0.01~0.05。4.3 选择、交叉、变异三个算子的实现细节遗传算法的三个核心算子每一步都有参数可调也有各自的坑。选择算子我推荐「锦标赛选择」每次随机抽 k 个个体取适应度最高的那个进入下一代。它能保留一定的随机性避免适应度最高的个体垄断下一代导致早熟收敛。def tournament_select(pop, fitness_vals, k3, rngNone): 锦标赛选择从 k 个个体里挑适应度最高的 idx rng.choice(len(pop), sizek, replaceFalse) best_idx idx[np.argmax(fitness_vals[idx])] return pop[best_idx]交叉算子常用「算术交叉」两个父代按比例混合产生两个子代。这里的 Alpha 是混合比例不是遗传算法里的交叉概率注意区分。更激进的 SBX模拟二进制交叉适合连续优化但参数多容易把解空间破坏得太厉害我一般先用算术交叉。def arithmetic_crossover(p1, p2, alpha0.5, rngNone): 算术交叉子代是父代的线性组合。 alpha0.5 时是平均值alpha 越大子代越接近第一个父代。 if rng.random() 0.9: # 交叉概率 0.9 c1 alpha * p1 (1 - alpha) * p2 c2 alpha * p2 (1 - alpha) * p1 else: c1, c2 p1.copy(), p2.copy() return c1, c2变异算子用「高斯扰动」而非随机重置。随机重置会把已经搜索到的优质结构彻底打散而高斯扰动只是在一个小邻域内微调保留原有的权重分布特征。变异率不能设太高否则退化成随机搜索。def gaussian_mutation(ind, mutation_rate0.15, sigma0.05, rngNone): 每个维度以 mutation_rate 的概率加上一个 N(0, sigma) 的噪声。 noise rng.normal(0, sigma, sizelen(ind)) mutated ind np.where(rng.random(len(ind)) mutation_rate, noise, 0.0) return mutated变异率 0.15 的含义是每个资产维度有 15% 的概率被扰动不是「15% 的个体变异」。如果种群有 50 个个体每一代大约有 50×5×15% ≈ 38 个维度被扰动。Sigma 控制扰动幅度权重尺度通常在 0~1 之间0.05 是比较安全的起步值。4.4 主循环精英保留、早停与收敛判断把算子串起来构成主循环。这里有个实战经验一定要保留精英个体。如果不保留适应度最高的个体可能因为交叉和变异被破坏下一代的最佳适应度反而比上一代低整个寻优过程会出现剧烈震荡。POP_SIZE 100 GENS 300 ELITE_SIZE 5 pop random_weights_dirichlet(n_assets, POP_SIZE, alpha1.0, rngrng) best_history [] for gen in range(GENS): fitness_vals np.array([fitness(ind, mu, cov) for ind in pop]) # 记录当前最优 best_idx np.argmax(fitness_vals) best_history.append(fitness_vals[best_idx]) # 精英保留直接复制 TOP5 到下一代 elite_idx np.argsort(fitness_vals)[-ELITE_SIZE:] new_pop [pop[i].copy() for i in elite_idx] # 用选择 交叉 变异补满整个种群 while len(new_pop) POP_SIZE: p1 tournament_select(pop, fitness_vals, k3, rngrng) p2 tournament_select(pop, fitness_vals, k3, rngrng) c1, c2 arithmetic_crossover(p1, p2, alpha0.5, rngrng) c1 gaussian_mutation(c1, mutation_rate0.15, sigma0.05, rngrng) c2 gaussian_mutation(c2, mutation_rate0.15, sigma0.05, rngrng) # 变异后再次修复约束 c1 c1 / c1.sum() c2 c2 / c2.sum() new_pop.extend([c1, c2]) pop np.array(new_pop[:POP_SIZE]) # 早停最优适应度连续 50 代变化小于阈值 if len(best_history) 50: if abs(best_history[-1] - best_history[-50]) 1e-4: print(f第 {gen} 代触发早停) break final_best max(best_history) print(最优适应度:, final_best)精英保留的 ELITE_SIZE 一般取种群大小的 5%~10%太小保不住最优基因太大则种群多样性骤降。早停阈值的 1e-4 是保守值如果你想看到更平滑的收敛曲线可以把阈值设成 1e-5代价是更多迭代时间。种群 100 加上迭代 300 代在五只资产上通常几秒能跑完资产数到 50 只以上建议把种群降到 80、代数降到 200否则一次优化任务会拖到分钟级。5. 组合优化的避坑记录参数设置与 5 个常见翻车点5.1 坑一变异后权重加和不等于 1夏普比率直接崩掉现象前几代适应度还能看到十几代后突然出现一堆负几百的异常值最优解曲线断崖式下跌。原因高斯变异是对每个维度独立加噪声变完之后个体不再满足权重加和为 1。如果你把未修复的个体直接送进夏普计算组合方差可能被放大几百倍适应度瞬间变成负数甚至出现除零错误。解决在适应度函数内部先做「clip 到 0~1 → 重新归一化」不要依赖变异算子自己修复。把归一化放在适应度里还有一个好处不管将来你换用哪种变异算子都不会漏掉约束处理。5.2 坑二协方差矩阵非正定scipy 报错或给出狗啃一样的权重现象最小方差组合的权重出现极端值比如一只资产权重 0.99、其他全是 0波动率却高得不合理或者 scipy 直接返回Positive directional derivative for linesearch。原因协方差矩阵是奇异矩阵或接近奇异常见于资产数量接近交易日数量、多只资产高度相关比如同一行业的多只股票时。逆矩阵不存在时梯度方向计算彻底失真。解决不要直接用原始协方差矩阵求逆在矩阵对角线上加一个很小的数比如cov 0.01 * np.eye(n)这叫脊回归正则化。如果资产是行业 ETF、彼此相关性极高考虑先用 PCA 降维或剔除高度相关的资产再接优化器。5.3 坑三跑十次遗传算法十次结果都不一样的「玄学时刻」现象同一份数据、同样的参数只改了随机种子最优权重从「重仓资产 A」变成「重仓资产 B」适应度却差不多。原因遗传算法是随机搜索算法本身不保证收敛到全局最优。种群较小或迭代代数不足时搜索结果取决于初始种群的采样位置就会出现「多个局部最优解适应度相近、权重完全不同」的情况。解决先固定随机种子复现再调大种群或代数。另一个有效手段是「多次独立运行取平均」跑 5~10 次把每次的最优权重做平均比单次结果稳定得多。这也是我做组合优化时最终提交权重的通用流程。5.4 坑四把日频收益率和年化参数混着用结果全错现象组合收益看着年化 20%但实际按天回测时净值曲线振荡剧烈甚至负收益。原因日频数据训练时均值和协方差已经乘了 252 年化但交易成本、换手率限制等参数仍按日频口径设置。比如按日换手率 1% 算惩罚和按年化换手率惩罚的尺度完全不同两者混用会让适应度函数内部量纲混乱。解决统一口径。所有输入参数要么都是年化口径收益、波动、成本按年化比例要么都是日频口径不要在一个适应度函数里混用。常见做法是全部用年化口径交易成本也折算成『单次换仓对年化收益的影响』。5.5 坑五适应度函数没有包含交易成本回测猛如虎、实盘亏成狗现象遗传算法选出的最优组合在历史回测里夏普很高实盘一跑全是手续费。原因适应度函数用的还是「无摩擦」的净收益调仓越频繁的组合天然占便宜。遗传算法会主动找到高频换仓的解因为它在适应度里完全没有代价。解决在适应度函数中加入换手惩罚项。上一章的fitness函数里turnover_penalty设为 0.02~0.05先跑一遍看看调仓频率是否下降再根据实盘成本调整。这一步是把「理论最优」变成「可执行最优」的关键宁可牺牲一点回测夏普也要让真实账户能拿得住。6. 别直接信遗传算法的结果滚动回测、换手惩罚与稳定性验证遗传算法给你的一组权重只是「历史数据上的一个高适应度解」不是「未来最优解」。我在这上面交过学费用全量历史数据训练出的一组权重夏普比率漂亮得惊人换到样本外跑了一个月就回撤 15%。从那以后组合优化结果上线前必须过三道验证缺一不可。第一道是滚动窗口验证用前 504 个交易日训练预测后面 63 个交易日然后窗口向前滚动切片时用python的数组切片语法处理。如果每一段样本外的夏普比率和训练集差的太多说明模型已经过拟合。train_days 504 test_days 63 step 63 results [] for start in range(0, len(returns) - train_days - test_days, step): train returns.iloc[start:start train_days] test returns.iloc[start train_days:start train_days test_days] # 用 train 重新估计 mu、cov跑遗传算法得到最优权重 # 然后用 test 计算样本外夏普 results.append(test_sharpe)第二道是稳定性检验同一份训练数据固定随机种子之外再换几个种子各跑一遍看输出的权重标准差。若标准差超过 0.1说明解不稳定优先调大种群或早停阈值不要急着上线。第三道是换手惩罚的敏感性测试把turnover_penalty从 0 调到 0.1观察权重和调仓频率的变化。理想情况是惩罚系数在一定区间内变动时组合收益没有剧烈衰减说明优化器找到的是「稳健区域」的解而不是恰好卡在一个尖峰上。很多写 python 量化交易策略代码的朋友最后都卡在这一步。跑了几年这类优化我的体会是遗传算法相比 scipy 的优势不在「找到更优」而在「在复杂约束下仍然能找到一个可执行的解」。真正能长期用的组合不是适应度最高的那个而是换参数、换种子、换时间窗都依然靠前的那个。把搜索算法当成「帮你缩小选择范围」的工具而不是「替你拍板」的黑匣子是我最想让你带走的一句话。希望这些代码和踩过的坑能让你在这方面少走一段弯路希望帮到你。本文还有配套的精品资源点击获取