Skip to content
SlashClone
Made by@qtrader

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.

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