# Performance Attribution

> Performance attribution analysis — Brinson sector/stock-selection attribution, factor alpha/beta decomposition, market-timing evaluation, and benchmark comparison framework.

- Skill: `gabrielmoreira/performance-attribution` (Agent Skill)
- Install (CLI): `npx skillmds@latest add gabrielmoreira/performance-attribution`
- Raw SKILL.md: https://api.skillmd.com/api/skills/gabrielmoreira/performance-attribution/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: gabrielmoreira (https://skillmd.com/u/gabrielmoreira)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/gabrielmoreira/performance-attribution

---


# Performance Attribution Analysis

## Overview

Decompose portfolio excess returns into explainable sources: sector allocation, stock selection, factor exposure, timing contribution, and more. This helps explain **why** a strategy made or lost money, rather than only **how much** it made or lost.

## Brinson Attribution Model

**Do not retype these formulas into throwaway Python.** They are implemented and
tested in `src/quantlib/attribution.py`; import them.

### Single-Period Brinson-Fachler Model

```
Let w_p,i = portfolio weight of sector i
    w_b,i = benchmark weight of sector i
    r_p,i = portfolio return of sector i
    r_b,i = benchmark return of sector i
    R_b   = total benchmark return

Allocation_i  = (w_p,i - w_b,i) × (r_b,i - R_b)
Selection_i   =  w_b,i          × (r_p,i - r_b,i)
Interaction_i = (w_p,i - w_b,i) × (r_p,i - r_b,i)

Total active return = Σ(Allocation_i) + Σ(Selection_i) + Σ(Interaction_i)
```

**The decomposition itself has no residual term.** The three effects sum to
`R_p - R_b` identically, for any sector returns whatsoever, provided the
portfolio and benchmark weights carry the same total. `brinson_fachler` enforces
the weight-sum precondition and raises rather than returning a decomposition
that does not tie out.

A residual is therefore never a property of the algebra — but it is a real and
expected property of a *reported* attribution, because the inputs are a
snapshot. Intra-period trading, cash drag, corporate actions and FX translation
all move the actual portfolio return away from the one these weights and sector
returns imply. So:

- residual inside the decomposition, given the inputs → **impossible**; if you
  see one, the arithmetic or the weight convention is wrong;
- residual between the decomposition and the reported fund return → **normal**;
  quantify it and attribute it to its source rather than absorbing it silently
  into selection. This is what the `/attrib` reconciliation gate asks for.

```python
from src.quantlib.attribution import brinson_fachler

result = brinson_fachler(
    portfolio_weights={"Tech": 0.40, "Financials": 0.10, "Energy": 0.30, "Health": 0.20},
    benchmark_weights={"Tech": 0.25, "Financials": 0.30, "Energy": 0.25, "Health": 0.20},
    portfolio_returns={"Tech": 0.12, "Financials": 0.04, "Energy": -0.02, "Health": 0.07},
    benchmark_returns={"Tech": 0.10, "Financials": 0.05, "Energy": -0.01, "Health": 0.06},
)

result.portfolio_return   # 0.0600
result.benchmark_return   # 0.0495
result.active_return      # 0.0105
result.allocation         # 0.0045
result.selection          # 0.0015
result.interaction        # 0.0045
# 0.0045 + 0.0015 + 0.0045 == 0.0105 exactly (residual ~3e-18, machine epsilon)

for effect in result.sectors:
    print(effect.sector, effect.allocation, effect.selection, effect.interaction, effect.total)
```

A sector return may be omitted only where the matching weight is zero. A
benchmark sector you did not own therefore shows zero selection and zero
interaction, and the whole effect lands in allocation — you cannot demonstrate
stock-picking skill in something you never held.

### Example Brinson Attribution

Rendered from the call above, so every figure below is reproducible:

```markdown
### Brinson Sector Attribution

| Sector | Portfolio Weight | Benchmark Weight | Portfolio Return | Benchmark Return | Allocation | Selection | Interaction |
|------|---------|---------|---------|---------|---------|---------|---------|
| Tech | 40% | 25% | 12% | 10% | +0.7575% | +0.50% | +0.30% |
| Financials | 10% | 30% | 4% | 5% | -0.0100% | -0.30% | +0.20% |
| Energy | 30% | 25% | -2% | -1% | -0.2975% | -0.25% | -0.05% |
| Health | 20% | 20% | 7% | 6% | +0.0000% | +0.20% | +0.00% |
| **Total** | 100% | 100% | 6.00% | 4.95% | **+0.45%** | **+0.15%** | **+0.45%** |

Active return 1.05% = allocation 0.45% + selection 0.15% + interaction 0.45%. No residual.
```

### Multi-Period Attribution (Linked Brinson)

Single-period effects add, but returns compound, so simply summing each period's
effects does **not** reproduce the compounded active return. Take the four-sector
period above and two more like it (the exact three are the `_three_periods`
fixture in `tests/quantlib/test_attribution.py`, so you can run them): summing the
three active returns gives 2.8500%, while the compounded active return is 3.0318%
— an 18.2bp error that grows with the horizon and the return level.

Use **Carino logarithmic linking**, implemented as `carino_link`. It is
residual-free, and its per-period scaling factor depends only on that period's
total portfolio and benchmark return — never on the effects being linked — so
linking is deterministic and cannot be steered by how sectors were bucketed.
(Menchero linking is also residual-free but distributes a correction term derived
from the effects themselves; Carino needs less machinery for the same guarantee.)

```
k   = (ln(1 + R_P) - ln(1 + R_B)) / (R_P - R_B)        # over the whole horizon
k_t = (ln(1 + R_p,t) - ln(1 + R_b,t)) / (R_p,t - R_b,t)  # for period t

linked effect = Σ_t (k_t / k) × effect_{i,t}
```

```python
from src.quantlib.attribution import brinson_fachler, carino_link

periods = [brinson_fachler(**month) for month in monthly_inputs]
linked = carino_link(periods)

linked.active_return   # compounded, not summed
linked.allocation, linked.selection, linked.interaction
linked.scaling_factors  # one k_t / k per period, exposed so a report can be audited

for sector in linked.sectors:
    print(sector.sector, sector.total)
# allocation + selection + interaction == linked.active_return exactly
```

Arithmetic linking is acceptable **only** when you explicitly report the residual.
Since `carino_link` costs one function call and leaves none, prefer it.

## Factor Attribution

### Alpha-Beta Decomposition

```
R_p = α + β × R_m + ε

α (alpha): excess return, manager skill
β (beta): market exposure, systematic risk
ε (epsilon): residual, idiosyncratic risk

Regression method: OLS regression, with at least 60 data points
```

#### Multi-Factor Attribution (Fama-French Extension)

```
R_p - R_f = α + β_mkt × (R_m - R_f) + β_smb × SMB + β_hml × HML + β_mom × MOM + ε

| Factor | Meaning | China A-share Proxy |
|------|------|--------|
| MKT | Market | CSI 300 return |
| SMB | Small-cap premium | CSI 500 - CSI 300 |
| HML | Value premium | high-PB group - low-PB group |
| MOM | Momentum | top past-12M winners - bottom group |
```

#### Factor Exposure Analysis Template

```markdown
### Factor Exposure Analysis

| Factor | Beta | t-stat | Significance | Interpretation |
|------|------|---------|--------|------|
| Market (MKT) | 0.85 | 12.3 | *** | Below 1, defensive profile |
| Small-cap (SMB) | 0.25 | 3.2 | ** | Small-cap tilt |
| Value (HML) | -0.15 | -1.8 | * | Growth tilt |
| Momentum (MOM) | 0.30 | 4.1 | *** | Significant momentum exposure |
| **Alpha** | **0.8% / month** | **2.5** | ** | **Significant alpha** |

R² = 0.72 → factors explain 72% of return variation
Alpha = 0.8% / month = 10% / year, significant
```

## Market-Timing Evaluation

### Treynor-Mazuy Model

```
R_p - R_f = α + β × (R_m - R_f) + γ × (R_m - R_f)² + ε

γ > 0 and significant → timing ability exists (adds risk in bull markets, cuts risk in bear markets)
γ ≤ 0 → no timing ability
```

### Henriksson-Merton Model

```
R_p - R_f = α + β × (R_m - R_f) + γ × max(R_m - R_f, 0) + ε

γ > 0 → portfolio beta is higher in bull markets (successful timing)
```

### Practical Timing Metrics

| Metric | Calculation | Meaning |
|------|------|------|
| Bull capture ratio | portfolio return in bull markets / benchmark return | >100% = outperforming |
| Bear capture ratio | portfolio return in bear markets / benchmark return | <100% = better downside defense |
| Timing hit rate | proportion of months where market direction was called correctly | >55% = shows skill |
| Correlation between position changes and market | `corr(position_change, future_return)` | >0 = timing is correct |

## Benchmark Comparison Framework

### Benchmark Selection

| Strategy Type | Recommended Benchmark | China A-share Code |
|---------|---------|---------|
| China A-share large cap | CSI 300 | 000300.SH |
| China A-share small cap | CSI 500 / CSI 1000 | 000905.SH |
| China A-share broad market | CSI All Share | 000985.SH |
| Hong Kong equities | Hang Seng Index | HSI |
| US equities | S&P 500 | SPX |
| Crypto | BTC | BTC-USDT |
| Multi-asset | 60/40 portfolio | self-constructed |

### Risk-Adjusted Performance Metrics

| Metric | Formula | Excellent | Good | Average |
|------|------|------|------|------|
| Sharpe | `(R_p - R_f) / σ_p` | >1.5 | 1.0-1.5 | 0.5-1.0 |
| Sortino | `(R_p - R_f) / σ_down` | >2.0 | 1.5-2.0 | 1.0-1.5 |
| Calmar | `R_p / MaxDD` | >1.0 | 0.5-1.0 | 0.2-0.5 |
| Information Ratio | `(R_p - R_b) / TE` | >1.0 | 0.5-1.0 | 0.2-0.5 |
| Treynor | `(R_p - R_f) / β` | used comparatively | | |

### Rolling Analysis

```
Use rolling windows (such as 12 months) to analyze:
- Rolling Sharpe: strategy stability
- Rolling alpha: whether alpha persists
- Rolling beta: whether market exposure is stable
- Rolling information ratio: persistence of benchmark outperformance

Suggested windows: 252 days for daily data, 12-36 months for monthly data
```

## Analysis Framework

### Step 1: Aggregate Analysis

```
1. Cumulative return vs benchmark
2. Excess-return decomposition (annual / monthly)
3. Summary risk metrics (volatility / max drawdown / Sharpe)
```

### Step 2: Attribution Decomposition

```
1. Brinson attribution (if sector information is available)
2. Factor attribution (alpha / beta / factor exposure)
3. Timing attribution (TM / HM models)
```

### Step 3: Style Analysis

```
1. Large cap vs small cap exposure
2. Growth vs value exposure
3. Style drift detection (rolling style analysis)
```

### Step 4: Conclusions and Recommendations

```
1. Main sources of excess return
2. Whether risk exposure is reasonable
3. Suggested improvement directions
```

## Output Format

```markdown
## Performance Attribution Report

### Performance Overview
| Metric | Strategy | Benchmark | Excess |
|------|------|------|------|
| Cumulative return | +85.2% | +32.1% | +53.1% |
| Annualized return | 12.5% | 5.8% | +6.7% |
| Annualized volatility | 18.2% | 20.5% | - |
| Sharpe | 0.69 | 0.28 | - |
| Information Ratio | 0.82 | - | - |

### Attribution Breakdown
| Source | Contribution (annualized) | Share |
|------|-----------|------|
| Sector allocation | +2.1% | 31% |
| Stock selection | +3.8% | 57% |
| Timing | +0.8% | 12% |

### Factor Exposure
[factor exposure table]

### Conclusion
Excess return mainly comes from stock selection (57% contribution), followed by sector allocation.
Alpha is significant (`t=2.5`), indicating real stock-picking ability.
Watch the risk of excessive small-cap exposure (`SMB beta=0.25`).
```

## Notes

1. **Attribution ≠ prediction**: attribution explains the past; it does not guarantee persistence in the future
2. **Benchmark selection affects attribution**: switch the benchmark and alpha may disappear, so benchmark choice must be appropriate
3. **Data frequency**: daily attribution is noisy, monthly attribution is more stable but has fewer samples; recommended workflow is daily computation with monthly reporting
4. **Survivorship bias**: delisted stocks may be excluded in backtests, creating false alpha
5. **Multiple-testing problem**: if you test 100 strategies, about 5 may appear significant by chance (`p=0.05`); use multiple-comparison correction
6. **Factor data requirement**: factor attribution requires factor return data, which can be obtained from `tushare` or self-constructed
7. **Attribution in backtest reports**: `metrics.csv` already provides basic metrics after a backtest; this skill adds deeper attribution analysis
8. **Brinson is implemented, not improvised**: `src/quantlib/attribution.py` holds the tested single-period and Carino-linked decomposition. Import it. Hand-written attribution code that reports a single-period residual is a bug in that code, not a property of the model

