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---
- 2name: attribution-analysis
- 3description: Performance attribution for a portfolio against a benchmark. Computes Brinson-Fachler allocation, selection, and interaction effects by sector for each period, links them across periods with the Carino method so they sum exactly to the cumulative active return, and writes a plain-English summary of what drove over or under performance. Use when the user asks "why did we beat (or lag) the benchmark", "run attribution", "Brinson", "allocation vs selection", "which sectors helped or hurt", or wants a monthly or quarterly performance review. Not financial advice.
- 4license: MIT
- 5---
- 7# Performance attribution (Brinson-Fachler, Carino-linked)
- 9Splits the gap between a portfolio and its benchmark into three parts per sector:
- 11- **Allocation**: did we overweight sectors that beat the benchmark? `(wp - wb) * (rb - Rb)`
- 12- **Selection**: inside each sector, did our holdings beat the sector's benchmark? `wb * (rp - rb)`
- 13- **Interaction**: the overlap, being overweight where we also picked well. `(wp - wb) * (rp - rb)`
- 15Per period, the three sum exactly to `Rp - Rb`. Across periods, simple adding does not work because returns compound, so the script uses **Carino log-linking**: each period's effects are scaled by `k_t / K`, where `k_t = (ln(1+Rp_t) - ln(1+Rb_t)) / (Rp_t - Rb_t)` and `K` is the same for the cumulative returns. The linked effects sum exactly to the cumulative active return.
- 17This explains the past. It is not a forecast and not financial advice. Say so in the report.
- 19## Inputs
- 21One CSV, usually `data/holdings.csv`, one row per security (or per sector) per period:
- 23```csv
- 24period,sector,security,port_weight,port_return,bench_weight,bench_return
- 252026-09,Tech,AAA,0.30,0.12,0.25,0.12
- 262026-09,Tech,Tech index,0,,0.15,0.08
- 27```
- 29- Weights are beginning-of-period weights as decimals. Returns are the period's total return as decimals (`0.05` is 5%). `5%` strings also work.
- 30- A return can be blank when its weight is 0. A security held by both sides can carry both weights on one row.
- 31- In each period, portfolio weights must sum to 1 and benchmark weights must sum to 1 (within 0.001, then rescaled with a warning). Cash counts: add a `Cash` sector row.
- 32- Periods are processed in file order, so sort them oldest first.
- 34If the user has data in another shape (a broker export, a sector table), reshape it to this CSV and tell them what you mapped. Ask for anything missing: the benchmark name, its sector weights and returns, and the period dates.
- 36## Steps
- 381. **Check the input.** Confirm the period order, that each period has both portfolio and benchmark rows, and the sector names match between the two sides (for example "Info Tech" vs "Technology"). Fix name mismatches with the user before running.
- 392. **Run it.**
- 41 ```bash
- 42 node scripts/attribution.mjs data/holdings.csv --out out/attribution
- 43 ```
- 45 This writes `out/attribution.json` (all numbers) and `out/attribution.md` (tables plus summary). It exits non-zero with a clear message on bad input. Read the message, fix the data, run again.
- 463. **Sanity check the output.** The linked total must equal the cumulative active return (the script warns if not). Spot-check one sector by hand from the period table.
- 474. **Write the review** in `out/attribution-review.md` using the template below. Lead with the summary sentence the script prints, then add judgment: which effects are repeatable decisions (sector tilts, stock picks) and which look like one-off luck (one stock, one period).
- 485. **Show the user** the summary and the linked table. Offer the per-period tables if they want detail.
- 50## Output template
- 52```markdown
- 53# Attribution: <portfolio> vs <benchmark>, <first period> to <last period>
- 55Portfolio +6.22% | Benchmark +5.53% | Active +70 bps
- 57## What drove it
- 58- Interaction +50 bps: overweight Tech while its holdings beat the Tech index.
- 59- Allocation +39 bps: underweight Energy in P1 when Energy lagged.
- 60- Selection -19 bps: Energy picks lagged the Energy index by 3% in P1.
- 62## By sector (linked)
- 63| Sector | Allocation | Selection | Interaction | Total |
- 65## Watch next period
- 66- <one or two questions the numbers raise>
- 68Not financial advice. Attribution describes past results only.
- 69```
- 71## Interpreting edge cases
- 73- **Sector not in the benchmark** (for example a crypto sleeve): its benchmark return is taken as the total benchmark return, so allocation and selection are 0 and the whole excess shows as interaction. Call this out.
- 74- **Sector not held**: selection and interaction are 0; only allocation remains.
- 75- **Large interaction**: common when big over or underweights meet big return gaps. Some teams fold it into selection. If the user asks, report "selection incl. interaction" as the sum; never silently change the method.
- 76- **Transaction costs, fees, intra-period trading**: Brinson uses beginning weights, so trading inside a period shows up as a small residual against reported returns. Use shorter periods (monthly) to shrink it.
- 77- **Currency effects** are not separated. For multi-currency portfolios, say so and suggest local-currency returns plus a separate currency line.
- 79## Guardrails
- 81- Never invent weights or returns. If data is missing, stop and ask.
- 82- Do not recommend trades. Describe what happened, and frame "watch next period" as questions.
- 83- Keep account values out of chat. Report percentages and basis points.
