1. 这不是金融课,是用Python把“鸡蛋怎么放篮子”算清楚的实战手册
“Portfolio Optimization in Python”——光看标题,很多人第一反应是:哦,量化投资、高大上的金融工程、得懂随机过程和凸优化。其实完全不是。我带过三届实习生,从会计专业转行做数据分析的、学机械设计的、甚至刚考完CPA的财务人员,只要会写for循环、能看懂pandas.DataFrame,两周内就能跑通一个可解释、可调整、能真正辅助决策的资产配置模型。它解决的核心问题特别朴素:手上有100万,股票、债券、黄金、REITs、现金该怎么分?不是靠感觉,不是听专家喊单,而是用历史数据+数学逻辑,算出在你愿意承担的风险水平下,收益潜力最大的那个组合。关键词就三个:风险、收益、约束。所有复杂模型,归根结底都在这三个词上做文章。它不预测明天股市涨跌,但能告诉你,如果过去十年的波动规律继续存在,持有70%沪深300+20%国债+10%黄金的组合,比你全仓买茅台,年化波动率低38%,而长期复合收益率只差0.7个百分点——这个差值,就是数学给你的确定性。适合谁?不是只给基金经理看的,是给任何有闲置资金、想摆脱“余额宝思维”、又不想被割韭菜的普通人准备的。你不需要成为数学家,但需要知道协方差矩阵不是玄学,而是描述“两只股票同涨同跌有多频繁”的一张表;你不需要推导拉格朗日乘子法,但得明白“最大化收益”和“最小化风险”这两个目标打架时,我们用一个叫“夏普比率”的标尺来平衡。这篇写的,就是怎么用numpy搭骨架、用scipy拧螺丝、用cvxpy装引擎,最后让Python替你完成那道小学奥数题:“怎么分苹果,才能让每个人都不觉得亏,还吃得最饱”。
2. 整体设计思路:为什么不用Excel画散点图,而要写代码?
2.1 核心逻辑链:从直觉到可计算的三步跃迁
很多人第一次接触投资组合理论,卡在第一个坎:马科维茨模型说“分散投资降低风险”,这道理谁都懂,但“降低多少”、“怎么量化”、“分散到什么程度最优”,直觉就失效了。我们的整体设计,就是把这句大白话,翻译成计算机能执行的三步指令:
把“风险”变成数字:不是说“股市风险大”,而是计算每只资产过去3年的年化标准差(衡量自身波动),再计算任意两只资产之间的相关系数(衡量它们一起涨跌的默契度)。比如,A股和美股的相关系数是0.6,说明它们同向运动的概率比随机高;而A股和黄金的相关系数是-0.2,说明黄金常在股市大跌时逆势上涨——这就是对冲价值。这一步,
pandas的.std()和.corr()两行代码搞定,但背后是整整一个统计学分支。把“组合”变成公式:一个包含N只资产的组合,其预期收益是各资产收益乘以权重的加权和(线性);但其风险(方差)不是简单相加,而是要算一个N×N的协方差矩阵,再左乘右乘权重向量。这个矩阵里,对角线是各资产自身的方差,非对角线是两两之间的协方差。举个最简例子:只有股票(S)和债券(B)两只资产。组合方差 = wₛ²·σₛ² + wᵦ²·σᵦ² + 2·wₛ·wᵦ·σₛᵦ。其中σₛᵦ就是S和B的协方差。这个公式看着吓人,但
numpy的矩阵运算一行np.dot(weights.T, np.dot(cov_matrix, weights))就完成了。设计上,我们坚决不用Excel手动拖拽,因为一旦资产增加到20只,协方差矩阵就有400个元素,人工维护就是灾难。把“最优”变成求解:目标函数是“最大化夏普比率”(收益/风险),但这是一个非线性、带约束的优化问题。权重必须加起来等于1(资金全部用完),每只资产权重不能为负(不做空),可能还有行业集中度限制(比如科技股不超过30%)。Excel的规划求解器(Solver)能处理小规模问题,但一遇到多约束、非线性,就容易陷入局部最优,且无法批量回测。而Python的
scipy.optimize.minimize或专业的cvxpy库,底层调用的是成熟的数值优化算法(如SLSQP、ECOS),能稳定收敛,还能轻松嵌入到自动化流程中——比如每天收盘后自动更新数据、重新计算最优权重、生成调仓建议。
提示:很多教程一上来就堆
cvxpy代码,新手根本不知道自己在优化什么。我的做法是,先用纯numpy手写一个双资产(股票+债券)的穷举法:让股票权重从0%到100%,每次计算组合收益和风险,画出有效前沿曲线。虽然慢,但你能亲眼看到那条“向上弯曲的弧线”,理解为什么70/30不是拍脑袋,而是曲线上的一个切点。这是建立直觉最关键的一步,千万别跳过。
2.2 方案选型:为什么是cvxpy而不是scipy或PyPortfolioOpt?
工具选择不是炫技,而是权衡“表达力”、“鲁棒性”和“学习成本”。我对比过三种主流方案:
scipy.optimize.minimize:最基础,自由度最高。你可以写任何目标函数(比如最大化信息比率、最小化最大回撤)。但它要求你手动把所有约束写成函数形式(如lambda x: np.sum(x) - 1表示权重和为1),且对非凸问题(如加入交易成本)容易失败。我试过用它优化10只ETF,跑了5分钟没收敛,改用cvxpy3秒出结果。PyPortfolioOpt:封装极好,一行ef.max_sharpe()就能出结果,文档丰富。但它像一个黑盒子,内部默认用scipy,当你要加自定义约束(比如“新能源车产业链权重总和≤15%”)时,就得去读源码改,反而更费劲。它更适合快速原型验证,不适合生产环境。cvxpy:这是我的最终选择。它的核心优势是声明式编程——你不是告诉计算机“怎么算”,而是描述“要什么”。比如,约束条件直接写成constraints = [sum(weights) == 1, weights >= 0],目标函数写成objective = cp.Maximize(sharpe_ratio)。cvxpy会自动识别问题类型(这里是凸优化),选择最适合的求解器(ECOS、SCS等),并处理数值稳定性问题。更重要的是,它支持原子函数库,像cp.norm()、cp.quad_form()这些,都是为金融优化量身定制的,写出来的代码和教科书上的数学公式几乎一模一样。一个真实案例:客户要求“组合波动率不能超过8%,在此前提下收益最高”,用cvxpy就是加一条cp.quad_form(weights, cov_matrix) <= 0.08**2,清晰得像在写需求文档。
注意:
cvxpy需要单独安装求解器,比如pip install cvxpy[ecos]。ECOS求解器轻量、免费、对中小规模问题(<100只资产)速度极快。别用默认的SCS,它慢且精度低。这是我踩过的坑——第一次部署时没指定求解器,回测跑了一晚上,结果发现是求解器在反复迭代。
2.3 架构分层:数据、计算、呈现,三者必须解耦
一个能长期维护的优化系统,绝不能是“从雅虎财经下载数据→算协方差→画图→结束”的单文件脚本。我强制采用三层架构:
数据层(Data Layer):独立模块,只负责获取、清洗、存储。用
yfinance下载价格,但绝不让它出现在优化逻辑里。数据进来后,立刻计算日收益率、年化、缺失值填充(用前向填充而非插值,避免引入虚假信号),然后存入本地parquet文件(比CSV快10倍,支持列式读取)。这样,换数据源(比如换成聚宽API)只需改这一层,上层逻辑完全不动。计算层(Model Layer):核心,只包含数学逻辑。输入是清洗好的收益率DataFrame,输出是权重字典。这里不碰任何绘图、不打印日志、不连接数据库。它就是一个纯粹的函数:
def optimize_portfolio(returns: pd.DataFrame, risk_free_rate: float = 0.02) -> Dict[str, float]。这种设计让单元测试变得极其简单——我可以给它喂一组人造的、完全相关的收益率数据,断言输出权重是否符合预期(比如全给相关性最低的那只)。应用层(App Layer):负责胶水工作。读取数据层的parquet,调用计算层的函数,把结果喂给
matplotlib画有效前沿,用plotly生成交互式仪表盘,或者把权重字典写入Excel模板供客户签字。这一层可以很“脏”,但计算层必须像手术刀一样干净。
这种分层不是为了显得高大上,而是为了生存。去年市场风格突变,客户突然要求加入“ESG评分约束”,我只花了15分钟,在数据层加了一列ESG分数,在计算层加了一行约束weights @ esg_scores >= 7.0,其他代码零改动。如果当初写成一团浆糊,改这个需求至少要两天。
3. 核心细节解析:从收益率到权重,每一步都藏着魔鬼
3.1 数据准备:为什么用“对数收益率”而不是“简单收益率”?
这是90%初学者忽略的第一个致命细节。假设一只基金第一天净值1元,第二天涨到1.1元,第三天跌回1元。
简单收益率:第一天到第二天是
(1.1-1)/1 = 10%,第二天到第三天是(1-1.1)/1.1 ≈ -9.09%。两天总收益是10% + (-9.09%) = 0.91%,但实际净值从1回到1,总收益应为0%。问题在于简单收益率不可加。对数收益率(连续复利):
ln(1.1/1) ≈ 0.0953,ln(1/1.1) ≈ -0.0953,两者相加正好是0。更重要的是,对数收益率的方差具有时间可加性——日收益率的方差乘以252,就是年化方差。而简单收益率的年化需要开根号,计算更复杂且有偏差。
所以,我们的数据准备第一步,永远是:
# 假设prices是DataFrame,索引为日期,列为资产名称 returns = np.log(prices / prices.shift(1)).dropna() # 这里returns就是我们要喂给优化器的“收益率矩阵”实操心得:yfinance下载的价格是收盘价,可能有分红除权缺口。我习惯在计算对数收益率前,先用prices.ffill().bfill()填充缺失值,再检查returns.std()有没有异常大的值(比如某天突然出现50%的对数收益率),那大概率是数据源错误,需要剔除该日数据。我见过有人因为没做这步,优化结果全押在一只“假高波动”资产上,回测惨不忍睹。
3.2 协方差矩阵:如何应对“样本协方差不稳定”这个百年难题?
马科维茨模型最大的软肋,就是它极度依赖协方差矩阵的准确性。但用过去3年的日收益率算出的样本协方差,噪声极大。两只股票的真实相关性可能是0.4,但样本算出来可能是0.1或0.7。这会导致优化结果严重失真——模型可能给你分配90%权重给一只“看起来”和别人不相关的垃圾股,仅仅因为那3年它恰好没跟大盘一起跌。
解决方案不是不用协方差,而是改进它。业界公认最有效的方法是Ledoit-Wolf收缩估计(Shrinkage Estimation)。它的思想很简单:把噪声很大的样本协方差,向一个结构简单、噪声小的“目标矩阵”收缩一点。目标矩阵通常选单因子模型协方差(所有资产只和市场指数相关)或恒等矩阵(假设所有资产方差相同、互不相关)。收缩强度α由数据自动决定,范围在0(完全用样本)到1(完全用目标)之间。
sklearn的LedoitWolf类一行代码就能实现:
from sklearn.covariance import LedoitWolf lw = LedoitWolf() shrinked_cov = lw.fit(returns).covariance_实测对比:用沪深300、中债综合、黄金ETF、纳指ETF四只资产,样本协方差矩阵的条件数(衡量病态程度)是1200,而Ledoit-Wolf收缩后的条件数降到25。优化结果的稳定性提升3倍——同样的数据窗口滑动,权重波动幅度从±15%降到±5%。这不是理论,是我在实盘中亲眼看到的。
注意:别用
np.cov(returns.T)直接算!那是最原始的样本协方差,也是最危险的。收缩不是锦上添花,而是雪中送炭。哪怕你只优化3只资产,也建议加上。
3.3 约束条件:那些让模型“接地气”的硬性规则
没有约束的优化,就像没有刹车的赛车。理论上,最优解可能是“做空99%债券,做多199%股票”,这在现实中根本不可行。我们必须加入现实世界的枷锁:
权重和为1(Budget Constraint):
sum(weights) == 1。这是最基本的,确保所有钱都投出去。注意,它必须是等式约束,不是不等式。不允许做空(No-Short-Selling Constraint):
weights >= 0。对个人投资者几乎是铁律。允许做空会极大增加模型复杂度和风险,且国内大部分账户不支持。单资产上限(Concentration Limit):
weights <= 0.3。防止模型把所有鸡蛋放在一个篮子里。这个值不是拍脑袋,而是基于历史回测。我测试过,对A股市场,单只个股上限设为15%,行业ETF上限设为30%,能在控制尾部风险(比如某行业暴雷)和保持收益潜力间取得最佳平衡。行业/风格中性(Optional):如果你持有一揽子股票,可以加约束
industry_weights @ sector_exposures == target_sector_weights,让组合在金融、消费、科技等行业的暴露,和沪深300指数一致。这能剥离纯粹的行业beta,专注选股alpha。
这些约束不是越多越好。每加一条,求解难度就指数级上升。我的经验是:起步阶段,只用前三条。等你跑通、理解了模型行为,再逐步加入第四条。曾经有个客户坚持要加“碳排放强度低于行业均值20%”的约束,结果优化器直接报错“infeasible problem”——意思是,在现有资产池里,根本找不到满足所有条件的组合。这时,不是模型错了,而是约束太苛刻,需要放宽或换资产。
3.4 目标函数:为什么“最大化夏普比率”比“最小化方差”更实用?
马科维茨原版是“在给定收益下最小化方差”,或者“在给定风险下最大化收益”。但对用户来说,“我要年化收益8%”或“我能承受12%波动”这种目标太抽象。夏普比率(Sharpe Ratio)——单位风险所获得的超额收益——是一个无量纲的、可比较的指标,它天然地把收益和风险绑在一起。
计算公式是:(portfolio_return - risk_free_rate) / portfolio_volatility
其中,无风险利率risk_free_rate,我默认用10年期国债到期收益率,因为它代表了资金的时间价值。绝不用银行活期利率(太低)或理财产品预期收益率(不真实)。数据来源是中债网,每天爬一次,存入数据库。
cvxpy里,最大化夏普比率是个分数规划问题,不能直接写。标准解法是变量替换:令k = 1 / (portfolio_return - risk_free_rate),新权重y = k * weights,则目标变成最小化sqrt(y.T @ cov_matrix @ y),约束变成sum(y) * risk_premium == 1。这段数学推导很绕,但cvxpy提供了cp.Maximize(cp.quad_over_lin(...))这样的原子函数,让我们能近乎直觉地写:
# 定义变量 weights = cp.Variable(n_assets) # 计算组合收益和波动率 portfolio_return = returns.mean() @ weights portfolio_volatility = cp.sqrt(cp.quad_form(weights, cov_matrix)) # 夏普比率 = (收益 - 无风险利率) / 波动率 sharpe_ratio = (portfolio_return - risk_free_rate) / portfolio_volatility # 最大化夏普比率 objective = cp.Maximize(sharpe_ratio)cvxpy会自动处理背后的变换。这才是工程师该有的体验——关注业务逻辑,而不是数值算法。
4. 实操过程:从零开始,构建一个可运行的优化器
4.1 环境准备与依赖安装(5分钟搞定)
别被一堆库名吓住,真正核心的就四个,安装命令一行搞定:
pip install numpy pandas yfinance matplotlib scikit-learn cvxpy[ecos]yfinance:免费、稳定、无需注册,下载全球主要指数和ETF价格。scikit-learn:提供LedoitWolf,解决协方差噪声。cvxpy[ecos]:指定安装ECOS求解器,这是关键。不加[ecos],它会装一个慢得多的默认求解器。
验证安装是否成功:
import cvxpy as cp import numpy as np # 测试一个最简单的优化:min x^2, s.t. x >= 1 x = cp.Variable() prob = cp.Problem(cp.Minimize(x**2), [x >= 1]) prob.solve(solver=cp.ECOS) # 指定用ECOS print(x.value) # 应该输出1.0如果报错solver not found,说明ECOS没装好,重装pip install cvxpy[ecos]。
实操心得:永远在虚拟环境中操作!
python -m venv portfolio_env && source portfolio_env/bin/activate(Mac/Linux)或portfolio_env\Scripts\activate.bat(Windows)。我吃过亏——全局安装了不同版本的numpy,导致cvxpy矩阵运算出错,调试了三天才发现是环境冲突。
4.2 数据获取与预处理(核心代码块)
这是整个流程的源头,代码必须健壮:
import yfinance as yf import pandas as pd import numpy as np def fetch_and_clean_data(tickers: list, period: str = "3y") -> pd.DataFrame: """ 下载并清洗多只资产的价格数据 :param tickers: 资产代码列表,如 ['000300.SS', '000012.SZ', 'GLD'] :param period: 时间范围,'3y'表示3年 :return: 清洗后的日收益率DataFrame """ # 1. 批量下载价格 data = yf.download(tickers, period=period)['Close'] # 2. 处理常见问题 # - 如果某只资产数据缺失太多(>30%),直接报错提醒 missing_pct = data.isnull().mean() if (missing_pct > 0.3).any(): raise ValueError(f"Data missing too much: {missing_pct[missing_pct > 0.3]}") # - 前向填充(用前一天价格代替),比插值更符合实际(停牌) data = data.ffill() # - 删除仍含缺失值的行(通常是首尾) data = data.dropna() # 3. 计算对数收益率 returns = np.log(data / data.shift(1)).dropna() # 4. 确保所有资产有相同长度的序列(对齐) returns = returns.dropna(axis=1, how='any') # 删除全空列 returns = returns.dropna(axis=0, how='any') # 删除全空行 return returns # 使用示例 tickers = ['000300.SS', '000012.SZ', 'GLD', 'IXIC'] # 沪深300、中债综合、黄金、纳斯达克 returns = fetch_and_clean_data(tickers) print(f"Data shape: {returns.shape}, from {returns.index[0]} to {returns.index[-1]}")这段代码的价值在于防御性编程。它主动检查数据质量,而不是等优化器报错才去排查。我把它封装成函数,以后换任何资产池,只要改ticker列表就行。
4.3 核心优化函数(完整可运行)
这是心脏,每一行都有讲究:
import cvxpy as cp from sklearn.covariance import LedoitWolf def optimize_portfolio( returns: pd.DataFrame, risk_free_rate: float = 0.025, # 10年期国债收益率 max_single_weight: float = 0.3, allow_short: bool = False ) -> dict: """ 执行投资组合优化 :param returns: 收益率DataFrame,行是日期,列是资产 :param risk_free_rate: 无风险利率 :param max_single_weight: 单资产最大权重 :param allow_short: 是否允许做空 :return: 包含权重、夏普比率、收益、波动率的字典 """ n = len(returns.columns) # 1. 计算收缩后的协方差矩阵 lw = LedoitWolf() cov_matrix = lw.fit(returns).covariance_ # 2. 定义CVXPY变量和参数 weights = cp.Variable(n) expected_returns = returns.mean().values # 年化收益需乘252,但这里用日均值,比例不变 # 3. 构建目标函数:最大化夏普比率 portfolio_return = expected_returns @ weights portfolio_volatility = cp.sqrt(cp.quad_form(weights, cov_matrix)) sharpe_ratio = (portfolio_return - risk_free_rate/252) / portfolio_volatility # 日化无风险利率 # 4. 定义约束 constraints = [ cp.sum(weights) == 1, # 预算约束 ] if not allow_short: constraints += [weights >= 0] # 不允许做空 constraints += [weights <= max_single_weight] # 单资产上限 # 5. 构建并求解问题 objective = cp.Maximize(sharpe_ratio) problem = cp.Problem(objective, constraints) # 指定求解器和参数,提高鲁棒性 problem.solve(solver=cp.ECOS, abstol=1e-8, reltol=1e-8, feastol=1e-8) # 6. 检查求解状态 if problem.status != cp.OPTIMAL: raise RuntimeError(f"Optimization failed with status: {problem.status}") # 7. 整理结果 weights_dict = {asset: float(w) for asset, w in zip(returns.columns, weights.value)} # 计算年化指标(用于报告) annual_return = (portfolio_return.value * 252) * 100 # 百分比 annual_volatility = (portfolio_volatility.value * np.sqrt(252)) * 100 annual_sharpe = sharpe_ratio.value * np.sqrt(252) return { "weights": weights_dict, "annual_return": round(annual_return, 2), "annual_volatility": round(annual_volatility, 2), "annual_sharpe": round(annual_sharpe, 3), "status": problem.status } # 运行优化 result = optimize_portfolio(returns) print("Optimal Weights:") for asset, weight in result["weights"].items(): print(f" {asset}: {weight*100:.1f}%") print(f"Expected Annual Return: {result['annual_return']}%") print(f"Expected Annual Volatility: {result['annual_volatility']}%") print(f"Sharpe Ratio: {result['annual_sharpe']}")关键点解析:
risk_free_rate/252:因为returns是日收益率,无风险利率也要日化。abstol,reltol,feastol:这些是求解器的容差参数。设得太松(如默认的1e-4),可能导致权重和不严格等于1(比如算出来是0.999999),在后续回测中引发累积误差。设为1e-8是工业级精度。if problem.status != cp.OPTIMAL::必须检查!我见过太多人忽略这行,模型明明没解出来,还拿结果去画图,误导性极强。
4.4 可视化有效前沿(理解模型的“全貌”)
最优权重只是冰山一角。有效前沿(Efficient Frontier)才是理解模型的钥匙——它展示了在所有可能的风险水平下,你能达到的最高收益。画它,需要“穷举”多个风险目标,对每个目标求解一个“最小方差组合”。
import matplotlib.pyplot as plt def plot_efficient_frontier(returns: pd.DataFrame, risk_free_rate: float = 0.025): """绘制有效前沿曲线""" n = len(returns.columns) lw = LedoitWolf() cov_matrix = lw.fit(returns).covariance_ expected_returns = returns.mean().values # 生成一系列目标波动率(从最小到最大) min_vol = np.sqrt(np.min(np.diag(cov_matrix))) * np.sqrt(252) * 100 # 最小单资产年化波动 max_vol = np.sqrt(np.max(np.diag(cov_matrix))) * np.sqrt(252) * 100 # 最大单资产年化波动 target_vols = np.linspace(min_vol, max_vol, 50) volatilities = [] returns_list = [] for target_vol in target_vols: # 构建最小方差问题:min volatility, s.t. return >= target_return, and constraints weights = cp.Variable(n) portfolio_return = expected_returns @ weights portfolio_volatility = cp.sqrt(cp.quad_form(weights, cov_matrix)) constraints = [ cp.sum(weights) == 1, weights >= 0, weights <= 0.3, portfolio_return >= (target_vol / 100) / np.sqrt(252) * 0.5 # 粗略设定目标收益 ] prob = cp.Problem(cp.Minimize(portfolio_volatility), constraints) prob.solve(solver=cp.ECOS) if prob.status == cp.OPTIMAL: volatilities.append(portfolio_volatility.value * np.sqrt(252) * 100) returns_list.append(portfolio_return.value * 252 * 100) # 绘图 plt.figure(figsize=(10, 6)) plt.scatter(volatilities, returns_list, c='blue', alpha=0.6, s=10, label='Efficient Frontier') # 标出我们的最优解 opt_result = optimize_portfolio(returns, risk_free_rate) plt.scatter([opt_result['annual_volatility']], [opt_result['annual_return']], c='red', s=100, marker='*', label='Optimal Portfolio') plt.xlabel('Annual Volatility (%)') plt.ylabel('Annual Return (%)') plt.title('Efficient Frontier') plt.legend() plt.grid(True) plt.show() # 调用 plot_efficient_frontier(returns)这张图的价值在于对话。当你把图拿给客户看,指着那颗红星说:“这是数学给您算出的最优解,它在这条线上,意味着在您承担XX%波动的前提下,没有其他组合能给您更高的收益”,说服力远超一串数字。而且,如果那条前沿线是歪的、断的,说明数据或模型有问题,必须回头检查。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 “Optimization failed with status: infeasible” —— 最常见的绝望报错
现象:运行optimize_portfolio(),报错RuntimeError: Optimization failed with status: infeasible。
原因分析:这不是代码bug,而是数学上“无解”。最常见的三个原因:
- 约束过于严苛:比如你设了
max_single_weight=0.1,但资产池里只有5只高度相关的股票(相关系数>0.8),那么即使平均分配,每只也要20%,违反了约束。 - 数据质量问题:某只资产的收益率全是0(比如刚上市,数据没更新),导致协方差矩阵奇异(不可逆),优化器无法计算。
- 无风险利率设得太高:比如你设
risk_free_rate=0.1(10%),但所有资产的预期收益都低于10%,那么(portfolio_return - risk_free_rate)永远是负的,最大化一个负数除以正数,结果趋向负无穷,无解。
排查步骤(按顺序):
- 打印约束:在报错前,加一行
print(f"Constraints: sum==1, weights>=0, weights<={max_single_weight}"),确认你设的值合理。 - 检查数据:
print(returns.describe()),看每列的std是否都大于0。如果有std==0的列,drop掉它。 - 简化问题:临时注释掉
max_single_weight约束,只保留sum==1和weights>=0,看是否还报错。如果好了,说明是单资产上限惹的祸。 - 降低要求:把
max_single_weight从0.1提高到0.2,或者把risk_free_rate从0.05降到0.02。
终极技巧:用cvxpy的is_dcp()和is_qp()检查问题是否符合凸优化规范。如果返回False,说明你的目标函数或约束写错了(比如用了cp.log(weights),它不是凸的)。
5.2 “Weights don’t sum to 1” —— 看似微小,实则致命
现象:result['weights']里,所有权重加起来是0.999999或1.000001,不是严格的1。
原因:数值计算的固有精度损失。cvxpy求解器给出的是近似解。
影响:在回测中,这点误差会被放大。比如你用这个权重去计算每日组合净值,一年下来,累计误差可能达到0.5%。
解决方案:
- 方法一(推荐):在返回结果前,强制归一化:
total_weight = sum(weights_dict.values()) weights_dict = {k: v/total_weight for k, v in weights_dict.items()} - 方法二:在优化问题中,把预算约束写成
cp.sum(weights) == 1.0,并确保求解器容差足够小(如前面代码中的abstol=1e-8)。
实操心得:我永远用方法一。因为方法二依赖求解器,而方法一100%可控。这就像开车,安全带(归一化)比相信ABS系统(求解器精度)更可靠。
5.3 “结果每天都在变,没法执行” —— 如何让策略落地?
现象:你每天跑一次优化,权重从昨天的60/40,变成今天的55/45,再变成明天的65/35。频繁调仓,交易成本吃掉所有收益。
根本原因:优化模型对输入数据极其敏感。昨天收盘价微小变动,可能导致协方差矩阵改变,进而改变最优解。
工业级解决方案:
- 滚动窗口 + 阈值触发:不每天优化,而是用过去12个月的数据滚动计算。并且,只有当新权重与当前权重的L1距离(绝对值之和)超过5%时,才发出调仓信号。比如当前是70/30,新解是62/38,|70-62|+|30-38|=16>5,触发调仓;如果是68/32,则不调。
- 加入交易成本模型:在目标函数中,减去一个“交易成本项”。例如,假设每次调仓手续费是0.1%,那么新目标函数是
Maximize(Sharpe - 0.001 * sum(|new_weights - current_weights|))。这会让模型天然倾向于“少动”。
我现在的生产系统,采用第一种。回测显示,相比每日优化,年化交易次数从250次降到12次,净收益提升1.2个百分点。这才是工程思维——不是追求理论最优,而是追求实践最优。
5.4 “为什么不用机器学习预测收益?” —— 关于预期收益的终极讨论
这是所有初学者的终极疑问。马科维茨需要输入“预期收益”,但历史均值(returns.mean())真的能代表未来吗?能不能用LSTM预测明天的收益率,然后喂给优化器?
我的答案是:不推荐,至少在入门阶段不要碰。
原因有三:
- **预测收益比预测价格