第 5 章 · 01 约束多空与市场中性


文档摘要

第 5 章 · 01 约束多空与市场中性 本节摘要:本章把第 4 章的标准均值-方差优化推向工程实战。本节讲第一类扩展——约束:如何在 之外加更精细的不等式/等式约束。具体包括:允许做空的 、单资产上下限、市场中性(组合 beta 趋零,权重和为 0)、以及按行业/类别分组的 。本节拆解 与 的源码,讲清 cvxpy 的 DCP 规则(约束必须是线性等式或凸不等式)、市场中性只对 / / 开放的原因,以及 cookbook 2 里「maxsharpe + 行业约束 + 单资产锁定」的真实案例。 内容来源:原项目源码 、 、文档 、 、 ,汉化并套用体系化模板。 学习目标 阅读完本节,你应当能够: 用 允许做空。 用 加单资产锁定/上下限。 用 加行业分组约束。

第 5 章 · 01 约束多空与市场中性

本节摘要:本章把第 4 章的标准均值-方差优化推向工程实战。本节讲第一类扩展——约束:如何在 weight_bounds 之外加更精细的不等式/等式约束。具体包括:允许做空的 weight_bounds=(-1, 1)、单资产上下限、市场中性(组合 beta 趋零,权重和为 0)、以及按行业/类别分组的 add_sector_constraints。本节拆解 BaseConvexOptimizer.add_constraintadd_sector_constraints 的源码,讲清 cvxpy 的 DCP 规则(约束必须是线性等式或凸不等式)、市场中性只对 efficient_risk/efficient_return/max_quadratic_utility 开放的原因,以及 cookbook 2 里「max_sharpe + 行业约束 + 单资产锁定」的真实案例。

内容来源:原项目源码 pypfopt/base/_base_optimizer.pypypfopt/efficient_frontier/efficient_frontier.py、文档 docs/UserGuide.rstdocs/GeneralEfficientFrontier.rstcookbook/2-Mean-Variance-Optimisation.ipynb,汉化并套用体系化模板。

学习目标

阅读完本节,你应当能够:

  1. weight_bounds=(-1, 1) 允许做空
  2. add_constraint单资产锁定/上下限
  3. add_sector_constraints行业分组约束
  4. market_neutral=True市场中性组合,理解其限制。
  5. 知道约束必须满足 cvxpy 的 DCP 规则

一、为什么要加约束

第 4 章的标准优化只有 weight_bounds 边界约束与「权重和为 1」。但真实组合管理常有更精细的诉求:

  1. 允许做空:weight_bounds=(-1, 1)
  2. 持仓上限:单资产不超过 10%,避免集中。
  3. 行业暴露控制:科技股不超过 30%、能源股至少 5%。
  4. 市场中性:多空抵消,组合 beta 趋零,只赚 α。
  5. 锁定特定仓位:「我已经持有 5% GOOG,优化时锁定它」。

二、weight_bounds:做空与持仓上限

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 >= lowerw <= upper,加入约束列表。None 自动转 ±1。

💡 做空的小技巧(文档 tip):若想生成只做空的组合,有个快捷 hack——把 μ 乘 -1,然后优化只做多组合。这样优化器会偏好「μ 最负」(即原始 μ 最正)的资产做空。

三、add_constraint:任意 DCP 约束

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))

要点:

  1. 必须是 callable:通常用 lambda w: <cvxpy 表达式>
  2. 求解前才能加:已经解过的实例不能再加约束(会抛 InstantiationError)。
  3. 必须满足 DCP:cvxpy 的 DCP(disciplined convex programming)规则——约束必须是线性等式凸不等式

3.1 单资产约束

# 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)

3.2 向量约束

import numpy as np # 每只资产有独立上限(向量) upper = np.array([0.1, 0.08, 0.15, ...]) ef.add_constraint(lambda w: w <= upper)

四、add_sector_constraints:行业分组

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)

要点:

  1. 权重和为 0:正权重(做多)与负权重(做空)完全抵消,组合净敞口为零。
  2. 必须允许做空:若 weight_bounds 不含负值,自动改成 (-1, 1) 并发警告。
  3. 只对三个方法开放:efficient_riskefficient_returnmax_quadratic_utilitymax_sharpemin_volatility 不支持。

5.1 为什么 max_sharpe 与 min_volatility 不支持

docs/UserGuide.rst 明确:

市场中性(权重和为零)只对 efficient_riskefficient_return 开放,因为另外两个方法对杠杆不满足不变性。

数学上,max_sharpe(切线组合)与 min_volatility(GMV)对杠杆缩放不是不变的——把所有权重乘 2,GMV 的方差会变 4 倍,切线组合的夏普不变但位置移动。而权重和为 0 等价于「无净杠杆」,只有对杠杆不变的优化目标才有意义。

六、完整案例:max_sharpe + 行业约束 + 单资产锁定

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_constraintadd_sector_constraints 可以任意顺序调用——它们都只是往 _constraints 列表里追加。约束在求解时同时生效,无优先级。

七、DCP 规则与常见错误

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. weight_bounds:构造时设置——(-1,1) 多空、(0,0.1) 上限、(None,None) 无界(转 ±1)、list of tuples 每资产独立。
  2. add_constraint:最通用的扩展点,接受 lambda w: <cvxpy 表达式>,必须满足 DCP(线性等式或凸不等式)。
  3. add_sector_constraints:需要 sector_mapper + sector_lower + sector_upper,按行业分组约束权重和;与做空组合会发警告。
  4. 市场中性:权重和为 0,需负权重;只对 efficient_risk/efficient_return/max_quadratic_utility 开放(max_sharpe 与 min_volatility 对杠杆不不变)。
  5. 求解前才能加约束:已解过的实例再加约束会抛 InstantiationError
  6. 完整案例:cookbook 2 的「max_sharpe + 行业约束 + 单资产锁定」展示了多约束叠加。

下一节,我们看 L2 正则——一个特殊的「额外目标」,用来减少零权重、让组合更分散。


发布者: 作者: 灏天文库 转发
评论区 (0)
U