M12 组合构建与优化¶
层级:L3 组合管理 | 优先级:★★ | 预计学时:12 小时 对应原书:黑箱第 6 章;GK 第 14–15 章;因子 7.3 前置知识:M10 的 α;M11 的协方差;M09 的转移系数。 读书笔记:黑箱第 6 章的权重 状态:reviewed 数据截至:2026-10|最近复核:2026-10-28
学习目标¶
- 手算逆波动权重,并分清它在常数相关和不等相关下是否等于风险平价。
- 写出无约束均值—方差解,并对照本仓库拒绝负权重、拒绝权重和大于 1 的合同。
- 区分两种「上限」:袖套内截断后再归一,以及账户层截断后把剩余预算留成现金。
- 用等权公式看只数增加以后还剩下的共同波动。
- 把一次换手的成本和杠杆上限放在权重后面,而不是放在优化目标里面。
- 在同一组 α 和 Σ 上写出只做多满仓、单票上限 0.50 和只做多最小方差的权重,并说明 IDM 在放大上限 1 之下会被截断。
本章知识拓扑图¶
flowchart TB
ALPHA["M10 的 α"] --> MV["无约束 w = Σ⁻¹α / 2λ"]
SIGMA["M11 的 Σ"] --> MV
SIGMA --> INV["逆波动,不看 α"]
MV --> GATE{"权重 ≥ 0 且总和 ≤ 1?"}
INV --> SLEEVE["袖套权重"]
GATE -->|否| REJECT["信号合同拒绝"]
GATE -->|是| SLEEVE
SLEEVE --> ACCT["乘策略预算,触顶留现金"]
ACCT --> ORD["缺口超过缓冲才生成委托,先卖后买"]
优化器给出的是一组数。attach 过的信号表只接受非负权重,而且每一行的和不能超过 1。target_volatility_scale 的放大倍数上限也是 1,只允许把过高的波动降下来。
🟢 入门层:权重先要能下单¶
1. 规则和优化用的输入不一样¶
等权、市值加权、逆波动和风险平价不读取 α。均值—方差读取 α 和 Σ。黑箱第 6 章把这两类都叫组合构建:前者少一个容易估错的输入,后者能同时写下收益、风险和约束。
DeMiguel、Garlappi 与 Uppal (2009, RFS 22(5)) 在七个数据集上比较了 14 个优化模型和 1/N。以夏普、确定性等值收益和换手来看,没有一种优化在样本外稳定胜过等权。他们的校准是:25 个资产上,样本均值—方差要在论文的设定下超过 1/N,估计窗大约要 3000 个月;50 个资产大约要 6000 个月。这里的图是一组给定的 α 和协方差,用来看权重长什么样。
2. 逆波动在相关相同的时候就是风险平价¶
三只股票的波动是 10%、20%、30%。逆波动权重正比于 1/σ:
1/0.10 : 1/0.20 : 1/0.30 = 10 : 5 : 10/3
和是 55/3,所以权重是
6/11 = 0.5455, 3/11 = 0.2727, 2/11 = 0.1818
相关为 0 时,组合方差是三条 wᵢ²σᵢ² 之和。每一条都是 0.36/121,合计 1.08/121,波动是 0.0945。方差份额各是 0.36/1.08 = ⅓。风险平价要求的就是这个份额。相关同为 0.5 时,份额仍然各是 ⅓,组合波动升到 0.1336,权重不必改。
相关不均匀时,逆波动不再平分风险。下面这个相关矩阵的最小特征值是 0.40,是正定的:
[[1, 0.10, 0.60], [0.10, 1, 0.10], [0.60, 0.10, 1]]
逆波动权重的风险份额变成 0.3696、0.2609、0.3696,组合波动 0.1170。把份额拉回各 ⅓ 的权重是 0.5151、0.3132、0.1717。中间那只和两边的相关都只有 0.10,风险平价会多配它。Maillard、Roncalli 与 Teïletche (2010, JPM 36(4)) 讨论的是这种风险贡献相等的组合。Qian (2005) 的白皮书是较早的风险平价表述。常数相关下两者重合,是这组数字里的结果,不是「有相关就一定要另算一套权重」。
无约束优化器会交出负权重,也会交出一行加总超过 1 的表。富途或 IB 的只做多账户在发单前就会拒绝这两条。 先在策略信封里得到比例,乘上分给这条策略的预算,再与单名上限取小。多出来的留在现金,不摊给别的股票。 逆波动在相关差不多时已经接近风险平价。相关差得很远时,低波动的那只仍可能和另外两只绑在一起,权重不能只看 1/σ。 想把股债的波动放大到 10% 的倍数,在上限 1.0 下不会被执行。分散之后波动偏低,就让它偏低。 进阶层给出的 56/57 与「砍掉负数再归一」不是同一组权重。下单以前先看权重的符号与合计。
发单前先看三件事:没有负权重,加总不超过 1,单名没有顶破上限。做不到的那一格留在现金。
三只波动 10%、20%、30%,相关为 0 时,逆波动权重是 0.5455、0.2727、0.1818。风险份额各是 ⅓,组合波动 0.0945。 相关变成 0.10 和 0.60 之后,同一组权重的份额是 0.3696、0.2609、0.3696。拉回各 ⅓ 的权重是 0.5151、0.3132、0.1717。
DeMiguel、Garlappi 与 Uppal 的校准里,25 个资产上样本均值—方差要超过 1/N,估计窗大约要 3000 个月。个人样本更短,等权留着当对照。
袖套里先得到比例,乘上这条策略的预算,再与单名上限取小。触顶的剩余不摊回其他股票。账户层若再做一次「和为 1」,会把现金也配进股票。
相关同为 0.5 时,三只的风险份额仍各是 ⅓,组合波动升到 0.1336,权重不必改。 50 个资产上,同一篇校准里估计窗大约要 6000 个月。没有那么长的样本时,优化器交出来的负权重仍然不能发单。
🟡 进阶层:无约束解和账户合同¶
3. 均值—方差的一阶条件¶
本章把目标写成
w′α − λ w′ Σ w
没有预算约束时,
w = 1 / (2λ) Σ⁻¹ α
λ 写在整个二次项前面,所以分母是 2λ。若把目标写成 w′α − (λ/2) w′ Σ w,分母里的 2 会消失。两组写法差一个常数倍,必须先声明用的是哪一组。
手算用 λ = 3,波动仍是 10%、20%、30%,两两相关 0.30,α = (0.06, 0.04, 0.02)。无约束权重是
1.0417, 0.0446, −0.0761
有符号的和是 1.0103,已经大于 1,而且高波动股票的权重是负的。把最低波动那只的 α 从 0.06 改成 0.09,权重变成
1.6220, −0.0223, −0.1207
有符号的和是 1.4790。低波动股票的仓位从 104.17% 升到 162.20%。Michaud (1989, FAJ 45(1)) 把这种对输入误差的放大叫做误差最大化。Chopra 与 Ziemba (1993, JPM 19(2)) 在他们考察的风险容忍度下,给出的数量级是:均值误差的影响大约是方差误差的 10 倍、协方差误差的 20 倍。3 个百分点这个例子是另一张账,不是那篇论文的表。
强制权重和等于 1、λ 仍为 3 时,原 α 的解是 1.0325、0.0435、−0.0760。梯度在三只股票上的差是 0,预算约束的一阶条件成立。它仍然做空。最小方差组合是 w ∝ Σ⁻¹ 1,再归一:0.8979、0.1069、−0.0048。高波动股票仍有一个很小的空头。这三组都还不是只做多解。只做多满仓、单票上限 0.50 和只做多最小方差在第 8 节。Clarke、de Silva 与 Thorley (2006, JPM 33(1)) 讨论的是美股里的最小方差组合。
信号合同拒绝负权重,也拒绝一行权重的和大于 1。上面两组无约束解都过不了。若它们都能持有,从第一组换到第二组的单边换手是 0.3460。按每侧 15 bps,这一次的成本是净值的 0.1038%;每侧 25 bps 时是 0.1730%。账户在下单前就会拒绝,这笔换手不会发生。
4. 两种上限,两种剩余¶
袖套是策略信封里的比例,加总为 1,还不是账户里的钱。 乘上分给这条策略的预算之后,再与单名上限比较。超出的部分留在现金,不摊给信封里的其他名字。 袖套函数为了维持和为 1,会把触顶的重量摊回去。账户函数不摊。两道手续不要叠用。 示例预算 30%、单名 10% 时,袖套 0.80 和 0.20 变成账户权重 0.10 和 0.06,剩下的预算留在现金。
inverse_volatility_weights 在触顶之后把多出来的权重摊给还没到顶的名字,最后仍要求和为 1。三只股票、上限 0.40 时,权重变成 0.4000、0.3263、0.2737。上限 0.10 时函数抛出 VolatilityError:三只股票每只至多 10%,加总到不了 1。
sleeve_to_account 不做这件摊回。袖套权重先乘策略预算,单名再与持仓上限比较,超出的部分留在现金里。用 risk_policy.md 里的示例——策略预算 30%、单名 10%——而不是把示例说成唯一参数:
| 袖套 | 乘 0.30 | 账户权重 |
|---|---|---|
| A:0.80 | 0.24 | 0.10 |
| B:0.20 | 0.06 | 0.06 |
B 没有因为 A 触顶而变大。另一组用四位小数的逆波动袖套 0.5455、0.2727、0.1818:账户权重是 0.1000、0.0818、0.0545,合计 0.2364。30% 的预算没有被重新塞满。
from decimal import Decimal
from quant.portfolio.allocation import AllocationPolicy, sleeve_to_account
policy = AllocationPolicy(
strategy_budget_pct=Decimal("0.30"),
max_position_pct=Decimal("0.10"),
max_order_value_pct=Decimal("0.05"),
rebalance_buffer_pct=Decimal("0.01"),
max_cost_skip_pct=Decimal("0.02"),
max_daily_child_orders=4,
lot_size=Decimal("1"),
)
def account_weights(sleeve: dict[str, str]) -> dict[str, Decimal]:
# 触顶后不把剩余预算摊回其他名字。摊回去会让没到顶的股票被动超配。
weights = {name: Decimal(value) for name, value in sleeve.items()}
return sleeve_to_account(weights, policy)
权重缺口小于缓冲就不交易,是为了避免一笔最小佣金去修一个很小的偏离。卖出委托排在买入前面,因为买入不能提前使用还没成交的卖出回款。费用和子单拆分放到 M13。
5. 均衡隐含收益,以及波动目标不能加杠杆¶
Black 与 Litterman (1992, FAJ 48(5)) 用市场权重反推一个先验超额收益 π = δ Σ w_市场,再把观点往这个先验上收。两只资产,市场权重 0.60 和 0.40,波动 20% 和 15%,相关 0.30,δ = 2.5:
π = (0.0690, 0.0360)
协方差是
Σ = [[0.0400, 0.0090], [0.0090, 0.0225]]
因为 0.20² = 0.0400,0.15² = 0.0225,0.30 × 0.20 × 0.15 = 0.0090。Σ w = (0.0276, 0.0144),乘上 δ = 2.5 就是上面的先验。
后验把一条观点收到这条先验上。教学观点:第一只资产的超额收益比第二只高 0.04。写成
P = (1, −1), Q = 0.04
标量 τ = 0.05,表示先验协方差按这个比例缩小后再和观点比较。观点的不确定度取成和先验在这条组合上一样大:
Ω = P(τ Σ) P′ = 0.002225
P Σ P′ = 0.0400 + 0.0225 − 2 × 0.0090 = 0.0445,再乘 τ 得到 0.002225。后验均值是
μ_BL = π + τ Σ P′(Pτ Σ P′ + Ω)⁻¹(Q − Pπ)
先验在这条观点上的值是 Pπ = 0.0690 − 0.0360 = 0.0330。观点比先验高 0.0400 − 0.0330 = 0.0070。分母是 0.002225 + 0.002225 = 0.004450。增益是 0.0070/0.004450 = 1.573034。
τ Σ P′ = (0.001550, −0.000675)。乘上增益,两只资产的更新是 0.002438 和 −0.001062。后验是
μ_BL = (0.071438, 0.034938)
后验利差是 0.071438 − 0.034938 = 0.036500,正好是先验利差 0.0330 和观点 0.0400 的平均。Ω 取得和 Pτ Σ P′ 一样大时,这条相对观点被半信半疑。两只资产不是各自向观点移动同一个数,因为更新沿着 τ Σ P′,协方差把第一只的上修部分传到第二只。τ、Ω 和 0.04 都是教学输入。本章没有用市场数据重估 Black 与 Litterman 的后验。
import numpy as np
def black_litterman(cov: np.ndarray, market: np.ndarray, delta: float, view: np.ndarray, target: float, tau: float) -> np.ndarray:
"""Ω 取观点的先验方差。后验仍可能含空头,送进信号表之前要再检查。"""
prior = delta * cov @ market
picked = view.reshape(1, -1)
omega = picked @ (tau * cov) @ picked.T
gain = (tau * cov) @ picked.T @ np.linalg.inv(picked @ (tau * cov) @ picked.T + omega)
return prior + gain @ (np.array([target]) - picked @ prior)
def two_cluster_hrp(cluster_var: np.ndarray) -> np.ndarray:
# 簇间按方差倒数。这不是 10%、20%、30% 那组逆波动权重。
raw = 1.0 / cluster_var
return raw / raw.sum()
把后验直接送进第 3 节的无约束解,仍可能做出空头和超过 1 的权重。信号合同拒绝这两类。带约束的目标要写成
max_w w′α − λ w′ Σ w
并加上你真正用的限制:w ≥ 0,w 的和不大于 1,行业暴露 Gw = 0,单边换手不超过事先写明的上限。无约束的一阶条件 w = Σ⁻¹ α / (2λ) 只在这些限制都没有绑住时成立。绑住之后要在边界上重解。第 8 节用纯 NumPy 的活跃集把同一组输入解完,仍然不调用 scipy 或 cvxpy。1.0417、0.0446、−0.0761 继续只表示无约束解。
层次风险平价不求 Σ⁻¹。它先按相关把资产分成簇,再按簇的方差倒数把资金分到簇上,簇内重复同一步。两簇教学方差 0.0400 和 0.0100 时,方差倒数 25 和 100,权重是 25/125 = 0.2000 和 100/125 = 0.8000。这不是第 2 节那组 10%、20%、30% 的逆波动权重,也不是 López de Prado (2016) 的样本外结果。它只说明:资金跟着簇的方差走,不需要把很小的特征值放进分母。本章第 7 节里那个 3×3 的迭代把风险份额拉平,仍然是风险平价,不是这一步聚类。
股和债的玩具:波动 16% 和 6%,逆波动权重 0.2727 和 0.7273。相关 −0.2 时组合波动 0.0552;相关 0.4 时权重不变,波动升到 0.0730。若想把波动抬到 10%,需要的放大倍数分别是 1.8117 和 1.3695。target_volatility_scale 在上限 1 之下返回 1.0000。仓位停在原来的波动上。杠杆上限 1.0 同样不允许 1.81 倍。风险平价文献里常见的「配很多低波动资产,再加杠杆」在这个函数里走不通。
6. 只数降不掉共同波动¶
等权、单只波动 40%、两两相关都是 ρ 时,
σₚ = 0.40 √(ρ + (1 − ρ) / N)
| 只数 | ρ = 0 | ρ = 0.3 | ρ = 0.6 |
|---|---|---|---|
| 1 | 40.00% | 40.00% | 40.00% |
| 10 | 12.65% | 24.33% | 32.00% |
| 30 | 7.30% | 22.74% | 31.33% |
| 40 | 6.32% | 22.54% | 31.24% |
| 60 | 5.16% | 22.33% | 31.16% |
| 下限 | 0 | 21.91% | 30.98% |
ρ = 0.3 时,从 30 只到 40 只,波动只从 22.74% 降到 22.54%。下限是 0.40 √0.3 = 21.91%。Statman (1987, JFQA 22(3)) 的结论是另一件事:相对他使用的分散化基准,借款投资者大约要 30 只、把钱借出的投资者大约要 40 只。右图说明的是共同相关留在组合里,不是他的效用门槛。
Jagannathan 与 Ma (2003, JF 58(4)) 说明非负和上限约束在一定写法下相当于改协方差。本章的合同就是这种约束的一个实现:负权重进不了信号表。Miller (1977, JF 32(4)) 讨论卖空受限时,悲观观点进不了价格。只做多账户里,负的 α 对应的权重是 0,不是无约束解里的 −0.0761。转移系数仍按 M09 的定义,用得分和主动权重的相关来算。那里四只股票的例子是 0.9535 和 0.8607。
🔴 深入层:估计误差留在权重里¶
7. 满仓最小方差和解出会做空的优化不是同一条指令¶
最小方差在这组数字里把 89.79% 放在波动 10% 的股票上,并给波动 30% 的股票一个 −0.48% 的权重。把空头换成 0 再归一,是一个启发式。第 8 节在 w ≥ 0、权重和为 1 的边界上重解,得到 17/19、2/19、0。外部优化库仍然不参与打印。
α 和风险模型若缺了同一列,优化器会把那一列上的预期收益当成没有方差的收益。M11 已经说过,轧空不在行业哑变量里。权重公式不会自动给它一行风险贡献。
López de Prado (2016, JPM 42(4)) 用层次聚类避开对样本协方差求逆。那是另一条构造。本章的不等相关例子用迭代把风险份额拉平,只对这一个 3×3 矩阵,不把它说成层次风险平价的样本外结果。
8. 只做多带约束的精确解¶
无约束解里 3 号股的空头是 1 号股的保护伞:它冲销了一部分共同风险。 只做多把这把伞拿走以后,1 号股的边际风险变了,平衡点要在 w ≥ 0 且权重和为 1 的边界上重算。 重算得到的满仓是 56/57、1/57、0。把负数砍成 0 再归一是 70/73、3/73、0,目标函数更差,那是另一个点。 差的那 0.0000632356 就是没在边界上重解、只做了一次归一的代价。
第 3 节的协方差是
Σ = [[0.01, 0.006, 0.009], [0.006, 0.04, 0.018], [0.009, 0.018, 0.09]]
目标仍是最大化 w′α − λ w′ Σ w,λ = 3,梯度是 g = α − 6 Σ w。加上 w ≥ 0、Σ w = 1 之后,内部的名字共用一个乘子 γ;压在 0 的名字必须 gᵢ ≤ γ;压在上限的名字必须 gᵢ ≥ γ。Σ 的最小特征值是 0.008423,目标严格凹,满足这组条件的点就是唯一最优。figures/M12/plot_construction.py 穷尽 27 个边界组合,只保留梯度误差小于 10⁻⁸ 的那个。下面把留下的边界写成方程。
问题 A:只做多满仓。 允许空头的满仓解是 1.0325、0.0435、−0.0760,第三只在下界。前两只权重和为 1,梯度相等:
0.024 w₁ − 0.204 w₂ = 0.02
代入 w₂ = 1 − w₁,得到 0.228 w₁ = 0.224,所以
w⋆ = (56/57, 1/57, 0) = (0.9824561404, 0.0175438596, 0)
组合预期收益 0.0596491228,波动 0.0993546427,目标函数 0.0300350877。第三只的梯度低于 γ,留在 0 上是对的。
把无约束解 (1.0416666667, 0.0446428571, −0.0760582011) 的负数截成 0 再归一,得到
(70/73, 3/73, 0) = (0.9589041096, 0.0410958904, 0)
它的预期收益 0.0591780822,波动 0.0986681814,目标函数 0.0299718521。和最优相差 0.0000632356。两只正权重的梯度差是 0.0053698630,KKT 误差 0.002685,大于 10⁻⁸。截断可行,但不是边界上的解。
问题 B:只做多,并且 wᵢ ≤ 0.50。 问题 A 的 0.9825 越过 0.50,上限绑在第一只上。后两只分掉剩下的 0.50,梯度相等,解是
w⋆⋆ = (½, 245/564, 37/564) = (0.5000000000, 0.4343971631, 0.0656028369)
预期收益 0.0486879433,波动 0.1210706278,目标函数 0.0047136525。上限把目标函数从 0.0300350877 压到这里。第一只的梯度高于 γ,所以顶在 0.50 上。
问题 C:只做多最小方差。 在前两只上最小化 w′ Σ w、权重和为 1。S⁻¹1 正比于 (0.034, 0.004),即 17 : 2。所以
w = (17/19, 2/19, 0) = (0.8947368421, 0.1052631579, 0)
波动 0.0978720970。第三只的边际方差是 0.189/19 = 0.00994737,高于留在组合里的 0.182/19 = 0.00957895,空头不该回来。无约束最小方差 0.8979、0.1069、−0.0048 把那点空头留着。把 −0.0048 换成 0 再归一,大约是 0.8936、0.1064、0,既不是无约束解,也不是 17/19、2/19。
import math
import numpy as np
vols = np.array([0.10, 0.20, 0.30])
corr = np.full((3, 3), 0.30)
np.fill_diagonal(corr, 1.0)
cov = np.outer(vols, vols) * corr
alpha = np.array([0.06, 0.04, 0.02])
def report(label: str, weights: np.ndarray) -> None:
mu = float(weights @ alpha)
variance = float(weights @ cov @ weights)
obj = mu - 3.0 * variance
text = " ".join(f"{value:.10f}" for value in weights)
print(label, text)
print(
label,
"mu",
f"{mu:.10f}",
"vol",
f"{math.sqrt(variance):.10f}",
"obj",
f"{obj:.10f}",
)
def solve_mean_variance(free: list[int], fixed: np.ndarray) -> np.ndarray:
# 自由名字的梯度相等,合在一起补满剩余预算。2λ = 6。
size = len(free)
system = np.zeros((size + 1, size + 1))
system[:size, :size] = 6.0 * cov[np.ix_(free, free)]
system[:size, size] = 1.0
system[size, :size] = 1.0
rhs = np.zeros(size + 1)
rhs[:size] = alpha[free] - 6.0 * (cov[free] @ fixed)
rhs[size] = 1.0 - float(fixed.sum())
solved = np.linalg.solve(system, rhs)
weights = fixed.copy()
weights[free] = solved[:size]
return weights
fixed_a = np.zeros(3)
long_only = solve_mean_variance([0, 1], fixed_a)
fixed_b = np.array([0.50, 0.0, 0.0])
cap50 = solve_mean_variance([1, 2], fixed_b)
block = cov[:2, :2]
direction = np.linalg.solve(block, np.ones(2))
long_min = np.array([direction[0], direction[1], 0.0])
long_min = long_min / long_min.sum()
unc = np.linalg.solve(cov, alpha) / 6.0
naive = np.maximum(unc, 0.0)
naive = naive / naive.sum()
report("long_only_sum1", long_only)
report("long_only_cap50", cap50)
report("long_only_minvar", long_min)
report("naive_clip", naive)
loss = (long_only @ alpha - 3.0 * (long_only @ cov @ long_only)) - (
naive @ alpha - 3.0 * (naive @ cov @ naive)
)
naive_g = alpha - 6.0 * (cov @ naive)
span = float(naive_g[naive > 1e-10].max() - naive_g[naive > 1e-10].min())
print("naive_obj_loss", f"{float(loss):.10f}")
print("naive_grad_span", f"{span:.10f}")
idm_c = np.full((3, 3), 0.25)
np.fill_diagonal(idm_c, 1.0)
idm_w = np.full(3, 1.0 / 3.0)
quad = float(idm_w @ idm_c @ idm_w)
print("idm", f"{1.0 / math.sqrt(quad):.10f}", "quad", f"{quad:.10f}")
9. 分散化乘数(IDM)¶
几条已经达标的波动拼在一起,相关小于 1 时,组合波动会低于目标。IDM 是把波动抬回去的倍数。 期货账户用这个倍数加杠杆。本仓库的放大上限是 1.0,想要的 1.4142135624 会被截成 1。 截成 1 之后,分散带来的低波动就留在账户里,IDM 表现为少加仓,不是再加一档杠杆。 股债玩具里想要的 1.8117 和 1.3695 同样返回 1,是同一条上限。
N 个子策略或资产已经各自做到目标波动时,按权重 w 拼在一起,组合波动会变成目标波动乘上 √(w′ Cw)。C 是它们的相关矩阵。要回到原来的目标波动,需要的倍数是
IDM = 1 / √(w′ Cw)
三只等权、两两相关 0.25:
w′ Cw = 0.25 + (1 − 0.25) / 3 = 0.5
IDM = 1/√0.5 = 1.4142135624
四位小数是 1.4142。每个子系统波动 10% 时,拼起来的波动是 0.10 √0.5 = 0.0707106781。想要的放大倍数就是这个 IDM。
target_volatility_scale 的 max_scale 必须落在 (0, 1]。上例调用返回 1.0000,想要的 1.4142135624 被截掉。权重和为 1、相关不超过 1 时,w′ Cw ≤ 1,所以 IDM 至少是 1。IDM 大于 1 的加仓倍数过不了这个上限,仓位停在分散之后的波动上。IDM 等于 1 时,相关二次型等于 1,想要的倍数也是 1,上限没有再削去一块。股债玩具里想要的 1.8117 和 1.3695 同样返回 1,是同一条上限。当前波动已经高于目标时,函数返回小于 1 的降风险倍数,那一步不是 IDM。
10. 纯 NumPy 与工业库对照¶
| 项目 | 纯 NumPy 活跃集 | scipy.optimize.minimize(SLSQP) |
cvxpy |
|---|---|---|---|
| 目标 | 最大化 w′α − λ w′ Σ w | 最小化这个目标的相反数 | Maximize,二次项用 quad_form |
| 约束 | 枚举下界、内点、上限 | bounds 加权重和为 1 |
sum(w)==1,w>=0,w<=upper |
| 本环境 | 脚本核对,梯度误差 < 10⁻⁸ | 可选库没装上时降级,打印 skipped | 可选库没装上时降级,打印 skipped |
| 最优性 | 最小特征值 0.008423,KKT 点唯一 | 凸问题下应落到第 8 节的同一点 | 同左 |
# 工业库对照。自带第 8 节的输入,单独运行也不依赖上一块。可选库没装上时降级,只打印 skipped。
import numpy as np
vols = np.array([0.10, 0.20, 0.30])
corr = np.full((3, 3), 0.30)
np.fill_diagonal(corr, 1.0)
cov = np.outer(vols, vols) * corr
alpha = np.array([0.06, 0.04, 0.02])
def industrial_long_only(alpha, cov, lam, upper):
"""SLSQP 与 cvxpy 的标准写法。装上库之后应落到第 8 节的活跃集解。"""
try:
from scipy.optimize import minimize
except ImportError:
print("scipy_slsqp", "skipped")
else:
def neg(weights):
return -float(weights @ alpha - lam * (weights @ cov @ weights))
cons = {"type": "eq", "fun": lambda weights: float(np.sum(weights) - 1.0)}
bounds = [(0.0, upper)] * len(alpha)
start = np.full(len(alpha), 1.0 / len(alpha))
result = minimize(
neg, start, method="SLSQP", bounds=bounds, constraints=cons
)
text = " ".join(f"{value:.10f}" for value in result.x)
print("scipy_slsqp", f"{upper:.2f}", text)
try:
import cvxpy as cp
except ImportError:
print("cvxpy_long_only", "skipped")
else:
weights = cp.Variable(len(alpha))
gain = alpha @ weights - lam * cp.quad_form(weights, cov)
constraints = [cp.sum(weights) == 1, weights >= 0, weights <= upper]
cp.Problem(cp.Maximize(gain), constraints).solve()
text = " ".join(f"{float(value):.10f}" for value in weights.value)
print("cvxpy_long_only", f"{upper:.2f}", text)
industrial_long_only(alpha, cov, 3.0, 1.0)
industrial_long_only(alpha, cov, 3.0, 0.5)
11. 多袖套汇总与现金担保看跌的行权冻结¶
总账户净值 100,000 美元。动量股票袖套的预算是 60%,即 60,000 美元。期权卫星袖套卖出 1 张行权价 95、乘数 100 的现金担保看跌,行权要付的现金是 95 × 100 = 9,500 美元。权利金 200 美元已经在这 100,000 里面,不能拿来抵消行权时仍要付出的 9,500。本仓库的现货适配器不发送这张看跌。账户里一旦有它,现金规则就先冻结。
可用自由现金 = 100,000 − 9,500 = 90,500。股票袖套只能在预算和这笔自由现金里取较小的一个:min(60,000, 90,500) = 60,000。买完之后闲置 90,500 − 60,000 = 30,500。三者加冻结,60,000 + 9,500 + 30,500 = 100,000。若把 9,500 也当成可买现金,指派当天要再凑行权款,账户会去用融资。期权还没到期,这笔冻结也要留着。
# goal8-check
"""股票袖套动用现金之前,先冻结看跌行权款。离线。"""
from decimal import Decimal
def reconcile_multi_sleeve_cash(
nav: Decimal,
stock_budget_pct: Decimal,
strike: Decimal,
contracts: int,
multiplier: int,
premium: Decimal,
) -> tuple[Decimal, Decimal, Decimal, Decimal]:
# 权利金已含在净值里。指派仍按行权价乘乘数付出现金。
reserve = strike * contracts * multiplier
free_cash = nav - reserve
stock_budget = nav * stock_budget_pct
stock_spendable = min(stock_budget, free_cash)
idle = free_cash - stock_spendable
if premium < 0:
raise ValueError("premium")
return reserve, free_cash, stock_spendable, idle
reserve, free_cash, stock_spendable, idle = reconcile_multi_sleeve_cash(
Decimal("100000"),
Decimal("0.60"),
Decimal("95"),
1,
100,
Decimal("200"),
)
print(f"{reserve:.2f}")
print(f"{free_cash:.2f}")
print(f"{stock_spendable:.2f}")
print(f"{idle:.2f}")
assert reserve == Decimal("9500.00")
assert free_cash == Decimal("90500.00")
assert stock_spendable + reserve + idle == Decimal("100000")
真实案例¶
第 3 节的 α 和协方差仍是给定的手算。下面两间实验室的输出,是同一类「等权还是碰波动率」的问题在真实净值上的账单。
案例 1:前 30 等权,和把 1/σ 再截断的那一版¶
momentum_lab/outputs/iteration_summary.csv 从 10 万美元记起。BM_Top30_EW 是前 30 等权、月度再平衡:复利 21.79%,波动 21.69%,夏普 1.02,最大回撤 43.92%,年化换手 0.35 倍,期末 3,622,024 美元。Iter2_OverPenalized_LowVol 在得分里已经除过波动率,权重再用 1/σ,波动率下限 0.12,单名先截到 0.22 并且不把截掉的部分摊回。复利 21.26%,波动 20.81%,夏普 1.03,最大回撤 30.12%,换手 3.90 倍,期末 3,344,369 美元。
回撤从 43.92% 收到 30.12%,夏普从 1.02 到 1.03。换手从 0.35 倍升到 3.90 倍,期末少了大约 28 万美元。低波动的名字在 1/σ 里动得最大,月度波动率的噪声把换手放大。这是实验室命名的踩坑,不是「逆波动一定比等权多赚」的证明。信号合同仍拒绝和大于 1 的权重,也拒绝负权重。这张表里的两行都是只做多。
案例 2:47 只等权,和复合得分上的逆波动¶
momentum_plus_lab/outputs/performance_summary.csv 的 BM_EW 是 2015 年成分里留在种子中的股票等权:复利 22.21%,波动 19.18%,夏普 1.14,最大回撤 30.89%,年化单边换手 0.23 倍,相对 SPY 的信息比率 1.44。S8_COMPOSITE_PLUS 先用复合得分选股票,再按 63 日波动的倒数配权,触顶后的剩余留在现金。复利 19.79%,波动 22.58%,夏普 0.91,最大回撤 33.06%,换手 2.05 倍,信息比率 0.45。
两行的股票集合不一样。等权是整张可交易表,逆波动只落在当期入选的名字上。波动没有更低,回撤没有更浅,换手高了一个数量级。DeMiguel、Garlappi 与 Uppal 的结论是优化常常胜不过 1/N。这里能核对的是这两份输出,不是把他们的七个数据集重跑了一遍。
案例 3:30 只股票消不掉的那一项,换手也消不掉¶
正文里 ρ = 0.3、单只波动 40% 时,30 只等权的组合波动是 22.74%。实验室的等权波动停在 21.69% 和 19.18%,数量级相同:只数上去之后,剩下的是股票彼此一起走的那一块。逆波动改变的是谁多谁少,以及为此付出的换手。Iter2 的 3.90 倍和 S8 的 2.05 倍,都还要再过 M13 的 2τ c。权重表若出现负数或和大于 1,在算换手之前就会被信号合同拒绝。
方法论来源¶
| 概念 / 模型 | 核心文献或规范出处 |
|---|---|
| 1/N | DeMiguel, Garlappi & Uppal (2009), RFS 22(5) |
| 风险平价 | Maillard, Roncalli & Teïletche (2010), JPM 36(4);Qian (2005) |
| 误差最大化 | Michaud (1989), FAJ 45(1);Chopra & Ziemba (1993), JPM 19(2) |
| 最小方差 | Clarke, de Silva & Thorley (2006), JPM 33(1) |
| Black–Litterman | Black & Litterman (1992), FAJ 48(5) |
| 层次风险平价 | López de Prado (2016)。本章两簇权重 0.2000 与 0.8000 是教学方差 |
| 约束与卖空 | Jagannathan & Ma (2003), JF 58(4);Miller (1977) |
| 预测缩放、IDM、缓冲 | Robert Carver, Systematic Trading(2015)。IDM 的加仓倍数受放大上限 1 约束 |
| 只做多均值—方差 | 第 8 节活跃集。scipy / cvxpy 只作对照,默认环境不执行 |
| 分散化只数 | Statman (1987), JFQA 22(3) |
| 账户映射 | sleeve_to_account,触顶后不摊回 |
| 图 | figures/M12/plot_construction.py |
| 等权与逆波动 | Labs/momentum_lab/outputs/iteration_summary.csv;Labs/momentum_plus_lab/outputs/performance_summary.csv |
💡 思考回顾与巩固¶
核心认知纠偏¶
| 误区 | 反例 | 用法 |
|---|---|---|
| 有相关时,逆波动就不是风险平价 | 相关同为 0 或同为 0.5 时,份额都是 ⅓ | 相关不一致时才分开算 |
| 无约束权重可以直接下单 | 1.0417 与 −0.0761 同时违反「和 ≤ 1」和「非负」 | 先过信号合同 |
| 把权重除以总和,就是加上预算约束的解 | 冲击后的无约束和是 1.4790;除以总和会把 162% 藏起来 | 满仓解要重新写一阶条件 |
| 触顶之后应该把权重摊给别的股票 | sleeve_to_account 里 B 停在 0.06,A 停在 0.10 |
袖套函数和账户函数分开 |
| 三只股票也可以每只最多 10% 还满仓 | 逆波动函数对上限 0.10 抛出 VolatilityError |
10% 上限至少要 10 只才满得了仓 |
| 风险平价加杠杆才能达到目标波动,所以账户也应该加 | 需要 1.8117 倍时,函数返回 1 | 杠杆上限 1.0 |
| 30 只股票就把个股风险分散完了 | ρ = 0.3 时,30 只还有 22.74%,下限 21.91% | 只数消的是 (1 − ρ) / N |
| 最小方差一定只做多 | 这组数字给出 −0.0048 | 空头仍要过合同 |
| 负数截成 0 再归一就是只做多最优 | 截断是 70/73、3/73、0;最优是 56/57、1/57、0;目标函数少 0.0000632356 | 在边界上重解 |
| IDM 1.4142 可以拿来加仓 | 想要 1.4142135624 时,放大函数返回 1 | 上限是 1 |
| 一次 α 改动的换手成本已经发生 | 0.1038% 是两组被拒绝的权重之间的假想成本 | 拒绝发生在下单之前 |
| 市场隐含收益可以略过 | δ = 2.5 时先验是 6.90% 和 3.60% | 观点先对着这个先验收 |
费曼自测¶
- 为什么相关同为 0.5 时,逆波动权重的风险份额仍然各是 ⅓?
- 无约束解的和已经是 1.0103。为什么还要单独写一个「权重和等于 1」的解?
- A 从 0.24 被截到 0.10 之后,为什么 B 还是 0.06?
- 右图里 ρ = 0.3 的曲线为什么在 30 只以后几乎走平?
自测要点
1. 权重正比于 1/σ 时,wᵢσᵢ 是同一个常数。相关矩阵又是常数,每只股票在组合方差里的份额就相同。份额相同就是风险平价。相关矩阵一旦不均匀,0.3696 和 0.2609 就会分开。 2. 和等于 1.0103 只是这组 α、Σ 和 λ = 3 碰出来的。冲击之后的和是 1.4790。强制满仓要另解乘子,而且本题的满仓解 1.0325、0.0435、−0.0760 仍然做空。除以总和既不是满仓优化,也过不了非负约束。 3. 账户函数只做两件事:乘预算,然后与单名上限取小。它不把 A 多出来的 0.14 分给 B。袖套里的逆波动函数才会为了维持和为 1 而摊回去,所以 0.40 的上限给出 0.4000、0.3263、0.2737。 4. 公式里随 N 下降的是 (1 − ρ) / N。ρ = 0.3 时,这一项在 N = 30 已经是 0.0233,N = 40 是 0.0175。开方之后,组合波动从 22.74% 到 22.54%。剩下的 21.91% 是共同部分。四维习题¶
A. 概念题¶
- 无约束均值—方差的目标若写成 w′α − λ w′ Σ w,解的分母为什么是 2λ?
- 逆波动和风险平价在什么时候权重相同?不等相关的例子里,中间那只股票的权重为什么上升?
inverse_volatility_weights和sleeve_to_account对触顶的处理差在哪里?- 为什么 1.81 倍的波动目标在本仓库里不会被执行?
B. 计算题¶
- 波动 10%、20%、30%,计算逆波动权重。相关为 0 时,验证三个方差份额都是 ⅓,组合波动是 0.0945。
- 写出 λ = 3 时的两组无约束权重,以及原 α 在「和为 1」下的权重。说明信号合同拒绝哪几条。
- 袖套 0.80 和 0.20,预算 0.30,单名上限 0.10。账户权重各是多少?
- 用右图的公式计算 ρ = 0.3、N = 10 和 N = 30 的组合波动,并与 21.91% 的下限比较。
- 用第 8 节的同一组输入,写出只做多满仓、上限 0.50、只做多最小方差的权重。截断再归一的目标函数少多少?三等权、相关 0.25 的 IDM 在放大上限 1 下返回多少?
C. 编程实战题¶
- 运行
figures/M12/plot_construction.py,核对逆波动 0.5455、无约束 1.0417 与 −0.0761、冲击后 1.6220,以及 ρ = 0.3 时 30 只股票的 0.2274。 - 把相关矩阵改成全 0.5,确认风险平价权重回到逆波动;再改回不均匀矩阵,确认份额 0.3696、0.2609、0.3696。
- 用
sleeve_to_account复现 0.10 和 0.06。把单名上限改成 0.40、预算改成 1,对照逆波动函数给出的 0.4000、0.3263、0.2737,说明两个函数不再给出同一组数。
D. 个人实战思考题¶
- 你只有 15 到 25 只股票,只做多,杠杆上限 1.0。无约束解、逆波动袖套、账户层上限,哪一层先绑定?
- 一个主观名单想「把最有把握的三只配满」。10% 的单名示例和 30% 的策略预算示例允许的最大账户权重是多少?剩下的预算留在哪里?
- 实验记录里若写了优化器权重,应该附上哪四个输入,才能判断它有没有违反信号合同?
完整参考答案与解析
**A1.** 对 w′ Σ w 求导得到 2 Σ w。目标里 λ 乘的是整个二次项,所以驻点满足 α − 2λ Σ w = 0。把 λ 定义成二次项系数的一半时,分母里就没有这个 2。本章的打印使用前一种定义,λ = 3。 **A2.** 相关矩阵是常数(包括全为 0)时,逆波动的风险份额已经相等,两个权重相同。不均匀矩阵里,中间那只与两边的相关都是 0.10,左右两只彼此相关 0.60。逆波动忽略了这点,份额是 0.3696、0.2609、0.3696。风险平价把中间的权重从 0.2727 提高到 0.3132,左右两边降到 0.5151 和 0.1717。 **A3.** 逆波动函数为了保持和为 1,把超出上限的部分按剩余空间摊回。三只股票、上限 0.40,得到 0.4000、0.3263、0.2737;上限 0.10 则无解。账户函数乘完预算后直接截断,不摊回。0.80 和 0.20 变成 0.10 和 0.06。 **A4.** 股债玩具在相关 −0.2 时波动只有 0.0552,要放大 1.8117 倍才到 10%。函数的放大上限是 1,返回 1.0000。政策里的杠杆上限也是 1.0。相关改成 0.4 以后,波动升到 0.0730,需要的倍数仍有 1.3695,返回值还是 1。 **B5.** 权重 6/11, 3/11, 2/11,即 0.5455、0.2727、0.1818。三条 wᵢ²σᵢ² 都是 0.36/121,份额各 1/3。方差 1.08/121 = 0.008926,波动 0.0945。 **B6.** 无约束原 α:1.0417、0.0446、−0.0761,和 1.0103。冲击后:1.6220、−0.0223、−0.1207,和 1.4790。和为 1 的原 α:1.0325、0.0435、−0.0760。三组都有负权重;前两组的和还大于 1。信号合同两条都会拒绝。最小方差 0.8979、0.1069、−0.0048 同样因为 −0.0048 被拒绝。 **B7.** 0.80 × 0.30 = 0.24,截成 0.10。0.20 × 0.30 = 0.06,不到上限,保持 0.06。合计 0.16,低于 0.30 的预算。 **B8.** √(0.3 + 0.7/10) = √0.37 = 0.6083,乘 0.40 得 0.2433。√(0.3 + 0.7/30) = √0.32333 = 0.5686,乘 0.40 得 0.2274。下限 0.40 √0.3 = 0.2191。10 只还比下限高 2.42 个百分点,30 只高 0.83 个百分点。 **B9.** 只做多满仓是 56/57、1/57、0,目标函数 0.0300350877。上限 0.50 是 1/2、245/564、37/564。只做多最小方差是 17/19、2/19、0。截断再归一是 70/73、3/73、0,目标函数少 0.0000632356。IDM 是 1.4142135624,函数返回 1。 **C10–C12.** 左图和账户权重由 `figures/M12/plot_construction.py` 打印。后验和两簇权重用第 5 节的函数。 `cap_0.10` 在脚本里抛出 `VolatilityError`。账户层 0.10 与 0.06 来自 `sleeve_to_account`,触顶后不摊回。 **D13.** 先绑定的是非负、权重和 ≤ 1,以及放大倍数 ≤ 1。15 到 25 只股票上,10% 的单名示例可以满仓,因为 15×0.10 大于 1;3 只则不行。无约束解里的 104% 和空头在进入袖套之前就会被拒绝。逆波动可以作为袖套,再乘策略预算。 **D14.** 单名账户权重的示例上限是 0.10,三只合计至多 0.30,正好用完 30% 的策略预算示例。任何一只袖套权重乘 0.30 超过 0.10,超出的部分留在现金,不摊给另外两只。这不是「三只配满整个账户」。 **D15.** 四个输入是:α(含期限)、Σ、目标函数里的 λ,以及约束是无约束、和为 1,还是非负。另外写明权重是袖套还是已经乘过预算的账户权重。和大于 1 或出现负数,就还不是可以交给 `attach` 的表。换手成本要单独附单边换手和每侧费率;0.1038% 只在两组权重都能成交时成立。📌 章节知识总结¶
一句话核心洞察¶
- 波动 10%、20%、30% 的逆波动权重是 0.5455、0.2727、0.1818。相关彼此相同的时候,风险份额各是 ⅓。
- λ = 3 的无约束解是 1.0417、0.0446、−0.0761。α 再加 3 个百分点,低波动股票变成 1.6220。信号合同拒绝负权重,也拒绝和大于 1。
- 袖套函数触顶后为了和为 1 会摊回。账户函数触顶后把剩余预算留成现金:0.80 和 0.20 变成 0.10 和 0.06。
- 波动目标要 1.81 倍时,放大函数返回 1。杠杆上限是 1.0。
- 单只波动 40%、相关 0.3 时,30 只等权的组合波动是 22.74%,下限是 21.91%。
- 只做多满仓是 56/57、1/57、0。单票上限 0.50 时是 ½、245/564、37/564。只做多最小方差是 17/19、2/19、0。等权、相关 0.25 的 IDM 是 1.4142135624,放大函数返回 1。
公式与样本¶
| 对象 | 表达式或数值 | 前提 |
|---|---|---|
| 无约束解 | w = Σ⁻¹ α / (2λ) | 目标是 w′α − λ w′ Σ w |
| 逆波动 | 0.5455、0.2727、0.1818 | 波动 10%、20%、30% |
| 零相关波动 | 0.0945,份额各 ⅓ | 同上,相关为 0 |
| 不均匀相关 | 份额 0.3696、0.2609、0.3696 | 左右相关 0.60,最小特征值 0.40 |
| 风险平价权重 | 0.5151、0.3132、0.1717 | 只对这个 3×3 |
| α 冲击 | 1.0417 → 1.6220 | 最低波动的 α 从 0.06 到 0.09,λ = 3 |
| 假想换手 | 单边 0.3460;15 bps 成本 0.1038% | 两组解都要能成交 |
| 账户截断 | 0.10 与 0.06 | 预算 0.30,上限 0.10,不摊回 |
| 股债玩具 | 波动 0.0552 与 0.0730;放大返回 1 | 想要的倍数 1.8117、1.3695 |
| 等权波动 | 0.40 √(ρ + (1 − ρ) / N) | ρ = 0.3、N = 30 时为 0.2274 |
| 先验超额 | 0.0690、0.0360 | δ = 2.5,市场权重 0.60 / 0.40 |
| 后验超额 | 0.071438、0.034938 | τ = 0.05,观点利差 0.04,Ω = 0.002225;利差 0.036500 |
| 只做多满仓 | 56/57、1/57、0 | 目标函数 0.0300350877;截断再归一少 0.0000632356 |
| 单票上限 0.50 | ½、245/564、37/564 | 目标函数 0.0047136525 |
| 只做多最小方差 | 17/19、2/19、0 | 波动 0.0978720970;无约束解仍含 −0.0048 |
| IDM | 1/√0.5 = 1.4142135624 | 三等权、相关 0.25;放大函数返回 1 |
跨模块¶
| 结论 | 交给 | 位置 |
|---|---|---|
| α 的期限和 IC | 优化的输入 | M10 |
| Σ 和风险贡献 | 份额 | M11 |
| 只做多的转移系数 | 得分和主动权重的相关 | M09 |
| 非负、和 ≤ 1 | 信号表 | ../../../src/quant/core/signals.py |
| 逆波动与只降波动 | 袖套 | ../../../src/quant/risk/volatility.py |
| 触顶留现金、先卖后买 | 账户权重和委托 | ../../../src/quant/portfolio/allocation.py |
| 杠杆上限 1.0,单名 10% 是示例 | 政策 | ../../../docs/risk_policy.md |
| 换手怎么变成费用 | 执行 | M13 |
| 2022 年 Mkt-RF −20.99% | 共同部分的实现 | M07 |
学习指引¶
概念卡片:Black–Litterman 模型 · 风险平价与层次风险平价
- 精读:黑箱第 6 章怎样把规则和优化分成两类。GK 第 14 章的构建和第 15 章的多空,对照本章的非负约束。
- 对照:权重以脚本为准。DeMiguel 等人的 3000 个月、Chopra 与 Ziemba 的 10 倍和 20 倍、Statman 的 30 与 40,都是论文里的结论,出处见方法论来源。
- 项目:袖套可以用逆波动。变成账户权重时走
sleeve_to_account,不要再归一一次。无约束解先检查符号与总和。 - 下一步:M13 把缓冲、最小佣金和子单拆分写成费用。本章的 0.1038% 只是一次假想换手。
自检清单¶
- 能把逆波动算成 6/11, 3/11, 2/11,并说明常数相关下份额各是 ⅓
- 能写出 1.0417、0.0446、−0.0761,以及冲击后的 1.6220
- 能说明 0.80、0.20 在预算 0.30 和上限 0.10 下变成 0.10、0.06
- 能说明 1.8117 倍的目标波动会被函数收成 1
- 能算出 ρ = 0.3、30 只股票时组合波动 22.74%,下限 21.91%
- 能指出最小方差这组解因为 −0.0048 过不了信号合同
- 能写出只做多满仓 56/57、1/57、0,以及截断再归一的目标函数损失 0.0000632356
- 能说明 IDM 1.4142135624 在放大上限 1 下返回 1
