Attribution analysis
@qtrader/attribution-analysis
Explain why a portfolio beat or lagged its benchmark. Brinson-Fachler allocation, selection, and interaction effects by sector and period, Carino-linked across periods so they add up exactly, plus a plain-English summary. Works offline from a CSV. Not financial advice.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1# attribution-analysis
- 3Find out why your portfolio beat or lagged its benchmark. This kit splits the gap into sector bets (allocation), stock picks (selection), and their overlap (interaction), links several periods so the numbers add up exactly, and writes a short plain-English summary.
- 5**Who it is for:** anyone who runs a portfolio against a benchmark and has to explain the result at month or quarter end: individual investors, small fund teams, investment clubs, students.
- 7Not financial advice. Attribution explains past results only.
- 9## Example
- 11Input `examples/holdings.csv` (two periods, three sectors, made-up tickers):
- 13```csv
- 14period,sector,security,port_weight,port_return,bench_weight,bench_return
- 15P1,Tech,AAA,0.30,0.12,0,
- 16P1,Tech,BBB,0.20,0.07,0,
- 17P1,Tech,Tech index,0,,0.40,0.08
- 18...
- 19```
- 21Output (`out/attribution.md`, trimmed):
- 23> The portfolio returned +6.22% against +5.53% for the benchmark, so it beat by 70 bps. The biggest driver was the overlap of the two (interaction) at +50 bps, then sector bets (allocation) at +39 bps and stock picking (selection) at -19 bps. Tech helped most (+88 bps), mostly from the holdings picked inside it. Energy hurt most (-46 bps), mostly from the holdings picked inside it.
- 25| Sector | Allocation | Selection | Interaction | Total |
- 26| --- | ---: | ---: | ---: | ---: |
- 27| Tech | +0.3013% | +0.3820% | +0.2009% | +0.8843% |
- 28| Health | +0.1581% | +0.0148% | +0.1054% | +0.2783% |
- 29| Energy | -0.0725% | -0.5879% | +0.1960% | -0.4644% |
- 30| **Total** | +0.3870% | -0.1910% | +0.5022% | +0.6982% |
- 32## Hand check (verify it yourself)
- 34Formulas (Brinson-Fachler), with `Rb` the total benchmark return:
- 36- Allocation = (wp - wb) x (rb - Rb)
- 37- Selection = wb x (rp - rb)
- 38- Interaction = (wp - wb) x (rp - rb)
- 40**Period P1.** Sector returns: Tech portfolio = (0.30 x 0.12 + 0.20 x 0.07) / 0.50 = 0.10.
- 42| Sector | wp | rp | wb | rb |
- 43| --- | --- | --- | --- | --- |
- 44| Tech | 0.50 | 0.10 | 0.40 | 0.08 |
- 45| Health | 0.30 | 0.04 | 0.30 | 0.05 |
- 46| Energy | 0.20 | -0.02 | 0.30 | 0.01 |
- 48Rp = 0.5(0.10) + 0.3(0.04) + 0.2(-0.02) = 0.058. Rb = 0.4(0.08) + 0.3(0.05) + 0.3(0.01) = 0.050. Active = 0.008.
- 50| Sector | Allocation | Selection | Interaction |
- 51| --- | --- | --- | --- |
- 52| Tech | 0.10 x (0.08 - 0.05) = 0.003 | 0.40 x 0.02 = 0.008 | 0.10 x 0.02 = 0.002 |
- 53| Health | 0 x ... = 0 | 0.30 x (-0.01) = -0.003 | 0 |
- 54| Energy | -0.10 x (0.01 - 0.05) = 0.004 | 0.30 x (-0.03) = -0.009 | -0.10 x (-0.03) = 0.003 |
- 55| Total | 0.007 | -0.004 | 0.005 |
- 570.007 - 0.004 + 0.005 = 0.008, the active return.
- 59**Period P2.** Tech portfolio = (0.20 x -0.04 + 0.20 x -0.06) / 0.40 = -0.05.
- 61| Sector | wp | rp | wb | rb |
- 62| --- | --- | --- | --- | --- |
- 63| Tech | 0.40 | -0.05 | 0.40 | -0.04 |
- 64| Health | 0.40 | 0.03 | 0.30 | 0.02 |
- 65| Energy | 0.20 | 0.06 | 0.30 | 0.05 |
- 67Rp = 0.004, Rb = 0.005, active = -0.001. Allocation: 0, 0.1 x 0.015 = 0.0015, -0.1 x 0.045 = -0.0045, total -0.003. Selection: -0.004, 0.003, 0.003, total 0.002. Interaction: 0, 0.001, -0.001, total 0. Sum = -0.001.
- 69**Linking (Carino).** Cumulative Rp = 1.058 x 1.004 - 1 = 0.062232. Cumulative Rb = 1.05 x 1.005 - 1 = 0.05525. Active = 0.006982. Simply adding the periods gives 0.008 - 0.001 = 0.007, which is off by 0.0018%, so we link:
- 71- k_t = (ln(1 + Rp_t) - ln(1 + Rb_t)) / (Rp_t - Rb_t): k1 = 0.948771, k2 = 0.995520
- 72- K = (ln 1.062232 - ln 1.05525) / 0.006982 = 0.944521
- 73- Each period's effects are multiplied by k_t / K: 1.004499 for P1 and 1.053994 for P2.
- 75Linked allocation = 0.007 x 1.004499 + (-0.003) x 1.053994 = 0.003870. Linked selection = -0.004 x 1.004499 + 0.002 x 1.053994 = -0.001910. Linked interaction = 0.005 x 1.004499 + 0 = 0.005022. Sum = 0.006982, exactly the cumulative active return.
- 77`node scripts/selftest.mjs` checks every number above to 12 decimal places, plus edge cases (a sector the benchmark does not hold, weights that do not sum to 1, percent strings).
- 79## What it can access
- 81- Reads your CSV from `data/`. Writes `out/attribution.json`, `out/attribution.md`, and the review in `out/`.
- 82- Runs only its own two scripts with Node. No network, no accounts, no API keys.
- 84## Setup
- 86Node 22 or newer. No packages to install. Put your CSV in `data/holdings.csv` and ask Claude to "run attribution against the benchmark".
