跳转至

M12 组合构建与优化

层级:L3 组合管理 | 优先级:★★ | 预计学时:12 小时 对应原书:黑箱第 6 章;GK 第 14–15 章;因子 7.3 前置知识:M10 的 α;M11 的协方差;M09 的转移系数。 读书笔记:黑箱第 6 章的权重 状态:reviewed 数据截至:2026-10|最近复核:2026-10-28


学习目标

  1. 手算逆波动权重,并分清它在常数相关和不等相关下是否等于风险平价。
  2. 写出无约束均值—方差解,并对照本仓库拒绝负权重、拒绝权重和大于 1 的合同。
  3. 区分两种「上限」:袖套内截断后再归一,以及账户层截断后把剩余预算留成现金。
  4. 用等权公式看只数增加以后还剩下的共同波动。
  5. 把一次换手的成本和杠杆上限放在权重后面,而不是放在优化目标里面。
  6. 在同一组 α 和 Σ 上写出只做多满仓、单票上限 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)) 讨论的是美股里的最小方差组合。

左:同一组 10%、20%、30% 波动。无约束解可以超过 100%,也可以为负。右:等权只数增加后,共同相关还留在组合波动里。

信号合同拒绝负权重,也拒绝一行权重的和大于 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% 观点先对着这个先验收

费曼自测

  1. 为什么相关同为 0.5 时,逆波动权重的风险份额仍然各是 ⅓?
  2. 无约束解的和已经是 1.0103。为什么还要单独写一个「权重和等于 1」的解?
  3. A 从 0.24 被截到 0.10 之后,为什么 B 还是 0.06?
  4. 右图里 ρ = 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. 概念题

  1. 无约束均值—方差的目标若写成 w′α − λ w′ Σ w,解的分母为什么是 2λ?
  2. 逆波动和风险平价在什么时候权重相同?不等相关的例子里,中间那只股票的权重为什么上升?
  3. inverse_volatility_weights 和 sleeve_to_account 对触顶的处理差在哪里?
  4. 为什么 1.81 倍的波动目标在本仓库里不会被执行?

B. 计算题

  1. 波动 10%、20%、30%,计算逆波动权重。相关为 0 时,验证三个方差份额都是 ⅓,组合波动是 0.0945。
  2. 写出 λ = 3 时的两组无约束权重,以及原 α 在「和为 1」下的权重。说明信号合同拒绝哪几条。
  3. 袖套 0.80 和 0.20,预算 0.30,单名上限 0.10。账户权重各是多少?
  4. 用右图的公式计算 ρ = 0.3、N = 10 和 N = 30 的组合波动,并与 21.91% 的下限比较。
  5. 用第 8 节的同一组输入,写出只做多满仓、上限 0.50、只做多最小方差的权重。截断再归一的目标函数少多少?三等权、相关 0.25 的 IDM 在放大上限 1 下返回多少?

C. 编程实战题

  1. 运行 figures/M12/plot_construction.py,核对逆波动 0.5455、无约束 1.0417 与 −0.0761、冲击后 1.6220,以及 ρ = 0.3 时 30 只股票的 0.2274。
  2. 把相关矩阵改成全 0.5,确认风险平价权重回到逆波动;再改回不均匀矩阵,确认份额 0.3696、0.2609、0.3696。
  3. 用 sleeve_to_account 复现 0.10 和 0.06。把单名上限改成 0.40、预算改成 1,对照逆波动函数给出的 0.4000、0.3263、0.2737,说明两个函数不再给出同一组数。

D. 个人实战思考题

  1. 你只有 15 到 25 只股票,只做多,杠杆上限 1.0。无约束解、逆波动袖套、账户层上限,哪一层先绑定?
  2. 一个主观名单想「把最有把握的三只配满」。10% 的单名示例和 30% 的策略预算示例允许的最大账户权重是多少?剩下的预算留在哪里?
  3. 实验记录里若写了优化器权重,应该附上哪四个输入,才能判断它有没有违反信号合同?
完整参考答案与解析 **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 节的函数。
import numpy as np

cov = np.array([[0.04, 0.009], [0.009, 0.0225]])
post = black_litterman(cov, np.array([0.60, 0.40]), 2.5, np.array([1.0, -1.0]), 0.04, 0.05)
assert abs(float(post[0]) - 0.071438) < 5e-7
weights = two_cluster_hrp(np.array([0.04, 0.01]))
assert abs(float(weights[0]) - 0.20) < 1e-12
`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% 只在两组权重都能成交时成立。

📌 章节知识总结

一句话核心洞察

  1. 波动 10%、20%、30% 的逆波动权重是 0.5455、0.2727、0.1818。相关彼此相同的时候,风险份额各是 ⅓。
  2. λ = 3 的无约束解是 1.0417、0.0446、−0.0761。α 再加 3 个百分点,低波动股票变成 1.6220。信号合同拒绝负权重,也拒绝和大于 1。
  3. 袖套函数触顶后为了和为 1 会摊回。账户函数触顶后把剩余预算留成现金:0.80 和 0.20 变成 0.10 和 0.06。
  4. 波动目标要 1.81 倍时,放大函数返回 1。杠杆上限是 1.0。
  5. 单只波动 40%、相关 0.3 时,30 只等权的组合波动是 22.74%,下限是 21.91%。
  6. 只做多满仓是 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