Markowitz与遗传算法:Python投资组合优化的实践与避坑指南

发布时间:2026/9/23 4:33:04
Markowitz与遗传算法:Python投资组合优化的实践与避坑指南 简介投资组合优化是资产管理的重要课题该资源以Markowitz均值-方差模型和遗传算法为主线提供可直接运行的Python代码与配套文档适合金融量化初学者、算法研究者和需要落地资产配置策略的开发者。资源包共59个文件大小18.68MB包括28个py脚本覆盖资产收益率计算、协方差矩阵构建、有效边界求解以及遗传算法进化流程17个pdf和1个md文档辅助理解理论与代码逻辑4个csv样例数据供直接测试另附URL链接、requirements依赖清单和LICENSE。已有1218人学习下载。读者可借助scipy.optimize和DEAP框架快速实现最小方差组合与全局优化并结合prices、tickers等数据复现经典案例通过README和清晰目录结构进一步扩展自定义策略。1. 投资组合优化为什么 Markowitz 之后还需要遗传算法拿到资产历史价格第一反应是用 Markowitz 求出最小方差组合画一条有效前沿好像事情就结束了。但真正落过地的朋友都知道真实场景里既有禁止做空的上下限约束又有非线性的目标函数市场数据还经常带着缺失值和重复列scipy 一条 minimize 调下去经常会翻车。这份 portfolio-optimization 代码包把两条路线都做全了一条是用 numpy、pandas、scipy 实现的 Markowitz 均值-方差框架直接算最小方差组合和有效前沿另一条是用遗传算法做组合优化把种群初始化、交叉、变异和适应度评估拆成 population.py 和 optimization/meta.py 这样的工程模块。适合正在入门量化组合管理的 Python 从业者也适合想看看一个完整的小型优化工程怎么组织数据、模块和测试的人。2. 代码包结构从 prices.csv 到 optimization/meta.py 的阅读路径解压压缩包之后别急着跑先把目录结构读一遍。根目录下是 README.md、setup.py、requirements.txt、LICENSE 和 .gitignore标准 Python 工程布局数据集中在 data 目录核心代码在 portfolio_optimization 包里example 目录里放着演示用的测试脚本。我的习惯是先看 setup.py 和 requirements.txt搞清楚依赖管理方式再打开 data 目录确认数据形态最后读源码。2.1 数据文件怎么区分data 下放了四个 CSV命名已经暴露了各自的定位。prices.csv 是主行情文件常规格式是日期做索引、每列一个资产、单元格为收盘价tickers.csv 存资产代码用来把列名映射成可读的证券名称prices_olc.csv 带 Open、Low、Close 的日线数据比纯收盘价多保留了日内波动信息适合做更细粒度的风险计算。prices - Copy.csv 从命名看就是备份或旧版本典型的手动复制产物如果混进读取逻辑会出现同一个资产出现两列的情况。读取前先做一次数据体检。这一步能挡掉后续 80% 的诡异结果我一般用 pandas 做四件事确认日期被正确解析、检查列名是否重复、统计缺失值、确认没有重复索引。import pandas as pd df pd.read_csv(data/prices.csv, index_col0, parse_datesTrue) print(df.head()) print(df.info()) print(缺失值数量, df.isnull().sum().sum()) print(重复日期数量, df.index.duplicated().sum()) print(重复列数量, df.columns.duplicated().sum())这段代码里 parse_datesTrue 会把第一列解析成 DatetimeIndex这是后续 pct_change 能按时间对齐的前提。df.columns.duplicated() 专门用来抓 prices - Copy.csv 这种备份混入导致的重复列问题如果返回结果里有 True就要回到数据准备环节去重。2.2 核心模块的职责边界portfolio_optimization 包的拆分思路是典型的数据层、计算层、优化层三层架构。assets.py 负责资产数据模型把 CSV 读进来的价格序列包装成资产对象持有收益率数据但不参与优化逻辑portfolio.py 是组合层输入一组权重和资产收益率输出组合收益、方差、夏普比率这类指标optimization/meta.py 放的是元启发式优化入口遗传算法的主循环应该在这个目录下population.py 则单独管理种群层面的操作比如个体的初始化、交叉和变异。utils 模块放的是一些通用工具bloomberg 模块是数据源适配器如果没订阅 Bloomberg 终端直接用 CSV 数据即可。这种拆分最大的好处是替换数据源时不用动优化代码想从 CSV 切到其他行情源只要 assets.py 和 bloomberg 模块的接口保持一致。2.3 环境准备与首次运行requirements.txt 里列的核心依赖是 numpy、pandas、scipy、matplotlib如果遗传算法部分基于 DEAP 实现DEAP 也会在里面。setup.py 的存在意味着这个包可以通过开发模式安装我建议在虚拟环境里操作避免污染全局 Python。python -m venv .venv source .venv/bin/activate # Windows 下是 .venv\Scripts\activate pip install -e . pip install -r requirements.txt python -m pytest example/test-e 参数是把当前目录以开发模式安装之后改动包内源码不用重复安装。最后一行用 python -m pytest 而不是直接敲 pytest是为了确保测试运行在当前虚拟环境的解释器下import 路径不会串到全局 site-packages。example/test 里如果只有演示脚本没有 pytest 用例可以直接运行python example/test/xxx.py看输出。3. Markowitz 最小方差组合年化参数与 scipy 的四个设置Markowitz 理论的核心是把资产收益的历史分布压缩成两个统计量均值和协方差。均值刻画预期收益方差刻画风险组合优化的目标就是在给定收益下最小化方差或者在给定风险下最大化收益。听起来简单实际编码时有一堆细节决定结果是否可信。3.1 均值-方差框架的三个前提第一个前提是收益用均值衡量这要求收益率序列基本平稳如果数据里混入暴涨暴跌的异常时段均值会被拉偏。第二个前提是风险用方差衡量这意味着上行波动和下行波动被同等对待但现实中投资者更厌恶下行风险。第三个前提是投资者风险厌恶即同等收益下一定选方差更小的组合。这三个前提在代码里的直接体现是两个年化参数日收益率均值乘以 252 得到年化收益率日协方差矩阵乘以 252 得到年化协方差。252 是 A 股和美股通用的交易日数量不是 365不少初学的人在这一点上翻过车直接用 365 会让年化波动率被系统性高估。3.2 收益率、协方差与最小方差组合的实现最小方差组合的求解本质上是一个带约束的二次规划问题。目标函数是 w^T Σ w约束是权重之和等于 1再加上每个权重的上下限。scipy.optimize.minimize 里的 SLSQP 方法能同时处理等式约束和边界约束是这里最常用的求解器。import numpy as np import pandas as pd from scipy.optimize import minimize df pd.read_csv(data/prices.csv, index_col0, parse_datesTrue) returns df.pct_change().dropna() mean_returns returns.mean() * 252 cov_matrix returns.cov() * 252 n_assets len(mean_returns) def portfolio_variance(weights): return np.dot(weights.T, np.dot(cov_matrix, weights)) def portfolio_return(weights): return np.dot(weights, mean_returns) constraints ({type: eq, fun: lambda w: np.sum(w) - 1}) bounds tuple((0, 1) for _ in range(n_assets)) init_weights np.repeat(1 / n_assets, n_assets) res minimize(portfolio_variance, init_weights, methodSLSQP, boundsbounds, constraintsconstraints) print(最优权重, res.x) print(年化波动率, np.sqrt(portfolio_variance(res.x))) print(年化收益, portfolio_return(res.x))df.pct_change() 把价格序列转成日收益率dropna 去掉转换后第一行的 NaN。协方差矩阵乘以 252 是年化方差所以后面算波动率时要再开根号。constraints 里的等式约束保证权重和等于 1bounds 里每个维度都限制在 0 到 1 之间含义是不允许做空、不允许加杠杆。初始化权重用等权组合这是最稳妥的起点SLSQP 对初始点有一定敏感性等权比随机权重更容易收敛。注意这个求解结果默认不做任何交易成本或流动性假设只回答“纯数学意义上的最小方差组合是什么”。3.3 有效前沿扫描有效前沿是给出一系列目标收益对每个目标收益都求一次最小方差。关键技巧是把目标收益写进等式约束让优化器在“权重和等于 1”之外再满足“组合收益等于目标值”这个条件。target_returns np.linspace(mean_returns.min(), mean_returns.max(), 30) frontier [] for target in target_returns: constraints [ {type: eq, fun: lambda w: np.sum(w) - 1}, {type: eq, fun: lambda w: portfolio_return(w) - target} ] res minimize(portfolio_variance, init_weights, methodSLSQP, boundsbounds, constraintsconstraints) if res.success: frontier.append((target, np.sqrt(portfolio_variance(res.x)))) frontier np.array(frontier)这里比较隐蔽的一个坑是 lambda 闭包延迟绑定。如果在循环里写lambda w: portfolio_return(w) - target所有闭包拿到的都是循环结束后的最后一个 target这是一个经典的 Python 陷阱。解决方式是用默认参数绑定当前值写成lambda w, ttarget: portfolio_return(w) - t。如果发现前沿曲线出现锯齿或跳变优先检查这里。检查 res.success 很重要因为 SLSQP 在目标收益超出可行范围时会返回失败直接把失败结果拼进 frontier 会让曲线出现奇怪的拐点。如果某些 target 点求解失败可以先获得无约束的全局最小方差和最大收益点再在两者之间取目标收益区间。绘制前沿曲线时把 frontier 的第一列作为 x 轴收益第二列作为 y 轴波动率画出来的就是教科书里的子弹形曲线。也可以用 matplotlib 把散点标在图上标注出最小方差组合的位置。4. 遗传算法部分种群初始化、交叉变异与收敛判断Markowitz 求解器在大部分线性约束场景下够用但一旦目标函数出现多峰、约束变成非线性或者持仓需要满足离散限制scipy 的 SLSQP 就显得吃力。遗传算法是一种不依赖梯度的全局搜索方法通过模拟自然选择过程在一组候选解之间反复执行选择、交叉、变异逐步逼近全局最优。4.1 为什么不用 SLSQP 一把梭SLSQP 的核心问题是局部搜索和导数依赖。目标函数只要稍微带点非线性或者可行域存在多个分离区域初始点选不好就容易陷进局部最优。另外如果组合优化里加入“单只股票权重不能低于 1%”或“仓位必须是 5% 的整数倍”这类离散约束scipy 的标准接口处理起来非常别扭。遗传算法的优势在于它只依赖适应度函数的值不要求函数可导。种群天然是多点并行搜索即使有局部最优交叉和变异操作也能把一部分个体推离陷阱区域。代价是计算量大参数多收敛性没有二次规划那么有保证。4.2 种群初始化与适应度函数种群里每个个体就是一个权重向量。初始化时最干净的方案是用 Dirichlet 分布直接生成和为一的非负权重而不是先生成均匀随机数再手动归一化。Dirichlet 的参数全部取 1 时生成的向量在单纯形上均匀分布天然满足权重约束。def init_population(pop_size, n_assets, random_seed42): rng np.random.default_rng(random_seed) return rng.dirichlet(np.ones(n_assets), sizepop_size) def fitness(weights, mean_returns, cov_matrix, lambda_risk3.0): ret np.dot(weights, mean_returns) var np.dot(weights.T, np.dot(cov_matrix, weights)) return ret - lambda_risk * varfitness 函数把风险厌恶系数 lambda_risk 显式写出来lambda_risk 越大组合越倾向于低波动。这个写法的好处是把“收益”和“风险”之间的权衡暴露成可调参数不用反复改目标函数结构。实际使用中lambda_risk 在 2 到 5 之间比较常见具体取值取决于个人风险偏好。4.3 选择、交叉、变异与主循环遗传算法的主循环可以拆成四个函数选择、交叉、变异和演化驱动。选择采用锦标赛选择从种群中随机抽 k 个个体挑适应度最高的进入下一代交叉采用凸组合方式子代权重是父代权重的线性插值这种交叉方式能保证子代权重依旧满足和为一的约束变异则是在个体上加高斯噪声后重新归一化。def select(pop, fitness_scores, k2): idx np.random.choice(len(pop), sizek, replaceFalse) best idx[np.argmax(fitness_scores[idx])] return pop[best] def crossover(parent_a, parent_b, alpha0.5): return alpha * parent_a (1 - alpha) * parent_b def mutate(individual, mutation_rate0.2, sigma0.05): if np.random.rand() mutation_rate: noise np.random.normal(0, sigma, sizelen(individual)) individual individual noise individual np.clip(individual, 0, None) if individual.sum() 0: individual individual / individual.sum() return individual def run_ga(mean_returns, cov_matrix, pop_size80, generations300): n_assets len(mean_returns) pop init_population(pop_size, n_assets) best_score -np.inf for _ in range(generations): scores np.array([ fitness(ind, mean_returns, cov_matrix) for ind in pop ]) elite_idx np.argmax(scores) elite pop[elite_idx].copy() new_pop [elite] while len(new_pop) pop_size: parent_a select(pop, scores) parent_b select(pop, scores) child crossover(parent_a, parent_b) child mutate(child) new_pop.append(child) pop np.array(new_pop) if scores.max() best_score: best_score scores.max() best_idx np.argmax([ fitness(ind, mean_returns, cov_matrix) for ind in pop ]) return pop[best_idx], best_scoreelite 变量保留了每代最优个体确保全局最优解不会在交叉变异过程中丢失这种做法叫精英保留。锦标赛选择中的 k 参数默认取 2k 越大选择压力越大种群越早收敛但过早收敛可能丢掉多样性。alpha 取 0.5 表示子代是两个父代的算术平均alpha 偏离 0.5 会让子代更接近某一个父代。主循环里每一代都重新计算全部个体的适应度计算成本主要在协方差矩阵乘法上。当资产数量在 20 到 50 个、种群规模在 80 左右时300 代一般在几秒内能跑完这个量级对个人电脑完全友好。4.4 参数经验值和收敛判断遗传算法的参数设置直接决定结果质量给一个我常用的经验区间作为起点。种群规模太小容易早熟规模太大计算慢变异率太高会退化成随机搜索太低又跳不出局部最优。参数经验取值说明pop_size50 ~ 100种群个体数量资产数越多越取大generations200 ~ 500迭代代数看收敛曲线再定mutation_rate0.1 ~ 0.3每个个体发生变异的概率sigma0.02 ~ 0.05高斯扰动的标准差相对权重幅度而言lambda_risk2.0 ~ 5.0风险厌恶系数越大越偏好低波动elitism1 ~ 5每代保留的最优个体数收敛判断我是看每一代最好适应度的曲线而不是死等固定代数。如果连续 30 代最好适应度变化不超过 1%基本可以认为收敛了。建议在 run_ga 里把每代 best_score 都存下来跑完直接画一条折线这条线能直观告诉你该加代数还是该调变异率。5. 避坑指南权重归一化、矩阵奇异与回测偏差代码包拿到手能跑通只是第一步真正决定结果可不可信的是那些容易翻车的细节。下面五个坑是我在复现这类组合优化项目时踩过的每一条都按现象、原因、解决三个层次拆开讲。5.1 坑一权重归一化的时机错了现象scipy 求解器返回的权重加起来不等于 1或者权重出现明显异常值但 res.success 仍然是 True。原因把weights weights / np.sum(weights)写在了 portfolio_variance 函数内部但优化器实际优化的是归一化之前的原始权重。等式的束np.sum(w) - 1针对的是原始权重梯度方向被内部归一化干扰约束形同虚设。解决最干净的方式是让优化器直接操作原生权重归一化只发生在数据生成阶段。对 GA 来说用 Dirichlet 分布初始化种群从源头保证每组权重和为一对 scipy 来说把权重归一化逻辑全部删掉约束全部放在 minimize 的 constraints 和 bounds 参数里。如果一定要在 fitness 函数内部归一化那传入优化器的原始向量必须另有含义且不相等的约束要废止。提示判断归一化是否正确的快速标准是看最优解的权重之和。用np.sum(res.x)检查偏离 1 超过 1e-6 就意味着哪里出了问题。5.2 坑二协方差矩阵奇异或非正定现象numpy 计算协方差矩阵特征值时出现负特征值或者 scipy 求解时报LinAlgError也有 minimize 返回成功但组合方差是负数的倒置现象。原因资产数量大于观测期长度也就是常说的高维小样本问题或者数据里存在完全相同的两列比如前文提到的 prices - Copy.csv 混入导致协方差矩阵秩亏。解决先检查矩阵的秩然后做收缩估计这是金融领域处理协方差矩阵的常见做法。收缩的核心思想是把样本协方差矩阵向单位矩阵方向收缩提高数值稳定性。def shrunk_cov(cov_matrix, shrinkage0.1): n cov_matrix.shape[0] target np.eye(n) * np.trace(cov_matrix) / n return (1 - shrinkage) * cov_matrix shrinkage * target收缩系数 0.1 表示用 10% 的目标矩阵混合原始协方差这个值可以根据资产数量调整。另外建议在读取数据后加一行去重逻辑df df.loc[:, ~df.columns.duplicated()]这是防复制文件混入的最快手段。5.3 坑三备份 CSV 混进数据现象最优权重里有几个资产各占一半凑成另一个资产的影子整体表现看着合理但组合无效。原因prices - Copy.csv 这类备份文件被读入同一个资产的价格序列出现两列优化器把同一资产拆成两个独立维度权重分散但净资产暴露不变。解决数据目录里只保留确定要用的文件。我一般在项目目录下建一个 data/raw 放原始数据data/processed 放清理后的数据代码只从 processed 读取。这样即使 raw 里有备份文件也不会影响运行结果。5.4 坑四GA 适应度方向搞反现象遗传算法收敛到一组风险极高、收益很差的权重或者每一代最好适应度持续下降。原因scipy 的 minimize 是最小化方向而遗传算法一般按最大化设计。直接把 portfolio_variance 当作 fitness 函数没加负号选择阶段保留的是方差最大的个体算法越跑越差。DEAP 里如果 FitnessMax 和 FitnessMin 定义错了也会出现同样的问题。解决写 fitness 函数时先打印几个随机个体的得分确认“得分高”对应“收益高、风险低”。在 run_ga 主循环里用 np.argmax 选择精英个体并让 fitness 返回ret - lambda_risk * var而不是var本身。最稳妥的做法是在主循环里输出每一代 best_score观察它是否单调上升。5.5 坑五全样本回测的前视偏差现象回测结果非常漂亮夏普比率高得离谱但实盘一塌糊涂。原因用全部历史数据计算 mean_returns 和 cov_matrix再对同一段数据做优化和评估相当于把未来的信息提前放进了参数估计里。这在学术上叫前视偏差也叫数据泄漏是组合优化最容易犯的错误。解决把数据按时间切分为训练集和测试集只在训练段计算参数测试段只做验证。train returns.loc[:2023-01-01] test returns.loc[2023-01-01:] train_mean train.mean() * 252 train_cov train.cov() * 252 # 此时对 test 段不做任何参数估计只把训练段得到的最优权重拿到 test 上算出收益和方差时间切分是回溯测试的基本纪律。从那以后我每次跑新的组合优化数据都强制走一遍训练集/测试集切分的流程这条纪律帮我挡掉了至少三个看着惊艳实际无效的策略。6. 验证与技巧夏普比率对比和参数敏感性检查跑完 Markowitz 和遗传算法手上有了两组权重直接说哪组更好不如让数据说话。我的验证流程分两步先用夏普比率做横向对比再做参数敏感性检查确认结果不是调参调出来的。def sharpe_ratio(weights, mean_returns, cov_matrix, rf0.02): ret np.dot(weights, mean_returns) vol np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights))) return (ret - rf) / volrf 是无风险利率我习惯取 2%。把两组最优权重分别代入在同一个测试集上算夏普比率数值高的一组胜出。注意夏普比率对波动率的极端值很敏感如果某组权重算出年化波动率超过 40%说明风险已经失控再高的夏普也不可靠。参数敏感性检查的做法是只动一个参数其他全部固定观察结果的标准差。比如把 mutation_rate 从 0.05 到 0.4 按 0.05 步长扫一遍记录每组权重的夏普比率。如果夏普比率随参数剧烈波动说明算法不稳参数一旦换了环境就会失效如果结果在合理范围内波动说明策略对参数不敏感可信度更高。 markdown 的表格里不用再放大段文字了直接给一个检查清单收尾。从那以后我每次拿到新的组合优化代码都要强制走一遍这套流程先做数据体检再验协方差矩阵的秩跑两组优化最后在时间切分的验证集上对比夏普比率和参数敏感性。这套流程帮我避开了很多坑希望帮到你。本文还有配套的精品资源点击获取