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
What it makes
Real output from this kit
- Sample file4 KB, download to openAttributionFrom the kit's tests
- Sample file2 KB, download to openAttributionFrom the kit's tests
How this works
- It is a skill: written instructions Claude reads when your request matches what the skill is for. You do not call it by name; just ask for the task.
- While it works, it reads and changes files in the places it lists, nowhere else.
- It runs these commands on your computer: node scripts/attribution.mjs, node scripts/selftest.mjs.
- Nothing runs until you choose to use it, and you can remove it any time.
SKILL.md
Performance attribution (Brinson-Fachler, Carino-linked)
Splits the gap between a portfolio and its benchmark into three parts per sector:
- Allocation: did we overweight sectors that beat the benchmark?
(wp - wb) * (rb - Rb) - Selection: inside each sector, did our holdings beat the sector's benchmark?
wb * (rp - rb) - Interaction: the overlap, being overweight where we also picked well.
(wp - wb) * (rp - rb)
Per 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.
This explains the past. It is not a forecast and not financial advice. Say so in the report.
Inputs
One CSV, usually data/holdings.csv, one row per security (or per sector) per period:
period,sector,security,port_weight,port_return,bench_weight,bench_return
2026-09,Tech,AAA,0.30,0.12,0.25,0.12
2026-09,Tech,Tech index,0,,0.15,0.08- Weights are beginning-of-period weights as decimals. Returns are the period's total return as decimals (
0.05is 5%).5%strings also work. - A return can be blank when its weight is 0. A security held by both sides can carry both weights on one row.
- 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
Cashsector row. - Periods are processed in file order, so sort them oldest first.
If 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.
Steps
- 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.
Run it.
node scripts/attribution.mjs data/holdings.csv --out out/attributionThis writes
out/attribution.json(all numbers) andout/attribution.md(tables plus summary). It exits non-zero with a clear message on bad input. Read the message, fix the data, run again.- 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.
- Write the review in
out/attribution-review.mdusing 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). - Show the user the summary and the linked table. Offer the per-period tables if they want detail.
Output template
# Attribution: <portfolio> vs <benchmark>, <first period> to <last period>
Portfolio +6.22% | Benchmark +5.53% | Active +70 bps
## What drove it
- Interaction +50 bps: overweight Tech while its holdings beat the Tech index.
- Allocation +39 bps: underweight Energy in P1 when Energy lagged.
- Selection -19 bps: Energy picks lagged the Energy index by 3% in P1.
## By sector (linked)
| Sector | Allocation | Selection | Interaction | Total |
## Watch next period
- <one or two questions the numbers raise>
Not financial advice. Attribution describes past results only.Interpreting edge cases
- 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.
- Sector not held: selection and interaction are 0; only allocation remains.
- 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.
- 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.
- Currency effects are not separated. For multi-currency portfolios, say so and suggest local-currency returns plus a separate currency line.
Guardrails
- Never invent weights or returns. If data is missing, stop and ask.
- Do not recommend trades. Describe what happened, and frame "watch next period" as questions.
- Keep account values out of chat. Report percentages and basis points.
README.md
attribution-analysis
Find 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.
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.
Not financial advice. Attribution explains past results only.
Example
Input examples/holdings.csv (two periods, three sectors, made-up tickers):
period,sector,security,port_weight,port_return,bench_weight,bench_return
P1,Tech,AAA,0.30,0.12,0,
P1,Tech,BBB,0.20,0.07,0,
P1,Tech,Tech index,0,,0.40,0.08
...Output (out/attribution.md, trimmed):
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.
| Sector | Allocation | Selection | Interaction | Total |
|---|---|---|---|---|
| Tech | +0.3013% | +0.3820% | +0.2009% | +0.8843% |
| Health | +0.1581% | +0.0148% | +0.1054% | +0.2783% |
| Energy | -0.0725% | -0.5879% | +0.1960% | -0.4644% |
| Total | +0.3870% | -0.1910% | +0.5022% | +0.6982% |
Hand check (verify it yourself)
Formulas (Brinson-Fachler), with Rb the total benchmark return:
- Allocation = (wp - wb) x (rb - Rb)
- Selection = wb x (rp - rb)
- Interaction = (wp - wb) x (rp - rb)
Period P1. Sector returns: Tech portfolio = (0.30 x 0.12 + 0.20 x 0.07) / 0.50 = 0.10.
| Sector | wp | rp | wb | rb |
|---|---|---|---|---|
| Tech | 0.50 | 0.10 | 0.40 | 0.08 |
| Health | 0.30 | 0.04 | 0.30 | 0.05 |
| Energy | 0.20 | -0.02 | 0.30 | 0.01 |
Rp = 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.
| Sector | Allocation | Selection | Interaction |
|---|---|---|---|
| Tech | 0.10 x (0.08 - 0.05) = 0.003 | 0.40 x 0.02 = 0.008 | 0.10 x 0.02 = 0.002 |
| Health | 0 x ... = 0 | 0.30 x (-0.01) = -0.003 | 0 |
| Energy | -0.10 x (0.01 - 0.05) = 0.004 | 0.30 x (-0.03) = -0.009 | -0.10 x (-0.03) = 0.003 |
| Total | 0.007 | -0.004 | 0.005 |
0.007 - 0.004 + 0.005 = 0.008, the active return.
Period P2. Tech portfolio = (0.20 x -0.04 + 0.20 x -0.06) / 0.40 = -0.05.
| Sector | wp | rp | wb | rb |
|---|---|---|---|---|
| Tech | 0.40 | -0.05 | 0.40 | -0.04 |
| Health | 0.40 | 0.03 | 0.30 | 0.02 |
| Energy | 0.20 | 0.06 | 0.30 | 0.05 |
Rp = 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.
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:
- k_t = (ln(1 + Rp_t) - ln(1 + Rb_t)) / (Rp_t - Rb_t): k1 = 0.948771, k2 = 0.995520
- K = (ln 1.062232 - ln 1.05525) / 0.006982 = 0.944521
- Each period's effects are multiplied by k_t / K: 1.004499 for P1 and 1.053994 for P2.
Linked 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.
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).
What it can access
- Reads your CSV from
data/. Writesout/attribution.json,out/attribution.md, and the review inout/. - Runs only its own two scripts with Node. No network, no accounts, no API keys.
Setup
Node 22 or newer. No packages to install. Put your CSV in data/holdings.csv and ask Claude to "run attribution against the benchmark".
Files
- README.md
- SKILL.md
- examples/holdings.csv
- kit.json
- scripts/attribution.mjs
- scripts/selftest.mjs
Did it work?
0worked for them0did not work
Sign in to say whether it worked for you.
Nobody has said yet. Tried it? Tell the next person.
