【Python量化策略评估体系 #01】策略赚钱了,是谁的功劳?Brinson 三件套拆解实战

系列定位:策略跑完回测 = "赚钱了"?其实远远不够。本系列用 6 篇系统化方法论 + Python 实战,把"黑盒跑数"升级为"白盒拆解"。

本篇是该系列 #01:把策略收益拆成行业配置 / 个股选择 / 交互效应 三件套。

一、策略赚钱 ≠ 你厉害

读者常见误区:策略回测 +20%,就觉得自己"会做量化"了。

但请回答三个灵魂拷问:

  1. 这 +20% 是大盘涨带飞的,还是选股赚的?
  2. 我是压对了赛道(食品饮料、新能源),还是在赛道里挑对了股(茅台 vs 五粮液)?
  3. 如果明年市场风格切换,这个策略还能不能跑赢?

不回答这三个问题,策略就是"黑盒跑数"——赚了不知道为啥赚,亏了不知道为什么亏。Brinson(1986)三件套就是把这两个问题量化的经典框架。

二、本文你将得到什么

  1. 行业配置效应(Allocation):组合的行业权重 vs 基准,差出来的收益
  2. 个股选择效应(Selection):在同行业内挑股的能力
  3. 交互效应(Interaction):权重 × 选股 的协同
  4. 完整 Python 实现:12 只 A 股大盘股,跑动量 top5 等权策略,三件套拆解超额收益

三、Brinson 三件套原理

假设你管理一个组合 P,基准是 B。每个标的 s 都有:

  • 权重 w_P(s) / w_B(s)
  • 收益 r(s)

按行业 j 分组后,对每个行业计算:

行业配置效应 (Allocation_j) = (w_P_j − w_B_j) × (r_B_j − r_B_total)
个股选择效应 (Selection_j) = w_B_j × (r_P_j − r_B_j)
交互效应 (Interaction_j) = (w_P_j − w_B_j) × (r_P_j − r_B_j)

其中 r_P_j / r_B_j 是组合 / 基准在行业 j 内的加权平均收益。

加总所有行业:

超额收益 (P − B) = Σ(Allocation_j) + Σ(Selection_j) + Σ(Interaction_j)

数学上恒等(残差 = 0),证明你的拆解是完备的——不会漏掉、不会重复算。

直觉解读:

  • Allocation > 0 :你超配了好行业(压对了赛道)
  • Selection > 0 :你在每个行业内挑对了股
  • Interaction > 0 :你压对的行业里又挑对了股(双优)
  • Interaction < 0 :你压对了好行业,但选股拖了后腿(典型场景:重仓赛道里的弱势股)

四、完整 Python 实现

from collections import defaultdict
from typing import Dict, Tuple

def brinson_attribution(
portfolio: Dict[str, float], # 组合权重 {stock: weight}
benchmark: Dict[str, float], # 基准权重
returns: Dict[str, float], # 每只股票区间收益
industry: Dict[str, str], # 股票 -> 行业
) -> Dict:
"""Brinson-Fachler (1986) 三件套归因。"""

1. 聚合到行业维度

by_ind_p = defaultdict(lambda: {"w": 0.0, "rw": 0.0}) # 组合
by_ind_b = defaultdict(lambda: {"w": 0.0, "rw": 0.0}) # 基准

for s in portfolio.keys() | benchmark.keys():
ind = industry.get(s, "其他")
wp = portfolio.get(s, 0.0)
wb = benchmark.get(s, 0.0)
r = returns.get(s, 0.0)

by_ind_p[ind]["w"] += wp
by_ind_p[ind]["rw"] += wp * r
by_ind_b[ind]["w"] += wb
by_ind_b[ind]["rw"] += wb * r

2. 基准总收益

rb_total = sum(d["rw"] for d in by_ind_b.values())

3. 逐行业拆解

rows = []
all_ind = set(by_ind_p.keys()) | set(by_ind_b.keys())
for ind in sorted(all_ind):
wp_j = by_ind_p[ind]["w"]
wb_j = by_ind_b[ind]["w"]
rp_j = by_ind_p[ind]["rw"] / wp_j if wp_j > 0 else 0.0
rb_j = by_ind_b[ind]["rw"] / wb_j if wb_j > 0 else 0.0

alloc = (wp_j - wb_j) * (rb_j - rb_total)
selec = wb_j * (rp_j - rb_j)
inter = (wp_j - wb_j) * (rp_j - rb_j)

rows.append({
"industry": ind,
"wp_j": wp_j, "wb_j": wb_j,
"rp_j": rp_j, "rb_j": rb_j,
"allocation": alloc,
"selection": selec,
"interaction": inter,
"excess_total": alloc + selec + inter,
})

4. 加总

total_alloc = sum(r["allocation"] for r in rows)
total_selec = sum(r["selection"] for r in rows)
total_inter = sum(r["interaction"] for r in rows)

rp_total = sum(returns.get(s, 0.0) * portfolio.get(s, 0.0)
for s in portfolio.keys())
excess = rp_total - rb_total

return {
"industry_detail": rows,
"rb_total": rb_total,
"rp_total": rp_total,
"excess": excess,
"allocation": total_alloc,
"selection": total_selec,
"interaction": total_inter,
"residual": excess - (total_alloc + total_selec + total_inter),
}

五、用真实 API 拉数据

from mairui import Client

DEMO_LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073"

def fetch_period_return(code: str, start: str, end: str) -> float:
"""拉区间日线,返回 (末收盘 − 首开盘) / 首开盘。"""
cli = Client(DEMO_LICENCE)
raw = cli.stock_history(code=code, st=start, et=end,
period="d", dividend="f") or []
if len(raw) < 2:
return 0.0
return (raw[-1]["c"] - raw[0]["o"]) / raw[0]["o"]

关键参数:

  • period="d":日线(m1/m5/m30 为分钟线)
  • dividend="f":前复权(不复权 n、后复权 b)
  • st / et:YYYYMMDD 或 YYYY-MM-DD 均可

六、构建组合 vs 基准

策略:动量 top5 等权(买过去收益最高的 5 只)

def build_weights(returns: Dict[str, float]):
sorted_stocks = sorted(returns.items(), key=lambda x: -x[1])
top5 = [s for s, _ in sorted_stocks[:5]]
portfolio = {s: 0.20 for s in top5} # top5 各 20%
benchmark = {s: 1.0 / len(returns) for s in returns} # 全等权
return portfolio, benchmark

基准也可以换成沪深 300 / 中证 500 指数权重(生产环境从指数成分股按权重计算)。

七、真实跑通结果

======================================================================
Brinson 三件套归因 Demo
区间:2025-09-01 ~ 2025-12-01 标的:12 只 A 股大盘股
======================================================================

[1] 拉取每只股票区间收益...
600519 (食品饮料): -6.66%
000001 (银行 ): +2.93%
300750 (电力设备): -4.28%
601318 (非银金融): +3.09%
000333 (家用电器): +0.05%
600036 (银行 ): -4.18%
601398 (银行 ): -2.42%
600276 (医药生物): -7.40%
600887 (食品饮料): +3.09%
000858 (食品饮料): +6.04%
601888 (社会服务): +8.67%
002594 (汽车 ): -4.37%

[2] 构建组合(动量 top5 等权)vs 基准(12 只等权)...
组合: 000001:20% 000858:20% 600887:20% 601318:20% 601888:20%
基准:等权 12 只 (各 ~8.33%)

[3] Brinson 三件套拆解...
基准总收益 Rb = -0.45%
组合总收益 Rp = +4.76%
超额收益 (Rp - Rb) = +5.22%

行业配置效应 (Allocation) = +2.89%
个股选择效应 (Selection) = +3.31%
交互效应 (Interaction) = -0.98%
残差(公式误差) = +0.0000%

[4] 按行业明细(贡献 Top 3)...
食品饮料 wp=40.0% wb=25.0% alloc=+0.19% selec=+0.94% inter=+0.56%
社会服务 wp=20.0% wb= 8.3% alloc=+1.06% selec=+0.00% inter=+0.00%
银行 wp=20.0% wb=25.0% alloc=+0.04% selec=+1.04% inter=-0.21%

解读:

  • 超额收益 = +5.22%,拆解为 Allocation +2.89% + Selection +3.31% + Interaction -0.98%
  • 残差 = 0.0000%(数学恒等,公式完备)
  • 配置贡献:超配了「食品饮料」(+0.19% alloc) 和「社会服务」(中国中免 +1.06% alloc)
  • 选择贡献:在「银行」行业内挑到「平安银行」(+1.04% selec)、「食品饮料」里挑到「五粮液 + 伊利」(+0.94% selec)
  • 交互为负:因为重仓食品饮料里选了 +6% 五粮液却放弃了同样正收益的伊利,导致权重协同轻微负贡献

八、常见坑

  1. 基准选错:用「全 A 股等权」做基准,跟沪深 300 完全不同。基准错了三件套结论会反转。建议用读者实际对标的指数。
  2. 行业映射漂移:申万一级每年会有微调(合并、新增)。同一年用同一套映射,跨年比较要做行业映射版本对齐。
  3. 权重用「期初」还是「平均」:本 demo 用「期初权重」(即建仓时点)。更严谨的是「日度加权平均」——但需要每日持仓快照。日常用期初足够。
  4. 交互效应别单独看:交互不是"独立效应",而是 Allocation 与 Selection 协同的副产物。解释时把它跟 Allocation/Selection 配对看。
  5. 多期归因:本 demo 是「单期归因」。多期(年/月滚动)需要把每期的 Allocation/Selection 累加,再做时间序列分析(trend、稳定性)。

九、小结

策略评估的第一道关:把收益拆成 Allocation / Selection / Interaction,回答"赛道 vs 选股"。

现象 诊断
Allocation >> 0, Selection ≈ 0 赛道压对了,但选股一般 → 行业 ETF 即可
Allocation ≈ 0, Selection >> 0 选股厉害,但没超配好赛道 → 该加择时
Interaction < 0 好赛道里选股拖后腿 → 行业内集中度不够
三者都 ≈ 0 跟基准差不多 → 考虑换基准或换策略

下一篇(#02)我们做单因子归因:5 个因子里到底谁真贡献 Alpha?——用 MPA(Multiple-Pillar Attribution)模型拆解多因子贡献。


免责声明:本文仅供技术学习交流,不构成任何投资建议。量化策略回测表现不代表未来收益,投资有风险,决策需谨慎。

代码与文档:https://github.com/MaiRuiApi

posted @ 2026-10-08 16:15  量化分析码农  阅读(10)  评论(0)    收藏  举报