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// Brinson-Fachler performance attribution with Carino multi-period linking.
- 3// Usage:
- 4// node scripts/attribution.mjs <holdings.csv> [--out out/attribution] [--tolerance 0.001]
- 5// Input columns (one row per security or sector per period):
- 6// period,sector,security,port_weight,port_return,bench_weight,bench_return
- 7// Weights and returns are decimals (0.05 means 5%). A return may be blank when its weight is 0.
- 8// Writes <out>.json and <out>.md. Not financial advice.
- 9import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
- 10import { dirname } from "node:path";
- 11import { fileURLToPath } from "node:url";
- 13export function parseCsv(text) {
- 14 const rows = [];
- 15 let row = [];
- 16 let field = "";
- 17 let quoted = false;
- 18 const pushRow = () => {
- 19 row.push(field);
- 20 field = "";
- 21 if (row.some((v) => v.trim() !== "")) rows.push(row);
- 22 row = [];
- 23 };
- 24 for (let i = 0; i < text.length; i++) {
- 25 const ch = text[i];
- 26 if (quoted) {
- 27 if (ch === '"' && text[i + 1] === '"') {
- 28 field += '"';
- 29 i++;
- 30 } else if (ch === '"') quoted = false;
- 31 else field += ch;
- 32 } else if (ch === '"') quoted = true;
- 33 else if (ch === ",") {
- 34 row.push(field);
- 35 field = "";
- 36 } else if (ch === "\n" || ch === "\r") {
- 37 if (ch === "\r" && text[i + 1] === "\n") i++;
- 38 pushRow();
- 39 } else field += ch;
- 40 }
- 41 pushRow();
- 42 const [header = [], ...body] = rows;
- 43 const keys = header.map((h) => h.trim().toLowerCase());
- 44 return body.map((r) => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? "").trim()])));
- 45}
- 47const REQUIRED = ["period", "sector", "port_weight", "port_return", "bench_weight", "bench_return"];
- 49function num(value, what) {
- 50 if (value === "" || value === undefined) return null;
- 51 const n = Number(String(value).replace(/%$/, ""));
- 52 if (!Number.isFinite(n)) throw new Error(`${what}: "${value}" is not a number`);
- 53 return String(value).endsWith("%") ? n / 100 : n;
- 54}
- 56// Turn raw rows into { periods: [{ period, sectors: Map(sector -> {wp, rp, wb, rb}) }] }.
- 57export function aggregate(rows, { tolerance = 0.001 } = {}) {
- 58 if (rows.length === 0) throw new Error("no rows in input");
- 59 for (const k of REQUIRED) {
- 60 if (!(k in rows[0])) throw new Error(`missing column "${k}". Needed: ${REQUIRED.join(",")} (security is optional)`);
- 61 }
- 62 const order = [];
- 63 const byPeriod = new Map();
- 64 rows.forEach((r, idx) => {
- 65 const line = idx + 2;
- 66 const wp = num(r.port_weight, `line ${line} port_weight`) ?? 0;
- 67 const wb = num(r.bench_weight, `line ${line} bench_weight`) ?? 0;
- 68 const rp = num(r.port_return, `line ${line} port_return`);
- 69 const rb = num(r.bench_return, `line ${line} bench_return`);
- 70 if (wp !== 0 && rp === null) throw new Error(`line ${line}: port_return is blank but port_weight is ${wp}`);
- 71 if (wb !== 0 && rb === null) throw new Error(`line ${line}: bench_return is blank but bench_weight is ${wb}`);
- 72 if (!r.sector) throw new Error(`line ${line}: sector is blank`);
- 73 if (!byPeriod.has(r.period)) {
- 74 byPeriod.set(r.period, new Map());
- 75 order.push(r.period);
- 76 }
- 77 const sectors = byPeriod.get(r.period);
- 78 const s = sectors.get(r.sector) ?? { wp: 0, wpr: 0, wb: 0, wbr: 0 };
- 79 s.wp += wp;
- 80 s.wpr += wp * (rp ?? 0);
- 81 s.wb += wb;
- 82 s.wbr += wb * (rb ?? 0);
- 83 sectors.set(r.sector, s);
- 84 });
- 85 const warnings = [];
- 86 const periods = order.map((period) => {
- 87 const sectors = byPeriod.get(period);
- 88 let sumWp = 0;
- 89 let sumWb = 0;
- 90 for (const s of sectors.values()) {
- 91 sumWp += s.wp;
- 92 sumWb += s.wb;
- 93 }
- 94 if (Math.abs(sumWp - 1) > tolerance || Math.abs(sumWb - 1) > tolerance) {
- 95 throw new Error(
- 96 `period ${period}: weights must each sum to 1 (portfolio ${sumWp.toFixed(6)}, benchmark ${sumWb.toFixed(6)}). Add a Cash row or fix the export.`,
- 97 );
- 98 }
- 99 if (Math.abs(sumWp - 1) > 1e-12 || Math.abs(sumWb - 1) > 1e-12) {
- 100 warnings.push(`period ${period}: weights rescaled to sum to exactly 1 (were ${sumWp.toFixed(6)} and ${sumWb.toFixed(6)})`);
- 101 }
- 102 const out = new Map();
- 103 for (const [name, s] of sectors) {
- 104 out.set(name, {
- 105 wp: s.wp / sumWp,
- 106 rp: s.wp !== 0 ? s.wpr / s.wp : null,
- 107 wb: s.wb / sumWb,
- 108 rb: s.wb !== 0 ? s.wbr / s.wb : null,
- 109 });
- 110 }
- 111 return { period, sectors: out };
- 112 });
- 113 return { periods, warnings };
- 114}
- 116// Single-period Brinson-Fachler.
- 117// allocation_i = (wp_i - wb_i) * (rb_i - Rb)
- 118// selection_i = wb_i * (rp_i - rb_i)
- 119// interaction_i = (wp_i - wb_i) * (rp_i - rb_i)
- 120// A sector the portfolio does not hold uses rp_i = rb_i; a sector the benchmark does not hold uses rb_i = Rb.
- 121export function brinsonFachler(sectors) {
- 122 let Rp = 0;
- 123 let Rb = 0;
- 124 for (const s of sectors.values()) {
- 125 Rp += s.wp * (s.rp ?? 0);
- 126 Rb += s.wb * (s.rb ?? 0);
- 127 }
- 128 const rows = [];
- 129 const total = { allocation: 0, selection: 0, interaction: 0 };
- 130 for (const [sector, s] of sectors) {
- 131 const rb = s.rb ?? Rb;
- 132 const rp = s.rp ?? rb;
- 133 const allocation = (s.wp - s.wb) * (rb - Rb);
- 134 const selection = s.wb * (rp - rb);
- 135 const interaction = (s.wp - s.wb) * (rp - rb);
- 136 total.allocation += allocation;
- 137 total.selection += selection;
- 138 total.interaction += interaction;
- 139 rows.push({ sector, port_weight: s.wp, port_return: s.rp, bench_weight: s.wb, bench_return: s.rb, allocation, selection, interaction, total: allocation + selection + interaction });
- 140 }
- 141 total.total = total.allocation + total.selection + total.interaction;
- 142 return { port_return: Rp, bench_return: Rb, active_return: Rp - Rb, sectors: rows, total };
- 143}
- 145// Carino (1999) log-linking coefficient. k(R, B) = (ln(1+R) - ln(1+B)) / (R - B), or 1/(1+R) when R = B.
- 146export function carinoK(R, B) {
- 147 if (Math.abs(R - B) < 1e-15) return 1 / (1 + R);
- 148 return (Math.log1p(R) - Math.log1p(B)) / (R - B);
- 149}
- 151export function link(periodResults) {
- 152 const cumP = periodResults.reduce((acc, p) => acc * (1 + p.port_return), 1) - 1;
- 153 const cumB = periodResults.reduce((acc, p) => acc * (1 + p.bench_return), 1) - 1;
- 154 const K = carinoK(cumP, cumB);
- 155 const sectors = new Map();
- 156 const total = { allocation: 0, selection: 0, interaction: 0 };
- 157 const factors = [];
- 158 for (const p of periodResults) {
- 159 const k = carinoK(p.port_return, p.bench_return);
- 160 const f = k / K;
- 161 factors.push({ period: p.period, k, factor: f });
- 162 for (const s of p.sectors) {
- 163 const acc = sectors.get(s.sector) ?? { sector: s.sector, allocation: 0, selection: 0, interaction: 0 };
- 164 acc.allocation += s.allocation * f;
- 165 acc.selection += s.selection * f;
- 166 acc.interaction += s.interaction * f;
- 167 sectors.set(s.sector, acc);
- 168 }
- 169 total.allocation += p.total.allocation * f;
- 170 total.selection += p.total.selection * f;
- 171 total.interaction += p.total.interaction * f;
- 172 }
- 173 const rows = [...sectors.values()].map((s) => ({ ...s, total: s.allocation + s.selection + s.interaction }));
- 174 total.total = total.allocation + total.selection + total.interaction;
- 175 return { method: "carino", port_return: cumP, bench_return: cumB, active_return: cumP - cumB, K, factors, sectors: rows, total };
- 176}
- 178export function analyze(rows, opts = {}) {
- 179 const { periods, warnings } = aggregate(rows, opts);
- 180 const results = periods.map((p) => ({ period: p.period, ...brinsonFachler(p.sectors) }));
- 181 const linked = link(results);
- 182 const residual = linked.total.total - linked.active_return;
- 183 if (Math.abs(residual) > 1e-10) warnings.push(`linking residual ${residual.toExponential(2)} (should be ~0)`);
- 184 return { periods: results, linked, warnings, summary: summarize(linked) };
- 185}
- 187const pct = (x, d = 2) => (x === null || x === undefined ? "n/a" : `${x >= 0 ? "+" : ""}${(x * 100).toFixed(d)}%`);
- 188const bps = (x) => `${x >= 0 ? "+" : ""}${Math.round(x * 10000)} bps`;
- 190export function summarize(linked) {
- 191 const lines = [];
- 192 const verb = linked.active_return >= 0 ? "beat" : "lagged";
- 193 lines.push(
- 194 `The portfolio returned ${pct(linked.port_return)} against ${pct(linked.bench_return)} for the benchmark, so it ${verb} by ${Math.round(Math.abs(linked.active_return) * 10000)} bps.`,
- 195 );
- 196 const parts = [
- 197 ["sector bets (allocation)", linked.total.allocation],
- 198 ["stock picking (selection)", linked.total.selection],
- 199 ["the overlap of the two (interaction)", linked.total.interaction],
- 200 ].sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
- 201 lines.push(`The biggest driver was ${parts[0][0]} at ${bps(parts[0][1])}, then ${parts[1][0]} at ${bps(parts[1][1])} and ${parts[2][0]} at ${bps(parts[2][1])}.`);
- 202 const sorted = [...linked.sectors].sort((a, b) => b.total - a.total);
- 203 const best = sorted[0];
- 204 const worst = sorted[sorted.length - 1];
- 205 if (best && best.total > 0) lines.push(`${best.sector} helped most (${bps(best.total)}), ${describe(best)}.`);
- 206 if (worst && worst.total < 0) lines.push(`${worst.sector} hurt most (${bps(worst.total)}), ${describe(worst)}.`);
- 207 return lines.join(" ");
- 208}
- 210function describe(s) {
- 211 const main = [
- 212 ["mostly from the sector weight", s.allocation],
- 213 ["mostly from the holdings picked inside it", s.selection],
- 214 ["mostly from the interaction of weight and picks", s.interaction],
- 215 ].sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))[0];
- 216 return main[0];
- 217}
- 219export function toMarkdown(result) {
- 220 const { periods, linked, warnings, summary } = result;
- 221 const out = [];
- 222 out.push("# Performance attribution (Brinson-Fachler)", "");
- 223 out.push(`Periods: ${periods.map((p) => p.period).join(", ")} | Linking: Carino (log-linked, sums exactly to the cumulative active return)`, "");
- 224 out.push("## Summary", "", summary, "");
- 225 out.push("## Cumulative, linked", "");
- 226 out.push(`Portfolio ${pct(linked.port_return, 4)} | Benchmark ${pct(linked.bench_return, 4)} | Active ${pct(linked.active_return, 4)}`, "");
- 227 out.push("| Sector | Allocation | Selection | Interaction | Total |", "| --- | ---: | ---: | ---: | ---: |");
- 228 for (const s of linked.sectors) out.push(`| ${s.sector} | ${pct(s.allocation, 4)} | ${pct(s.selection, 4)} | ${pct(s.interaction, 4)} | ${pct(s.total, 4)} |`);
- 229 out.push(`| **Total** | ${pct(linked.total.allocation, 4)} | ${pct(linked.total.selection, 4)} | ${pct(linked.total.interaction, 4)} | ${pct(linked.total.total, 4)} |`, "");
- 230 for (const p of periods) {
- 231 out.push(`## Period ${p.period}`, "");
- 232 out.push(`Portfolio ${pct(p.port_return, 4)} | Benchmark ${pct(p.bench_return, 4)} | Active ${pct(p.active_return, 4)}`, "");
- 233 out.push("| Sector | Wp | Rp | Wb | Rb | Allocation | Selection | Interaction | Total |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |");
- 234 for (const s of p.sectors) {
- 235 out.push(
- 236 `| ${s.sector} | ${pct(s.port_weight)} | ${pct(s.port_return)} | ${pct(s.bench_weight)} | ${pct(s.bench_return)} | ${pct(s.allocation, 4)} | ${pct(s.selection, 4)} | ${pct(s.interaction, 4)} | ${pct(s.total, 4)} |`,
- 237 );
- 238 }
- 239 out.push(`| **Total** | | | | | ${pct(p.total.allocation, 4)} | ${pct(p.total.selection, 4)} | ${pct(p.total.interaction, 4)} | ${pct(p.total.total, 4)} |`, "");
- 240 }
- 241 if (warnings.length) out.push("## Warnings", "", ...warnings.map((w) => `- ${w}`), "");
- 242 out.push("_Not financial advice. Attribution explains past returns; it does not predict future ones._", "");
- 243 return out.join("\n");
- 244}
- 246function main(argv) {
- 247 const args = [...argv];
- 248 const take = (flag, fallback) => {
- 249 const i = args.indexOf(flag);
- 250 if (i === -1) return fallback;
- 251 const v = args[i + 1];
- 252 args.splice(i, 2);
- 253 return v;
- 254 };
- 255 const outBase = take("--out", "out/attribution");
- 256 const tolerance = Number(take("--tolerance", "0.001"));
- 257 const input = args[0];
- 258 if (!input || input === "--help") {
- 259 console.error("Usage: node scripts/attribution.mjs <holdings.csv> [--out out/attribution] [--tolerance 0.001]");
- 260 process.exit(input ? 0 : 2);
- 261 }
- 262 let rows;
- 263 try {
- 264 rows = parseCsv(readFileSync(input, "utf8"));
- 265 } catch (e) {
- 266 console.error(`Could not read ${input}: ${e.message}`);
- 267 process.exit(1);
- 268 }
- 269 let result;
- 270 try {
- 271 result = analyze(rows, { tolerance });
- 272 } catch (e) {
- 273 console.error(`Input problem: ${e.message}`);
- 274 process.exit(1);
- 275 }
- 276 mkdirSync(dirname(outBase) || ".", { recursive: true });
- 277 const jsonPath = outBase + ".json";
- 278 const mdPath = outBase + ".md";
- 279 writeFileSync(jsonPath, JSON.stringify(result, null, 2) + "\n");
- 280 writeFileSync(mdPath, toMarkdown(result));
- 281 console.log(result.summary);
- 282 console.log(`Wrote ${outBase}.json and ${outBase}.md`);
- 283}
- 285function isMain() {
- 286 try {
- 287 return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
- 288 } catch {
- 289 return false;
- 290 }
- 291}
- 293if (isMain()) main(process.argv.slice(2));
