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