第 5 章 · 01 约束多空与市场中性 本节摘要:本章把第 4 章的标准均值-方差优化推向工程实战。本节讲第一类扩展——约束:如何在 之外加更精细的不等式/等式约束。具体包括:允许做空的 、单资产上下限、市场中性(组合 beta 趋零,权重和为 0)、以及按行业/类别分组的 。本节拆解 与 的源码,讲清 cvxpy 的 DCP 规则(约束必须是线性等式或凸不等式)、市场中性只对 / / 开放的原因,以及 cookbook 2 里「maxsharpe + 行业约束 + 单资产锁定」的真实案例。 内容来源:原项目源码 、 、文档 、 、 ,汉化并套用体系化模板。 学习目标 阅读完本节,你应当能够: 用 允许做空。 用 加单资产锁定/上下限。 用 加行业分组约束。
本节摘要:本章把第 4 章的标准均值-方差优化推向工程实战。本节讲第一类扩展——约束:如何在
weight_bounds之外加更精细的不等式/等式约束。具体包括:允许做空的weight_bounds=(-1, 1)、单资产上下限、市场中性(组合 beta 趋零,权重和为 0)、以及按行业/类别分组的add_sector_constraints。本节拆解BaseConvexOptimizer.add_constraint与add_sector_constraints的源码,讲清 cvxpy 的 DCP 规则(约束必须是线性等式或凸不等式)、市场中性只对efficient_risk/efficient_return/max_quadratic_utility开放的原因,以及 cookbook 2 里「max_sharpe + 行业约束 + 单资产锁定」的真实案例。
内容来源:原项目源码
pypfopt/base/_base_optimizer.py、pypfopt/efficient_frontier/efficient_frontier.py、文档docs/UserGuide.rst、docs/GeneralEfficientFrontier.rst、cookbook/2-Mean-Variance-Optimisation.ipynb,汉化并套用体系化模板。
阅读完本节,你应当能够:
weight_bounds=(-1, 1) 允许做空。add_constraint 加单资产锁定/上下限。add_sector_constraints 加行业分组约束。market_neutral=True 求市场中性组合,理解其限制。第 4 章的标准优化只有 weight_bounds 边界约束与「权重和为 1」。但真实组合管理常有更精细的诉求:
weight_bounds=(-1, 1)。weight_bounds 在构造函数里设置,控制每只资产的可行域:
# 只做多(默认) ef = EfficientFrontier(mu, S) # 允许做空 ef = EfficientFrontier(mu, S, weight_bounds=(-1, 1)) # 单资产最多 10% ef = EfficientFrontier(mu, S, weight_bounds=(0, 0.1)) # 无界(转 ±1) ef = EfficientFrontier(mu, S, weight_bounds=(None, None)) # 每资产独立边界 bounds = [(0, 0.3), (0.05, 0.4), ...] # 长度 = 资产数 ef = EfficientFrontier(mu, S, weight_bounds=bounds)
源码里 _map_bounds_to_constraints(在 _base_optimizer.py 221-267 行)把 bounds 转成两条 cvxpy 约束 w >= lower 和 w <= upper,加入约束列表。None 自动转 ±1。
💡 做空的小技巧(文档
tip):若想生成只做空的组合,有个快捷 hack——把 μ 乘 -1,然后优化只做多组合。这样优化器会偏好「μ 最负」(即原始 μ 最正)的资产做空。
BaseConvexOptimizer.add_constraint 是最通用的扩展点。源码(371-396 行):
def add_constraint(self, new_constraint): if not callable(new_constraint): raise TypeError("New constraint must be provided as a callable (e.g lambda function)") if self._opt is not None: raise InstantiationError( "Adding constraints to an already solved problem might have unintended consequences. ..." ) self._constraints.append(new_constraint(self._w))
要点:
lambda w: <cvxpy 表达式>。InstantiationError)。# GOOG 占恰好 5% ef.add_constraint(lambda w: w[0] == 0.05) # AMZN 不超过 10% amzn_index = ef.tickers.index("AMZN") ef.add_constraint(lambda w: w[amzn_index] <= 0.10) # 至少 5% 给第 10 只资产 ef.add_constraint(lambda w: w[10] >= 0.05)
import numpy as np # 每只资产有独立上限(向量) upper = np.array([0.1, 0.08, 0.15, ...]) ef.add_constraint(lambda w: w <= upper)
add_sector_constraints 是「按分组约束权重和」的便利方法。源码(398-437 行):
def add_sector_constraints(self, sector_mapper, sector_lower, sector_upper): if np.any(self._lower_bounds < 0): warnings.warn("Sector constraints may not produce reasonable results if shorts are allowed.") for sector in sector_upper: is_sector = [sector_mapper.get(t) == sector for t in self.tickers] self.add_constraint(lambda w: cp.sum(w[is_sector]) <= sector_upper[sector]) for sector in sector_lower: is_sector = [sector_mapper.get(t) == sector for t in self.tickers] self.add_constraint(lambda w: cp.sum(w[is_sector]) >= sector_lower[sector])
需要三件东西:
sector_mapper:{ticker: sector} 字典,把每只资产映射到行业。sector_lower:{sector: 下限} 字典,某行业权重和至少多少。sector_upper:{sector: 上限} 字典,某行业权重和至多多少。cookbook 2 的完整示例:
sector_mapper = { "MSFT": "Tech", "AMZN": "Consumer Discretionary", "KO": "Consumer Staples", "MA": "Financial Services", "COST": "Consumer Staples", "LUV": "Aerospace", "XOM": "Energy", "PFE": "Healthcare", "JPM": "Financial Services", "UNH": "Healthcare", "ACN": "Misc", "DIS": "Media", "GILD": "Healthcare", "F": "Auto", "TSLA": "Auto" } sector_lower = {"Consumer Staples": 0.1, "Tech": 0.05} sector_upper = {"Tech": 0.2, "Aerospace": 0.1, "Energy": 0.1, "Auto": 0.15} ef = EfficientFrontier(mu, S) ef.add_sector_constraints(sector_mapper, sector_lower, sector_upper)
⚠️ 与做空不兼容的警告:源码第一行就检查
if np.any(self._lower_bounds < 0): warnings.warn(...)。因为行业约束的语义在允许做空时会变得模糊——做空一只科技股到底是「减少科技暴露」还是「增加负科技暴露」?实务上,行业约束通常用于只做多组合。
市场中性(market neutral)是「组合 beta 趋零」的特殊约束——多空抵消,使组合不暴露于市场系统性风险,只赚 α。在 PyPortfolioOpt 里,它通过权重和为 0 实现:
ef.efficient_return(target_return=0.2, market_neutral=True) ef.efficient_risk(target_volatility=0.2, market_neutral=True) ef.max_quadratic_utility(risk_aversion=2, market_neutral=True)
源码 _make_weight_sum_constraint(152-193 行):
def _make_weight_sum_constraint(self, is_market_neutral): if is_market_neutral: portfolio_possible = np.any(self._lower_bounds < 0) if not portfolio_possible: warnings.warn("Market neutrality requires shorting - bounds have been amended") self._map_bounds_to_constraints((-1, 1)) ... self.add_constraint(lambda w: cp.sum(w) == 0) # 关键:权重和为 0 else: self.add_constraint(lambda w: cp.sum(w) == 1)
要点:
weight_bounds 不含负值,自动改成 (-1, 1) 并发警告。efficient_risk、efficient_return、max_quadratic_utility。max_sharpe 与 min_volatility 不支持。docs/UserGuide.rst 明确:
市场中性(权重和为零)只对
efficient_risk和efficient_return开放,因为另外两个方法对杠杆不满足不变性。
数学上,max_sharpe(切线组合)与 min_volatility(GMV)对杠杆缩放不是不变的——把所有权重乘 2,GMV 的方差会变 4 倍,切线组合的夏普不变但位置移动。而权重和为 0 等价于「无净杠杆」,只有对杠杆不变的优化目标才有意义。
cookbook 2 的招牌示例,把本节学的全用上:
from pypfopt import EfficientFrontier, expected_returns, risk_models mu = expected_returns.capm_return(prices) S = risk_models.CovarianceShrinkage(prices).ledoit_wolf() ef = EfficientFrontier(mu, S) # weight_bounds 默认 (0, 1) ef.add_sector_constraints(sector_mapper, sector_lower, sector_upper) # AMZN 恰好 10% amzn_index = ef.tickers.index("AMZN") ef.add_constraint(lambda w: w[amzn_index] == 0.10) # TSLA 不超过 5% tsla_index = ef.tickers.index("TSLA") ef.add_constraint(lambda w: w[tsla_index] <= 0.05) # 第 10 只资产至少 5% ef.add_constraint(lambda w: w[10] >= 0.05) ef.max_sharpe() weights = ef.clean_weights()
这个组合同时满足:只做多、行业上下限、AMZN 锁 10%、TSLA 上限 5%、第 10 资产下限 5%、权重和为 1、最大化夏普。所有约束都被 cvxpy 同时满足。
💡 约束的顺序:
add_constraint与add_sector_constraints可以任意顺序调用——它们都只是往_constraints列表里追加。约束在求解时同时生效,无优先级。
cvxpy 要求约束满足 DCP 规则。常见错误:
# 错误:非凸约束(两个变量相乘) ef.add_constraint(lambda w: w[0] * w[1] == 0.05) # 报错 # 错误:非凸函数 ef.add_constraint(lambda w: cp.abs(w[0]) <= 0.1) # 实际上 abs 是凸的,这是合法的 # 正确:线性等式 ef.add_constraint(lambda w: w[0] == 0.05) # 正确:线性不等式 ef.add_constraint(lambda w: w[0] <= 0.1) # 正确:凸不等式(sum 是凸的) ef.add_constraint(lambda w: cp.sum(w[:5]) <= 0.3)
⚠️ 非凸约束怎么办:若你的约束真的非凸(如
w[0] * w[1] <= c),cvxpy 会拒绝。此时有两个选择:(1) 用nonconvex_objective方法 + scipy 后端(第 5 章第 03 节);(2) 重新建模,把非凸约束转化为凸的等价形式(如对数变换)。
(-1,1) 多空、(0,0.1) 上限、(None,None) 无界(转 ±1)、list of tuples 每资产独立。lambda w: <cvxpy 表达式>,必须满足 DCP(线性等式或凸不等式)。sector_mapper + sector_lower + sector_upper,按行业分组约束权重和;与做空组合会发警告。efficient_risk/efficient_return/max_quadratic_utility 开放(max_sharpe 与 min_volatility 对杠杆不不变)。InstantiationError。下一节,我们看 L2 正则——一个特殊的「额外目标」,用来减少零权重、让组合更分散。