绩效归因:把收益拆成「为什么赚」
直觉
一个组合跑赢基准 5%,到底是赌对了板块(科技超配),还是选对了个股(板块内挑得准)?绩效归因就是把超额收益拆成可解释的来源。最常用两种:Brinson 归因按板块把超额拆成「配置效应 + 选股效应」;因子归因把收益回归到风险因子(如 Fama-French),剥离出「扣除因子暴露后的真实 Alpha」。
Brinson-Fachler 分解
设板块 在基准/组合里的权重 ,板块收益 ,基准总收益 :
- 配置效应:超配/低配了涨得比平均好/差的板块吗?
- 选股效应:在同一板块里,组合选的股票比基准好吗?
「人话」解释:归因到底在问什么?
它把「我赚了多少」翻译成「凭什么赚的」。配置效应问「我是不是把筹码押在了对的赛道」,选股效应问「在同一赛道里,我挑的选手是不是比平均强」。两者一拆,你才知道自己是真有选股能力,还是只是碰巧坐上了某个板块的顺风车——后者换个周期可能就没了。因子归因更进一步:把收益里能被「市场、价值、动量」等系统性因子解释的部分扣除,剩下的才是「纯 Alpha」。
可运行案例①:Brinson 板块归因
基准 = 50 只股票等权;组合 = 按 quality 倾斜(高质量股权重更高)。把平均月度超额收益拆成「配置 + 选股」。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
prices = pd.read_csv('/data/sp500_universe_daily.csv', parse_dates=['date']).set_index('date')
meta = pd.read_csv('/data/sp500_metadata.csv').set_index('ticker')
tickers = list(prices.columns); sec = meta.loc[tickers, 'sector']; quality = meta.loc[tickers, 'quality']
monthly = prices.resample('ME').last(); mret = monthly.pct_change().dropna(how='all')
bw = pd.Series(1/len(tickers), index=tickers) # 基准等权
qpos = quality - quality.min() + 0.5; pw = qpos / qpos.sum() # 组合: 质量倾斜
alloc = pd.Series(0.0, index=sec.unique()); sel = pd.Series(0.0, index=sec.unique())
Rb = Rp = 0.0; n = 0
for t in mret.index:
rt = mret.loc[t]; bench_total = (bw*rt).sum(); port_total = (pw*rt).sum()
Rb += bench_total; Rp += port_total; n += 1
for s in alloc.index:
m = sec == s; Bs = bw[m].sum(); Ws = pw[m].sum()
Rsb = rt[m].mean(); Rsp = (pw[m]*rt[m]).sum() / Ws
alloc[s] += (Ws - Bs)*(Rsb - bench_total) # 配置效应
sel[s] += Ws*(Rsp - Rsb) # 选股效应
alloc /= n; sel /= n; active = Rp/n - Rb/n
print(f"平均月度主动收益 = {active:+.4%}")
print(f"分解合计(配置+选择) = {(alloc.sum()+sel.sum()):+.4%} ← 应与主动收益吻合")
print(pd.DataFrame({'配置': alloc, '选股': sel}).sort_values('配置').round(4))
df = pd.DataFrame({'配置': alloc, '选股': sel}).sort_values('配置')
plt.figure(figsize=(9, 3.6))
plt.bar(range(len(df)), df['配置'], label='配置效应', color='steelblue')
plt.bar(range(len(df)), df['选股'], bottom=df['配置'], label='选股效应', color='indianred')
plt.xticks(range(len(df)), df.index, rotation=30, fontsize=8)
plt.axhline(0, color='k', lw=0.6); plt.title('Brinson 板块归因(月均贡献)'); plt.legend(fontsize=9)
plt.tight_layout(); plt.show()
可运行案例②:Fama-French 因子归因
把组合月收益回归到五因子,剥离因子暴露,剩下的截距就是「扣除系统性风险后的 Alpha」。
import numpy as np
import pandas as pd
prices = pd.read_csv('/data/sp500_universe_daily.csv', parse_dates=['date']).set_index('date')
meta = pd.read_csv('/data/sp500_metadata.csv').set_index('ticker').loc[prices.columns]
ff = pd.read_csv('/data/ff_factors_monthly.csv', parse_dates=['date']).set_index('date')
monthly = prices.resample('ME').last(); mret = monthly.pct_change()
qpos = meta['quality'] - meta['quality'].min() + 0.5
port = mret.dot(qpos / qpos.sum()).rename('p') # 质量倾斜组合月收益
d = port.to_frame().join(ff, how='inner').dropna()
y = d['p'].values - d['RF'].values
X = np.column_stack([np.ones(len(d)), d[['MKT','SMB','HML','RMW','CMA']].values])
coef, *_ = np.linalg.lstsq(X, y, rcond=None)
resid = y - X @ coef
r2 = 1 - (resid**2).sum() / ((y - y.mean())**2).sum()
print(f"Alpha(月)={coef[0]*100:+.3f}% 年化={coef[0]*12*100:+.2f}%")
print("Beta:", {k: round(float(v), 2) for k, v in zip(['MKT','SMB','HML','RMW','CMA'], coef[1:])})
print(f"R^2={r2:.2f} ← 越接近1, 收益越能被五因子解释, 纯Alpha越少")
小结
- Brinson 归因:把超额收益拆成配置效应(押对板块)+ 选股效应(板块内挑准);
- 因子归因:回归到 FF 等因子,剥离系统性风险暴露,剩下的截距是纯 Alpha;
- 归因回答的不是「赚了多少」,而是「凭什么赚」——这是区分能力与运气的基础。