做基金研究这块的,迟早会接到一个活:领导或者客户拿着一根净值曲线问,我们比基准多赚的那部分钱,到底是从哪来的?是资产配置做对了,还是个股、个券选得好?如果只是拍脑袋回答,那这活基本就干砸了。要回答这个问题,就得用上基金绩效归因模型,而在所有归因框架里,Brinson模型是业界应用最广、也最容易跟人讲明白的一个。这篇文章要解决的,就是从单期Brinson到多期Brinson的完整Python实现问题。
我之所以专门写多期,是因为绝大多数实际场景里,基金不会只拿一个区间给你看。年度、季度、月度,甚至逐日的归因结果,要跨期汇总,简单加总会对不上账,这个问题会让所有刚接触归因的人头疼。这篇文章会先把单期模型讲透,再解释为什么多期不能直接相加,然后用Carino修正法做一版可落地的多期归因Python代码。适合刚入门量化的同学,也适合已经有单期归因代码、正在为多期对不上账发愁的从业者,看完可以直接照着改自己的实现。
1. 为什么做基金绩效归因,以及为什么选Brinson
1.1 归因到底在回答什么问题
绩效归因的本质,是把组合相对基准的超额收益拆成几块,每一块对应一个业务动作。基金经理说自己跑赢基准了,这句话值不值得信,要看超额收益的构成。Brinson模型最经典的做法,是把超额收益拆成两类:大类资产配置贡献和证券选择贡献。前者解释你该不该超配股票、低配债券,后者解释你在股票内部、债券内部有没有选出更强的东西。
打个比方,一家餐厅营业额比去年涨了20%,老板想知道是因为菜品结构变了、把便宜菜换成了贵菜,还是因为厨师手艺变好了、同样价格下回头客更多。资产配置就是前者,证券选择就是后者。做投资决策也是一个道理,拿业绩基准当去年菜单,组合就是今年的新菜单,Brinson把这个账算得明明白白。
这个模型之所以被普遍采用,一是它直观,拆出来的每一部分都有业务含义,跟投委会和客户都解释得通;二是它对数据要求相对不高,只需要组合权重、基准权重、各资产收益这几个常规变量,就可以完成计算。
1.2 单期Brinson的两大经典口径BHB与BF
单看单期Brinson,实际有两种常见的拆解口径。一种是1986年Brinson、Hood和Beebower提出的BHB三因子模型,把超额收益严格拆成三块:配置效应、选择效应和交互效应;另一种是1985年Brinson和Fachler提出的BF两因子模型,把交互效应并进了选择效应,更加简洁。
两者的差异主要在选择效应的权重口径上。BHB里选择效应用的是基准权重,BF里选择效应用的是实际组合权重。我见过不少刚接触归因的人,代码里混用这两种口径,算出来的结果要么差一个交互项,要么莫名其妙多了个残差。严格来说,BHB三因子加总是恒等于总超额收益的,这是一个数学恒等式;BF两因子如果配置效应用的是基准收益差、选择效应用实际权重收益差,总和也正好等于总超额,因为交互项被吸收到选择里了。
这篇文章里的实现,我统一用BHB三因子口径,原因很简单:交互效应单独拆出来,多期Carino修正时端口更清晰,后续对接其他系统也会少很多歧义。
2. 单期Brinson模型拆解与Python实现
2.1 数学分解与实现口径选择
设组合中资产i的实际权重为(w_i),基准中资产i的权重为(b_i),组合中资产i的区间收益率为(r_i),基准中资产i的区间收益率为(rb_i)。组合总收益为:
[ R_p = \sum_i w_i r_i ]
基准总收益为:
[ R_b = \sum_i b_i rb_i ]
BHB三因子把超额收益(R_p - R_b)拆成三部分:
- 配置效应:反映组合相对基准在资产权重上的偏离所带来的贡献,公式为(\sum_i (w_i - b_i) rb_i)。
- 选择效应:反映在给定组合权重下,组合内各资产收益相对基准对应资产收益的偏离,公式为(\sum_i b_i (r_i - rb_i))。
- 交互效应:反映权重偏离和收益偏离同时发生时的协同部分,公式为(\sum_i (w_i - b_i)(r_i - rb_i))。
三个部分相加,恰好等于(R_p - R_b)。很多初学者会问,为什么选择效应不用组合权重而用基准权重?因为配置效应已经把权重偏离的贡献单独拿走了,选择效应再乘组合权重就会和配置效应重叠。BHB为了保证三项加总恒等,选择效应必须乘基准权重。这个细节是理解全篇的关键。
2.2 完整实现代码与手动验证
用Python实现单期BHB很简单,核心就是一个函数加三行计算。这里我直接用pandas和numpy写,保证后续能平滑过渡到多期版本。
import numpy as np import pandas as pd def brinson_single_period(wp: pd.Series, wb: pd.Series, rp: pd.Series, rb: pd.Series) -> pd.Series: """ 单期Brinson-BHB归因 wp: 组合资产权重 wb: 基准资产权重 rp: 组合资产收益率 rb: 基准资产收益率 """ allocation = ((wp - wb) * rb).sum() selection = (wb * (rp - rb)).sum() interaction = ((wp - wb) * (rp - rb)).sum() total_r_p = (wp * rp).sum() total_r_b = (wb * rb).sum() excess = total_r_p - total_r_b return pd.Series({ "组合收益": total_r_p, "基准收益": total_r_b, "超额收益": excess, "配置效应": allocation, "选择效应": selection, "交互效应": interaction, "三因素合计": allocation + selection + interaction, })光看函数不放心,来一组双资产业务数据验证一下。假设资产只有股票和债券两类。组合股票权重60%、债券40%,基准股票权重55%、债券45%;组合股票收益5%、债券1%,基准股票收益4%、债券1.5%。
手算一遍:组合收益(0.6\times0.05+0.4\times0.01=0.034),基准收益(0.55\times0.04+0.45\times0.015=0.02875),超额收益0.00525。配置效应((0.6-0.55)\times0.04+(0.4-0.45)\times0.015=0.00125),选择效应(0.55\times(0.05-0.04)+0.45\times(0.01-0.015)=0.00325),交互效应(0.05\times0.01+(-0.05)\times(-0.005)=0.00075)。三项相加0.00525,和超额收益完全相等。
这种能用手算验证的样例,我建议每个人写归因代码时都保留一套,后面对数据对不上账时,最快定位问题的方式就是拿这种小样例重算一遍。
2.3 交互效应为什么不能简单丢弃
实际业务中,交互效应经常被合并处理。很多系统按BF口径把交互并进选择效应,对外只说配置和选择两项。但对于内部研究,最好保留交互项,因为它确实有业务含义。比如基金经理超配了某个行业,同时这个行业里的选股又明显跑赢基准,那么超配带来的额外收益和选股带来的额外收益会发生叠加,这个叠加就是交互效应。
如果你在给客户汇报时觉得交互效应不好解释,可以把交互并入选择,改BF口径。但我强烈建议计算底层保留三因素,展示层再按需要合并。这样将来切口径、做多期、复核数据,不用重跑一遍底层。
3. 多期归因:为什么不能把单期结果直接相加
3.1 复利路径依赖带来的“残差”
很多人第一次做多期归因,会自然而然地想:我把每个季度、每个月单期Brinson的结果算出来,然后加起来,不就是全年归因吗?答案是不对。错在哪?错在复利不是加法,是乘法。
假设两个季度,第一季组合超额2%,第二季组合超额3%。全年组合收益不是(2%+3%=5%),而是((1+2%)(1+3%)-1=5.06%)。这多出来的0.06%就是所谓路径依赖交叉项。既然全年收益不是单期收益之和,那全年超额收益自然也不是单期超额收益之和,强行相加就会产生一个说不清道不明的残差。
在实际业务里,这个残差大小取决于收益波动。收益波动大的年份,残差甚至可以到几十个基点,客户对账时一眼就能看出问题。做归因系统的人必须理解,跨期加总不是简单求和,而是要先做数学变换。
3.2 常用多期方案与选择
目前业界处理多期归因的常用方案有这么几类:对数法、最优单期法、Carino链接法、Menchero优化法。
对数法思路最直接,把每期收益转成对数收益。对数收益天然可加,所以多期没有残差。缺点也很明显,对数收益不是真实组合收益,换算回收益率的展示成本高,而且杠杆类资产上对数收益的金融含义会打折扣。
最优单期法是在约束条件下找到一个和真实总超额最接近的权重组合,再基于这个组合做单期归因,数学上比较重,代码和解释成本都不低。
Menchero优化法在多家商业系统里用得多,它把不同期之间的路径依赖看成需要分配的对象,通过优化把残差分配回各期。计算稳定,但实现复杂度比Carino高。
我的选择是Carino链接法。它是现在实务中接受度较高的方案,计算量小,解释起来也不费劲:先用对数变换来解决可加性问题,再通过一个缩放因子把结果搬回算术超额口径。下面就用它实现。
4. 用Python实现Brinson多期归因
4.1 数据结构与准备
做多期归因,数据组织最好用一种标准的宽表结构:每个期间一行,每个资产一列。我需要四个表或者四个DataFrame:组合权重wp_df、基准权重wb_df、组合收益rp_df、基准收益rb_df,行索引是期间,列索引是资产。
建议一开始就把结构定义清楚,否则后面代码会越写越乱。我这里用一个四期双资产的模拟数据做演示,四期分别是季度,资产只有股票和债券两类。这个结构扩展到几十个行业、几百只股票都不会有问题,只要把列扩开就行。
4.2 四期示例与Carino修正代码
先构造数据:
periods = ["2024Q1", "2024Q2", "2024Q3", "2024Q4"] assets = ["股票", "债券"] data_wp = np.array([ [0.60, 0.40], [0.65, 0.35], [0.55, 0.45], [0.50, 0.50], ]) data_wb = np.array([ [0.55, 0.45], [0.60, 0.40], [0.50, 0.50], [0.45, 0.55], ]) data_rp = np.array([ [0.05, 0.01], [0.03, 0.02], [-0.02, 0.01], [0.04, 0.018], ]) data_rb = np.array([ [0.04, 0.015], [0.02, 0.025], [0.00, 0.01], [0.05, 0.015], ]) wp_df = pd.DataFrame(data_wp, index=periods, columns=assets) wb_df = pd.DataFrame(data_wb, index=periods, columns=assets) rp_df = pd.DataFrame(data_rp, index=periods, columns=assets) rb_df = pd.DataFrame(data_rb, index=periods, columns=assets)这份数据的业务含义比较典型:组合在股票上一直比基准高配,但后面两季股票收益转弱,组合吃了一些亏。接下来先跑一遍单期归因,再看多期结果。
def multi_period_brinson(wp_df: pd.DataFrame, wb_df: pd.DataFrame, rp_df: pd.DataFrame, rb_df: pd.DataFrame) -> pd.DataFrame: rows = [] for idx in wp_df.index: res = brinson_single_period( wp_df.loc[idx], wb_df.loc[idx], rp_df.loc[idx], rb_df.loc[idx] ) res.name = idx rows.append(res) single_df = pd.DataFrame(rows) # 全期真实组合收益与基准收益 r_p_total = np.prod(1 + (wp_df * rp_df).sum(axis=1).values) - 1 r_b_total = np.prod(1 + (wb_df * rb_df).sum(axis=1).values) - 1 # 每期对数链接系数 r_p_period = (wp_df * rp_df).sum(axis=1) r_b_period = (wb_df * rb_df).sum(axis=1) k_t = np.where( np.abs(r_p_period - r_b_period) < 1e-12, 1.0 / (1.0 + r_p_period), (np.log1p(r_p_period) - np.log1p(r_b_period)) / (r_p_period - r_b_period) ) if np.abs(r_p_total - r_b_total) < 1e-12: K = 1.0 + r_p_total else: K = (r_p_total - r_b_total) / (np.log1p(r_p_total) - np.log1p(r_b_total)) adjusted_df = single_df[["配置效应", "选择效应", "交互效应"]].mul(k_t, axis=0) * K adjusted_df["三因素合计"] = adjusted_df.sum(axis=1) return single_df, adjusted_df, r_p_total, r_b_total代码里最重要的部分是两个系数。(k_t)负责把单期算术超额变成该期在对数空间的贡献,(K)负责把整个对数贡献总量搬回真实的算术超额口径。两者一乘,每期调整后的三因素加总自然等于全期组合收益减全期基准收益,不会再出现残差。
4.3 结果解读与口径提醒
跑出来的结果,单期简单加总会得到总超额约-0.35%,但直接用全期真实收益计算,总超额其实是-0.40%左右。多出来的约0.05个百分点的差异,就是复利路径依赖导致的交叉项。Carino修正之后,各期三因素加总应该精确等于全期真实总超额,这才是能拿去跟客户对账的数字。
这里要特别提醒一句,Carino修正不是要把每期归因结果放大或缩小到失真,它只是在保持每期相对贡献结构不变的前提下,把单期算术口径换算成全期算术口径。修正前后的配置、选择、交互方向不会逆转,但数值会略有变化,这是正常现象。
另外要注意,这个修正系数对配置、选择、交互三个维度是统一作用的。所以不需要为每个维度分别算一遍Carino,只需要对每期总超额算一个公共系数,再统一乘到三个维度上就行。很多人误以为要分维度算,那是把问题复杂化了。
5. 实操中的常见问题与排查技巧
5.1 数据对齐与权重口径
实际项目里最容易出问题的地方,第一是权重口径不统一。组合权重到底是期初权重、期末权重还是期间平均权重?基准权重呢?如果组合用期初权重、基准用平均权重,算出来的配置效应就会扭曲。建议全部统一成同一时点或同一区间的估值权重,最常用的是期初权重或日频平均暴露。
第二是收益率的对齐问题。资产收益率应当是区间内简单收益率,不是年化收益率,也不是对数收益率。拿年化收益去算季度归因,结果完全不能看。
第三是现金和未投资资产的处理。组合里往往有现金,基准里通常没有对应资产权重,如果不把现金单独列为一种资产,组合权重加起来小于1,归因结果会出现残差。我的做法是把现金也当作资产列,收益率为0或货基收益,放进归因框架统一计算。
5.2 常见问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 单期归因三因素合计不等于超额收益 | 选择了基准权重和实际权重混用,或某项公式写错 | 回到手算样例逐项核对公式,统一口径 |
| 多期简单加总后和真实超额差一截 | 没做Carino或对数修正 | 改用Carino链接法,或改用对数法 |
| 组合收益和基准收益都算对了,但归因结果总和偏差很大 | 权重没有归一化,现金资产漏掉 | 把现金单列为资产,确保权重总和为1 |
| 某个资产收益超大,多期残差明显变大 | 复利交叉项放大 | 提高归因频率,或做几何归因 |
| 调整后贡献在某些期为负很大 | 可能是单期选择效应本身就为负,也可能权重数据滞后 | 检查权重是否用了未来信息或错期数据 |
表格里的现象基本都是我实际踩过的。特别是现金漏算这条,很多团队第一版归因系统都会犯,问题的表现是数据怎么都对不上,最后发现组合总权重只有99.3%,那0.7%就是现金。
5.3 我在实际项目里踩过的坑
最后分享几条偏经验向的操作习惯。第一,归因代码一定要配一套手算样例做回归测试。模型本身的逻辑不复杂,但数据对齐、口径调整、多期修正都是容易改一个地方废掉全局的操作,没有回归测试兜底,改完只能心里发虚。
第二,对外汇报时,不要把季度归因直接用Carino修正结果替代单期结果。单期数据有单期的业务解释意义,修正后的数据用于全年累计和总表,如果混着用,客户会疑惑为什么一季度单独看是0.5%,到全年汇总里却变成0.48%。最稳的做法是:展示表用真实单期数,累计表用Carino修正数,或者所有跨期加总都走修正。
第三,多期归因的频率选择上,日频数据量大的时候计算压力不小,但建议至少保留月度频率。季度归因太粗,一旦某季度内权重大幅漂移,单期内部就会先失真,多期修正也只能修正期与期之间的路径依赖,修不了期内的漂移误差。
Brinson多期归因这件事,看起来是个数学公式加一段Python代码,真正难的是把口径和数据理清楚。Carino修正也不是什么高深算法,但它解决了复利和加法之间的矛盾,让我们能把单期归因的结果,在业务上可信地汇总成全期结论。希望这篇实战记录能帮你在做基金绩效归因时少走几趟弯路。