ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Markowitz与遗传算法双引擎:Python投资组合优化实战解析

Markowitz与遗传算法双引擎:Python投资组合优化实战解析 简介面向量化投资与金融数据分析开发的Python项目围绕马科维茨资产组合理论与遗传算法展开着重演示如何在给定风险水平下最大化预期收益、在期望收益下最小化组合波动。涵盖资产收益率与协方差矩阵计算、有效边界构建、最小方差组合求解以及基于遗传算法的全局搜索优化完整呈现从理论到代码的落地路径。压缩包共59个文件、18.68MB含28个py源码涵盖组合构建、种群进化、优化求解、工具函数等核心模块、17个pdf理论讲解、4个csv价格样本数据以及若干网址链接、说明文档等目录结构明确便于按兴趣取用。已有1218人学习/下载适合想掌握量化组合优化技术的金融分析师、Python工程师和算法研究人员。其中完整包含项目源码、示例数据与配套文档读者可快速复现均值方差模型和遗传算法调参流程并以此为模板扩展自己的资产配置实验降低从理论到实战的转换成本。1. 从 Markowitz 到遗传算法这套 Python 源码包到底能解决什么做投资组合优化的人大概率都经历过这种尴尬用 Markowitz 均值-方差模型算出权重跑回测挺漂亮一上实盘就拉胯换成遗传算法乱搜一通又怕收敛到一堆不可解释的权重上。这套portfolio-optimization-main源码包正好把两条路都铺好了——既给了经典的有效边界与最小方差组合解法也给了基于 DEAP 的遗传算法寻优实现还带一份可直接用的价格数据和 ticker 清单。它不是教学 Demo是能直接pip install -e .跑起来、能看到权重结果和收敛曲线的工程代码。适合三类人正在做量化选股想快速验证组合效果的从业者、被课程设计卡住的学生、以及想搞懂“遗传算法怎么套到金融约束里”的 Python 开发者。我拆完这套包之后最深的感受是理论模型本身不难难的是数据预处理、约束处理和算法早熟这三个坎源码里都有对应的处理。2. Markowitz 均值-方差框架协方差矩阵与最小方差组合的 Python 实现2.1 有效边界的前提两个关键输入必须算对Markowitz 模型所有结论都建立在两个输入上预期收益向量和协方差矩阵。源码包里的portfolio_optimization/portfolio.py和assets.py干的就是这件事——把data/prices.csv读进来算收益率再算协方差。我一般会先用 pandas 读数据看一眼索引和列名import pandas as pd df pd.read_csv(data/prices.csv, index_col0, parse_datesTrue) print(df.head()) print(df.info())这里index_col0是把第一列日期当索引parse_datesTrue让日期列变成DatetimeIndex。后面所有计算都依赖这个索引严格对齐如果日期格式不一致协方差矩阵直接出 NaN这是最常见的翻车点。接下来算日收益率和年化参数returns df.pct_change().dropna() mean_returns returns.mean() * 252 # 年化期望收益 cov_matrix returns.cov() * 252 # 年化协方差矩阵注意这里我乘了 252把日频参数年化。很多新手直接拿日频协方差去优化结果权重形态完全不对因为日频方差太小优化器会倾向于集中持有高波动资产。协方差矩阵的质量直接决定优化结果靠不靠谱。样本协方差在资产数量接近时间序列长度时非常不稳定后面避坑章节我会展开说 Ledoit-Wolf 收缩估计的事。先记住一个原则算协方差之前先检查资产之间的相关性高度相关的两只股票同时放进组合矩阵可能奇异。2.2 用 scipy.optimize 求解最小方差组合有了两个输入最小方差组合就是一个带约束的二次规划问题在权重和为 1 的前提下最小化w^T Σ w。源码包的optimization.py里封装了这个逻辑核心就是 scipy 的minimize。import numpy as np from scipy.optimize import minimize def portfolio_variance(weights, cov_matrix): return np.dot(weights.T, np.dot(cov_matrix, weights)) # 约束权重之和为 1 constraints ({type: eq, fun: lambda w: np.sum(w) - 1}) # 边界默认允许做空则设 None不允许做空则设 (0, 1) bounds tuple((0, 1) for _ in range(len(mean_returns))) # 初值等权重是最稳妥的起点 initial_weights np.array([1 / len(mean_returns)] * len(mean_returns)) result minimize( portfolio_variance, initial_weights, args(cov_matrix,), methodSLSQP, constraintsconstraints, boundsbounds, options{maxiter: 1000, ftol: 1e-9} ) optimal_weights result.x print(optimal_weights)逻辑说明portfolio_variance是目标函数输入权重向量weights输出组合方差constraints保证权重加起来等于 1bounds控制是否允许做空。这里最值得注意的参数是methodSLSQP——它支持等式和不等式约束是这类问题最实用的求解器。ftol1e-9是收敛精度太小会迭代很久太大会得到粗糙权重。如果想去掉无风险资产画出有效边界还需要计算夏普比率最大化组合。源码包example/目录里有完整的脚本它把scipy.optimize的minimize包了一层目标函数换成负夏普比率def negative_sharpe(weights, mean_returns, cov_matrix, risk_free0.0): portfolio_return np.dot(weights, mean_returns) portfolio_vol np.sqrt(portfolio_variance(weights, cov_matrix)) return -(portfolio_return - risk_free) / portfolio_vol把portfolio_variance换成negative_sharpe再跑一遍同样的minimize得到的就是有效边界上的“最优夏普组合”。这里有个实际操作建议不要只跑一个初值多给几组随机初值取结果中目标函数最小的一组。因为 SLSQP 本质是局部优化初值不对会落在局部极值上。2.3 边界条件与约束的写法直接影响权重形态很多人在这一步翻车翻车点集中在两个约束上。第一要不要允许做空。bounds(0, 1)表示不允许做空每个资产权重在 0 到 1 之间boundsNone表示允许做空权重可以为负。注意允许做空时权重之和依然必须等于 1但单个权重可能跑到 -0.5 甚至 -1 以下。源码包里默认的是(0, 1)这个设计是对的——对大多数个人投资者做空既不现实也难以解释。第二约束冲突。如果你同时加了“权重和等于 1”和“每个权重必须大于 0.05”比如限定单只股票仓位不低于 5%那么资产超过 20 只时约束无解。这种约束冲突minimize不会报错只会返回一个不满足条件的“伪最优解”肉眼很难发现。我的习惯是加一个校验函数每次优化完都强制检查权重是否满足所有约束assert abs(np.sum(optimal_weights) - 1) 1e-6, 权重之和偏离 1 assert (optimal_weights -1e-6).all(), 存在负权重检查 bounds 配置这两行检查看起来简单但能挡住绝大多数“优化器灵异结果”的问题。3. 遗传算法寻优种群进化与适应度函数设计3.1 为什么有了 SLSQP 还要上遗传算法Markowitz 的二次规划解法有一个前提目标函数是凸的。但实际做组合优化时目标函数经常不凸——比如加了交易成本、换手率惩罚、行业暴露约束或者你想同时最大化夏普比率、最小化最大回撤、控制换手这些多目标加进来之后问题变成多模态非凸的SLSQP 经常陷在局部极值里出不来。遗传算法的价值就在这它不依赖梯度信息靠种群进化的随机搜索来逼近全局最优。代价是计算量大、调参玄学、结果不稳定。所以源码包的设计很务实——population.py负责种群管理meta.py记录每代的最优适应度和收敛轨迹optimization.py里把遗传算法和 SLSQP 放在同一个接口下面你可以在两者之间切换。3.2 DEAP 种群定义与适应度函数权重编码是成败关键遗传算法的第一步是定义个体。这里有个容易踩的坑个体不能直接裸表示权重数组因为遗传算子交叉、变异会把约束破坏掉。比如两个权重[0.5, 0.5]和[0.6, 0.4]做单点交叉子代可能变成[0.5, 0.4]权重之和成了 0.9直接违反约束。常见做法是个体表示原始权重向量评估前在适应度函数内部做归一化。源码包的population.py里就是这么干的import numpy as np import random from deap import base, creator, tools creator.create(FitnessMax, base.Fitness, weights(1.0,)) creator.create(Individual, list, fitnesscreator.FitnessMax) def normalize_weights(w): w np.abs(w) 1e-8 return w / np.sum(w) def evaluate_portfolio(individual, mean_returns, cov_matrix, risk_free0.0): weights normalize_weights(individual) ret np.dot(weights, mean_returns) vol np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights))) sharpe (ret - risk_free) / vol return (sharpe,)逻辑说明creator.create(FitnessMax, base.Fitness, weights(1.0,))表示这是一个最大化问题只有一个目标evaluate_portfolio接收一个个体原始权重向量先归一化再做计算返回夏普比率。这样设计的好处是无论遗传算子怎么折腾个体永远能在评估时被拉回合法空间。1e-8是防零除的小量极少数个股归一化后权重可能为 0加入极小值避免分母为 0。这里我再提醒一句绝对不要在bounds层面硬卡权重范围然后把越界个体直接淘汰。那样做会让种群快速失去多样性十几代就收敛到几个固定个体上结果比随机权重还差。3.3 遗传算子配置交叉、变异与选择策略DEAP 的遗传算子选择直接决定收敛质量。源码包population.py里用的是锦标赛选择 双点交叉 高斯变异这套组合在实盘场景下表现稳定from deap import base, creator, tools toolbox base.Toolbox() toolbox.register(attr_float, random.uniform, 0, 1) toolbox.register(individual, tools.initRepeat, creator.Individual, toolbox.attr_float, nlen(mean_returns)) toolbox.register(population, tools.initRepeat, list, toolbox.individual) toolbox.register(evaluate, evaluate_portfolio, mean_returnsmean_returns, cov_matrixcov_matrix) toolbox.register(mate, tools.cxTwoPoint) toolbox.register(mutate, tools.mutGaussian, mu0, sigma0.2, indpb0.2) toolbox.register(select, tools.selTournament, tournsize3)参数说明attr_float生成 0-1 之间的随机浮点数作为基因每个个体包含n个基因对应n只资产cxTwoPoint是双点交叉适合实数编码mutGaussian是高斯变异mu0表示变异围绕原值扰动sigma0.2是扰动幅度indpb0.2是每个基因发生变异的概率。selTournament的tournsize3表示每次从 3 个个体里挑胜者。tournsize 调大选择压力大、收敛快但容易早熟调小则多样性好但收敛慢。我一般从 3 开始调。然后是进化主循环。源码包meta.py里记录了每一代的适应度方便画收敛曲线from deap import algorithms population toolbox.population(n200) hof tools.HallOfFame(1) stats tools.Statistics(lambda ind: ind.fitness.values[0]) stats.register(max, np.max) stats.register(mean, np.mean) population, logbook algorithms.eaSimple( population, toolbox, cxpb0.7, mutpb0.3, ngen100, statsstats, halloffamehof, verboseTrue )参数说明cxpb0.7是交叉概率mutpb0.3是变异概率ngen100是进化代数halloffame保底最优个体。整个eaSimple跑完后最优解在hof[0]里。这里有个实操经验先跑 30 代看logbook里的 mean 和 max 曲线。如果 max 在 20 代以内就卡平说明早熟了如果 30 代还在涨说明可以加大ngen到 150 以上。盲目一上来ngen500是浪费机器时间90% 的问题 100 代以内已经收敛或确认早熟。3.4 和源码包里文件对应你的落点在哪拆这套包你会发现作者把遗传算法拆成了三个文件而不是一个大杂烩population.py管理种群的创建、交叉、变异meta.py管元信息每一代的适应度记录、收敛曲线、个体历史optimization.py是统一入口既能跑 SLSQP 也能跑遗传算法。这种拆分的好处是你可以只改population.py里的算子配置不需要动优化流程。如果你想把问题从单目标改成多目标——同时考虑夏普和最大回撤——DEAP 还支持weights(1.0, -1.0)定义多目标适应度配合selNSGA2选择算子就能用上 NSGA-II。源码包里没做这个扩展但架构上留了接口这是我最欣赏这个包的地方。4. 工程落地数据清洗、代码结构与环境安装4.1 数据文件长什么样哪些该用哪些是坑解压后data/目录下有几个文件我建议先对它们做个盘点文件内容建议prices.csv主价格数据日期索引 各资产价格列主力数据直接用tickers.csv资产代码清单对应价格列名配合列名核对prices_olc.csvOpen/Low/Close 格式的价格变体做 OHLC 相关回测时用prices - Copy.csv明显是手工另存的副本正常跑用不到建议忽略我建议先用tickers.csv和prices.csv的列名做一次对齐检查python -c import pandas as pd prices pd.read_csv(data/prices.csv, index_col0, parse_datesTrue) tickers pd.read_csv(data/tickers.csv) print(prices.columns.tolist()) print(tickers.head()) 如果价格列和 ticker 列表不一致通常是因为有些股票停牌退市导致 CSV 里多了空白列。这种情况要决定是删列还是填充千万不要带着 NaN 去算协方差。源码包根目录有一个setup.py说明这是一个标准 Python 包安装方式是pip install -e .-e是 editable 模式改代码不用重装。装完就能import portfolio_optimization。依赖在requirements.txt里核心是这几个库pandas1.3.0 numpy1.21.0 scipy1.7.0 matplotlib3.4.0 deap1.3.1这些都是组合优化的标配。deap是遗传算法框架scipy负责 SLSQP。这里提醒一句如果系统里 Python 是 3.10 以上deap大概率需要 pip 重新编译记得装wheel包再装deap否则会报编译错误。4.2 代码结构拆解哪些文件值得重点读现在拆一下源码包的主体逻辑portfolio_optimization/目录下的文件分工assets.py资产数据管理读 CSV、日收益率、协方差矩阵都从这出portfolio.py组合对象持有权重、收益、方差、夏普等属性optimization.py优化入口同时支持 SLSQP 和遗传算法population.py遗传算法种群相关个体定义、交叉、变异、选择meta.py进化过程的元数据记录收敛曲线画图用的数据在这里utils.py工具函数包括约束校验、归一化、数据对齐bloomberg辅助模块处理 BLOOMBERG 格式的下载数据我的阅读顺序建议是先assets.py和utils.py搞清楚数据流再portfolio.py理解组合对象之后optimization.py看整体流程最后才进population.py和meta.py研究遗传算法细节。很多人一上来就啃遗传算法忽略数据层结果调了三天算子发现是数据对齐问题血泪教训。test/目录里应该有简单的单元测试跑一下可以快速验证环境装对了python -m pytest test/ -v如果测试因为缺数据文件失败检查一下当前工作目录是不是源码包根目录——测试里大概率用相对路径读data/prices.csv。4.3 跑通 Example 的最小验证路径example/目录下的脚本是整个包开箱即用的入口。以典型的“优化最小方差组合”为例python example/example_minimum_variance.py脚本内部做的事就是读价格数据 → 算收益率 → 算协方差 → 跑 SLSQP → 打印最优权重。跑通这个脚本说明环境、数据、代码链路都正常。bloomberg模块我单独说一句如果你是从 BLOOMBERG 终端导出的数据列名格式通常带US EQUITY这类后缀和源码包里的tickers.csv可能对不上。常见做法是在assets.py里做一次列名清理把后缀剥离df.columns [col.split( )[0] for col in df.columns]5. 避坑排查协方差不稳定、早熟收敛与权重越界5.1 协方差矩阵非正定导致优化器报错或结果怪异现象跑minimize时 SLSQP 不报错但最优权重里出现极端值比如某只股票权重 0.98其他全是 0.001或者直接提示LinAlgError: Matrix is not positive definite。原因样本协方差矩阵在资产数量接近时间序列长度时是病态的尤其当两只资产高度相关比如同一行业的两只龙头股时矩阵接近奇异。SLSQP 内部要解线性方程组奇异矩阵直接崩。解决用 Ledoit-Wolf 收缩估计替代样本协方差。这是业界最常用的做法不需要手动调参from sklearn.covariance import LedoitWolf lw LedoitWolf().fit(returns) cov_matrix lw.covariance_ * 252LedoitWolf会把样本协方差往单位矩阵方向收缩收缩强度由数据自动决定。跑完你会看到权重分布合理很多不再出现单只股票独占的情况。5.2 遗传算法早熟收敛几十代后适应度卡死现象logbook里显示 max 适应度在第 15 代就停止增长后续 80 多代一点变化没有。最终权重集中到少数几只股票上多样性极差。原因种群多样性丢失。最常见的原因是变异概率太低mutpb0.1以下或变异幅度太小sigma0.05基因型趋同后交叉算子产生不出新个体。解决先调大变异强度——把sigma从 0.2 提到 0.5indpb从 0.2 提到 0.4mutpb提到 0.5同时把tournsize从 3 降到 2降低选择压力。如果还不行加tools.selRandom作为备用选择算子每代随机引入 5% 的新个体。这些参数修改都在population.py里不需要动其他文件。5.3 权重出现负值或权重之和偏离 1现象优化结果里某个权重是 -0.23但你的本意是不允许做空或者归一化后权重之和是 0.97 而不是 1。原因第一种情况纯粹是bounds配置问题——没设(0, 1)约束第二种情况通常是遗传算法评估函数里归一化逻辑写错比如直接用w / np.sum(w)而没有对负权重取绝对值。解决如果是 SLSQP检查bounds参数如果是遗传算法在normalize_weights函数里先np.abs(w)再归一化。这里我多说一句np.abs会让负权重变成正权重相当于方差惩罚可能改变问题含义。如果项目不允许做空更好的做法是在评估函数里直接给负权重个体一个惩罚适应度def evaluate_portfolio(individual): if (np.array(individual) 0).any(): return (-1e6,) weights normalize_weights(individual) ...这样遗传算法自己会淘汰负权重个体比硬钳制更平滑。5.4 价格数据时间窗口不一致导致协方差矩阵有 NaN现象returns.cov()结果里有 NaN优化器直接挂掉或者权重结果全是 NaN。原因prices.csv里不同股票的起始日期不同有的从 2015 年开始有的从 2018 年开始。pct_change()之后早期数据是 NaNcov()默认 pairwise 计算会忽略 NaN 对但如果某只股票长期缺失得到的协方差矩阵就不是正定的。解决先统一时间窗口再用dropna()清理。源码包utils.py里应该有对齐函数我习惯这么写common_index prices.dropna(howall).index prices prices.loc[common_index].fillna(methodffill).dropna(axis1) returns prices.pct_change().dropna()这里的逻辑是先去掉全部为 NaN 的日期再向前填充个别停牌日最后删除仍有缺失的资产列通常是上市太晚的次新股。做完这一步再算协方差基本不会出 NaN。6. 进阶技巧把有效边界画出来再用滚动窗口验证6.1 蒙特卡洛模拟绘制有效边界拿到协方差矩阵和期望收益后最快的验证方式是蒙特卡洛模拟——随机生成大量权重组合把它们全部画在“收益率-波动率”平面上边界就是有效边界。这比只求一个最优解更能看清楚风险和收益的取舍空间import pandas as pd import numpy as np import matplotlib.pyplot as plt def monte_carlo_efficient_frontier(mean_returns, cov_matrix, num_portfolios10000): results np.zeros((3, num_portfolios)) num_assets len(mean_returns) for i in range(num_portfolios): weights np.random.random(num_assets) weights / np.sum(weights) port_ret np.dot(weights, mean_returns) port_vol np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights))) sharpe (port_ret - 0.0) / port_vol results[0, i] port_ret results[1, i] port_vol results[2, i] sharpe return results results monte_carlo_efficient_frontier(mean_returns, cov_matrix, 10000) plt.scatter(results[1], results[0], cresults[2], cmapviridis, s4) plt.colorbar(labelSharpe Ratio) plt.xlabel(Volatility) plt.ylabel(Return) plt.show()这个图能直观告诉你两件事你的最优解是不是落在边界上如果不是说明约束加多了以及有效边界凸不凸、合不合理。我每次调参数都把这张图打出来配合遗传算法的收敛曲线一起看。6.2 滚动窗口和“先用后验证”习惯历史最优不等于未来最优。我做组合优化有一条强制流程先用前 250 个交易日优化出权重然后用接下来 20 个交易日验证这个权重和 Sharpe 的关系。这个 rolling-window 验证不算复杂但能把 Markowitz 模型打回原形——很多组合回测漂亮滚动一验证就露馅。def rolling_walk_forward(returns, window250, hold20): sharpe_list [] for start in range(0, len(returns) - window - hold, hold): train returns.iloc[start:startwindow] test returns.iloc[startwindow:startwindowhold] mean_ret train.mean() * 252 cov_mat train.cov() * 252 # 这里用 SLSQP 求最大夏普组合代码与第 2 章相同 opt_weights optimize_max_sharpe(mean_ret, cov_mat) test_ret test.dot(opt_weights) sharpe_list.append(test_ret.mean() / test_ret.std() * np.sqrt(252)) return sharpe_list这套验证跑完你对这套源码包里的每个参数都会产生不一样的敬畏——协方差估计方式、遗传算法的种群大小、约束边界任何一个改动都可能让滚动验证的 Sharpe 分布整体偏移。从那以后我每次做组合优化都不再迷信单个最优解而是强制在优化前先定好验证窗口、优化后跑一遍 walk-forward再回头调参数。这套源码包给了我一个能同时做“历史优化”和“样本外验证”的完整闭环省掉了重写底层逻辑的时间——希望帮到你。本文还有配套的精品资源点击获取
返回列表