欢迎光临
我们一直在努力

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

系列定位:策略跑完回测 = “赚钱了”?其实远远不够。本系列用 6 篇系统化方法论 + Python 实战,把"黑盒跑数"升级为"白盒拆解"。
本篇是该系列 #01:把策略收益拆成行业配置 / 个股选择 / 交互效应 三件套。

一、策略赚钱 ≠ 你厉害

读者常见误区:策略回测 +20%,就觉得自己"会做量化"了。
但请回答三个灵魂拷问:

  • 这 +20% 是大盘涨带飞的,还是选股赚的?
  • 我是压对了赛道(食品饮料、新能源),还是在赛道里挑对了股(茅台 vs 五粮液)?
  • 如果明年市场风格切换,这个策略还能不能跑赢?
  • 不回答这三个问题,策略就是"黑盒跑数"——赚了不知道为啥赚,亏了不知道为什么亏。Brinson(1986)三件套就是把这两个问题量化的经典框架。

    二、本文你将得到什么

  • 行业配置效应(Allocation):组合的行业权重 vs 基准,差出来的收益
  • 个股选择效应(Selection):在同行业内挑股的能力
  • 交互效应(Interaction):权重 × 选股 的协同
  • 完整 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% 五粮液却放弃了同样正收益的伊利,导致权重协同轻微负贡献

    八、常见坑

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

    赞(0)
    未经允许不得转载:171主机测评 » 【Python量化策略评估体系 #01】策略赚钱了,是谁的功劳?Brinson 三件套拆解实战
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址