12 — Attribution¶
Learning objectives¶
Reconcile a simple contribution-based P&L breakdown; then compute a real
Brinson allocation/selection/interaction decomposition and confirm the
three effects sum exactly to active return. (For the full multi-period
version and a deeper Brinson treatment, see
notebooks/integration/25_brinson_and_rebalancing.ipynb; the true
end-to-end integration exercise is the capstone,
notebooks/integration/30_capstone_portfolio_review.ipynb.)
Free learning pack¶
reference/concepts/brinson_attribution.md- CFA active portfolio management page
- PIMCO fixed-income education hub
Do not search for more material until these are insufficient.
Distinction¶
Attribution asks: what caused realized performance? — as opposed to scenario analysis (notebook 11), which asks "what would happen." Attribution looks backward at what actually happened; a teaching decomposition: carry, rates, curve, spread, equity/factor, residual/selection.
contributions = {
"carry": 210_000,
"rates": 470_000,
"curve": -80_000,
"credit_spread": 510_000,
"selection_residual": 230_000,
}
# MANUAL FIRST: reconcile total P&L (just sum the dict's values).
total_pnl = None
print(total_pnl)
# CHECK (uncomment after your attempt):
# assert total_pnl == 1_340_000
PREDICT¶
A portfolio holds the exact same assets as its benchmark, just at different weights — no stock-picking or bond-picking within any bucket, purely different sizing decisions. Before calculating: should the Brinson selection effect come out to exactly zero, roughly zero, or could it still be nonzero?
Formula (Brinson attribution)¶
allocation = (w_p - w_b) * r_b
selection = w_b * (r_p - r_b)
interaction = (w_p - w_b) * (r_p - r_b)
Identity: sum(allocation + selection + interaction) = active_return
import numpy as np
# Portfolio and benchmark hold the same 3 segments, same realized returns
# per segment (rp == rb) - only the weights differ.
wp = np.array([0.5, 0.3, 0.2])
wb = np.array([0.4, 0.4, 0.2])
rp = np.array([0.08, 0.05, 0.03])
rb = np.array([0.08, 0.05, 0.03]) # identical to rp: no selection skill by construction
# MANUAL FIRST:
allocation = None
selection = None
interaction = None
print("allocation:", allocation, " sum:", None if allocation is None else allocation.sum())
print("selection:", selection)
portfolio_return = float(wp @ rp)
benchmark_return = float(wb @ rb)
active_return = portfolio_return - benchmark_return
print("active return:", active_return)
# CHECK (uncomment after your attempt):
# assert np.allclose(selection, 0.0) and np.allclose(interaction, 0.0), (
# "with rp==rb per segment, only the weighting decision can differ - "
# "selection and interaction must be exactly zero"
# )
# assert np.isclose(allocation.sum(), active_return)
Now with real selection skill¶
Same weights, but this time the portfolio's realized returns differ from the benchmark's within each segment too.
rp_skill = np.array([0.09, 0.04, 0.035]) # portfolio beat segment 1, lagged segment 2
# MANUAL FIRST:
from pm.attribution import brinson_attribution
allocation2, selection2, interaction2 = None, None, None
active_return2 = float(wp @ rp_skill - wb @ rb)
print("allocation:", allocation2.sum() if allocation2 is not None else None)
print("selection:", selection2.sum() if selection2 is not None else None)
print("interaction:", interaction2.sum() if interaction2 is not None else None)
# CHECK (uncomment after your attempt):
# assert np.isclose(allocation2.sum() + selection2.sum() + interaction2.sum(), active_return2)
# assert not np.allclose(selection2, 0.0), "this time selection should be genuinely nonzero"
Reference¶
reference/concepts/brinson_attribution.md
Promote¶
Use src/pm/attribution.py (total_attribution, brinson_attribution)
only after your own implementation.
Test¶
pytest tests/test_attribution.py
ORAL CHECK¶
Explain to a PM why allocation and selection are genuinely different skills — a manager could get one right and the other wrong. Using your two examples above, what would you tell a PM whose portfolio shows large allocation effect and near-zero selection effect, versus the reverse?
Try /tutor Brinson attribution for an adaptive walkthrough. Continue to
docs/PROGRESS.md to log what you've completed.