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#!/usr/bin/env node
- 2// Self-test: checks the script against the hand calculation in README.md.
- 3// Usage: node scripts/selftest.mjs (exits 1 on any failure)
- 4import { readFileSync } from "node:fs";
- 5import { dirname, join } from "node:path";
- 6import { fileURLToPath } from "node:url";
- 7import { analyze, brinsonFachler, carinoK, parseCsv } from "./attribution.mjs";
- 9const here = dirname(fileURLToPath(import.meta.url));
- 10let failed = 0;
- 11let passed = 0;
- 12function close(actual, expected, label, tol = 1e-12) {
- 13 if (Math.abs(actual - expected) <= tol) passed++;
- 14 else {
- 15 failed++;
- 16 console.error(`FAIL ${label}: expected ${expected}, got ${actual}`);
- 17 }
- 18}
- 19function throws(fn, text, label) {
- 20 try {
- 21 fn();
- 22 failed++;
- 23 console.error(`FAIL ${label}: expected an error`);
- 24 } catch (e) {
- 25 if (e.message.includes(text)) passed++;
- 26 else {
- 27 failed++;
- 28 console.error(`FAIL ${label}: wrong error "${e.message}"`);
- 29 }
- 30 }
- 31}
- 33const rows = parseCsv(readFileSync(join(here, "../examples/holdings.csv"), "utf8"));
- 34const r = analyze(rows);
- 35const [p1, p2] = r.periods;
- 36const by = (p, s) => p.sectors.find((x) => x.sector === s);
- 38// Period 1 (README "Hand check")
- 39close(p1.port_return, 0.058, "P1 Rp");
- 40close(p1.bench_return, 0.05, "P1 Rb");
- 41close(by(p1, "Tech").port_return, 0.10, "P1 Tech rp aggregates AAA and BBB");
- 42close(by(p1, "Tech").allocation, 0.003, "P1 Tech allocation");
- 43close(by(p1, "Tech").selection, 0.008, "P1 Tech selection");
- 44close(by(p1, "Tech").interaction, 0.002, "P1 Tech interaction");
- 45close(by(p1, "Health").selection, -0.003, "P1 Health selection");
- 46close(by(p1, "Energy").allocation, 0.004, "P1 Energy allocation");
- 47close(by(p1, "Energy").selection, -0.009, "P1 Energy selection");
- 48close(by(p1, "Energy").interaction, 0.003, "P1 Energy interaction");
- 49close(p1.total.allocation, 0.007, "P1 total allocation");
- 50close(p1.total.selection, -0.004, "P1 total selection");
- 51close(p1.total.interaction, 0.005, "P1 total interaction");
- 52close(p1.total.total, p1.active_return, "P1 effects sum to active");
- 54// Period 2
- 55close(p2.port_return, 0.004, "P2 Rp");
- 56close(p2.bench_return, 0.005, "P2 Rb");
- 57close(p2.total.allocation, -0.003, "P2 total allocation");
- 58close(p2.total.selection, 0.002, "P2 total selection");
- 59close(p2.total.interaction, 0, "P2 total interaction");
- 61// Linking
- 62const L = r.linked;
- 63close(L.port_return, 0.062232, "cumulative Rp");
- 64close(L.bench_return, 0.05525, "cumulative Rb");
- 65close(L.K, 0.9445214874210852, "Carino K", 1e-12);
- 66close(L.factors[0].k, 0.948771158334455, "Carino k1", 1e-12);
- 67close(L.factors[1].k, 0.9955202415015086, "Carino k2", 1e-12);
- 68close(L.total.allocation, 0.0038695121630486157, "linked allocation", 1e-12);
- 69close(L.total.selection, -0.0019100085856813627, "linked selection", 1e-12);
- 70close(L.total.interaction, 0.0050224964226328675, "linked interaction", 1e-12);
- 71close(L.total.total, L.active_return, "linked effects sum exactly to cumulative active", 1e-14);
- 72close(L.sectors.find((s) => s.sector === "Tech").total, 0.008842513593995592, "linked Tech total", 1e-12);
- 73close(L.sectors.find((s) => s.sector === "Energy").total, -0.004643984259584313, "linked Energy total", 1e-12);
- 75// Carino edge: equal returns use 1/(1+R)
- 76close(carinoK(0.05, 0.05), 1 / 1.05, "carinoK equal returns");
- 78// Single period: linked equals unlinked
- 79const single = analyze(rows.filter((x) => x.period === "P1"));
- 80close(single.linked.total.allocation, 0.007, "single period linking is identity");
- 82// Sector held only by the portfolio: rb falls back to Rb, so allocation is 0 and the excess is interaction
- 83const off = brinsonFachler(
- 84 new Map([
- 85 ["A", { wp: 0.9, rp: 0.1, wb: 1, rb: 0.05 }],
- 86 ["Crypto", { wp: 0.1, rp: 0.5, wb: 0, rb: null }],
- 87 ]),
- 88);
- 89const crypto = off.sectors.find((s) => s.sector === "Crypto");
- 90close(crypto.allocation, 0, "off-benchmark allocation");
- 91close(crypto.selection, 0, "off-benchmark selection");
- 92close(crypto.interaction, 0.1 * (0.5 - 0.05), "off-benchmark interaction");
- 93close(off.total.total, off.active_return, "off-benchmark effects sum to active");
- 95// Bad inputs
- 96throws(() => analyze(parseCsv("period,sector,port_weight,port_return,bench_weight,bench_return\nP1,A,0.5,0.1,1,0.05\n")), "must each sum to 1", "weights not summing to 1");
- 97throws(() => analyze(parseCsv("period,sector,port_weight,port_return,bench_weight,bench_return\nP1,A,1,,1,0.05\n")), "port_return is blank", "blank return with weight");
- 98throws(() => analyze(parseCsv("period,sector,weight\nP1,A,1\n")), "missing column", "missing columns");
- 99// Percent strings are accepted
- 100const pctRows = analyze(parseCsv("period,sector,port_weight,port_return,bench_weight,bench_return\nP1,A,100%,10%,100%,5%\n"));
- 101close(pctRows.periods[0].active_return, 0.05, "percent strings");
- 103console.log(`${passed} passed, ${failed} failed`);
- 104process.exit(failed ? 1 : 0);
