
做基金研究这块的迟早会接到一个活领导或者客户拿着一根净值曲线问我们比基准多赚的那部分钱到底是从哪来的是资产配置做对了还是个股、个券选得好如果只是拍脑袋回答那这活基本就干砸了。要回答这个问题就得用上基金绩效归因模型而在所有归因框架里Brinson模型是业界应用最广、也最容易跟人讲明白的一个。这篇文章要解决的就是从单期Brinson到多期Brinson的完整Python实现问题。我之所以专门写多期是因为绝大多数实际场景里基金不会只拿一个区间给你看。年度、季度、月度甚至逐日的归因结果要跨期汇总简单加总会对不上账这个问题会让所有刚接触归因的人头疼。这篇文章会先把单期模型讲透再解释为什么多期不能直接相加然后用Carino修正法做一版可落地的多期归因Python代码。适合刚入门量化的同学也适合已经有单期归因代码、正在为多期对不上账发愁的从业者看完可以直接照着改自己的实现。1. 为什么做基金绩效归因以及为什么选Brinson1.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.050.4\times0.010.034)基准收益(0.55\times0.040.45\times0.0150.02875)超额收益0.00525。配置效应((0.6-0.55)\times0.04(0.4-0.45)\times0.0150.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%)而是((12%)(13%)-15.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, indexperiods, columnsassets) wb_df pd.DataFrame(data_wb, indexperiods, columnsassets) rp_df pd.DataFrame(data_rp, indexperiods, columnsassets) rb_df pd.DataFrame(data_rb, indexperiods, columnsassets)这份数据的业务含义比较典型组合在股票上一直比基准高配但后面两季股票收益转弱组合吃了一些亏。接下来先跑一遍单期归因再看多期结果。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(axis1).values) - 1 r_b_total np.prod(1 (wb_df * rb_df).sum(axis1).values) - 1 # 每期对数链接系数 r_p_period (wp_df * rp_df).sum(axis1) r_b_period (wb_df * rb_df).sum(axis1) 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, axis0) * K adjusted_df[三因素合计] adjusted_df.sum(axis1) 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修正也不是什么高深算法但它解决了复利和加法之间的矛盾让我们能把单期归因的结果在业务上可信地汇总成全期结论。希望这篇实战记录能帮你在做基金绩效归因时少走几趟弯路。