fin-ratios 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -119,10 +119,10 @@ gordonGrowthModel.formula = "D1 / (r - g)";
119
119
  gordonGrowthModel.description = "DDM for stable dividend-paying stocks. Only valid when r > g.";
120
120
  function reverseDcf(input) {
121
121
  const target = input.marketCap + (input.netDebt ?? 0);
122
- const computeEV = (g2) => {
122
+ const computeEV = (g4) => {
123
123
  const result = dcf2Stage({
124
124
  baseFcf: input.baseFcf,
125
- growthRate: g2,
125
+ growthRate: g4,
126
126
  years: input.years,
127
127
  terminalGrowthRate: input.terminalGrowthRate,
128
128
  wacc: input.wacc
@@ -452,8 +452,8 @@ function annualizeReturn(returnValue, periodsPerYear) {
452
452
  }
453
453
  function stdDev(values, ddof = 1) {
454
454
  if (values.length < 2) return null;
455
- const mean2 = values.reduce((a, b) => a + b, 0) / values.length;
456
- const variance = values.reduce((sum, v) => sum + Math.pow(v - mean2, 2), 0) / (values.length - ddof);
455
+ const mean4 = values.reduce((a, b) => a + b, 0) / values.length;
456
+ const variance = values.reduce((sum, v) => sum + Math.pow(v - mean4, 2), 0) / (values.length - ddof);
457
457
  return Math.sqrt(variance);
458
458
  }
459
459
  function mean(values) {
@@ -1331,6 +1331,400 @@ function _evict() {
1331
1331
  }
1332
1332
  }
1333
1333
 
1334
+ // src/utils/moat-score.ts
1335
+ function mean2(xs) {
1336
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
1337
+ }
1338
+ function std(xs) {
1339
+ if (xs.length < 2) return 0;
1340
+ const m = mean2(xs);
1341
+ return Math.sqrt(xs.reduce((s, x) => s + (x - m) ** 2, 0) / (xs.length - 1));
1342
+ }
1343
+ function cv(xs) {
1344
+ const m = mean2(xs);
1345
+ return Math.abs(m) > 1e-9 ? std(xs) / Math.abs(m) : 1;
1346
+ }
1347
+ function olsSlope(ys) {
1348
+ const n = ys.length;
1349
+ if (n < 2) return 0;
1350
+ const xm = (n - 1) / 2;
1351
+ const ym = mean2(ys);
1352
+ const ssXX = Array.from({ length: n }, (_, i) => (i - xm) ** 2).reduce((a, b) => a + b, 0);
1353
+ const ssXY = Array.from({ length: n }, (_, i) => (i - xm) * ((ys[i] ?? 0) - ym)).reduce((a, b) => a + b, 0);
1354
+ return ssXX ? ssXY / ssXX : 0;
1355
+ }
1356
+ function g2(d, key) {
1357
+ const v = d[key];
1358
+ return typeof v === "number" && !isNaN(v) ? v : 0;
1359
+ }
1360
+ function yearRoic(d) {
1361
+ const ebit = g2(d, "ebit");
1362
+ const equity = g2(d, "totalEquity");
1363
+ const debt = g2(d, "totalDebt");
1364
+ const cash = g2(d, "cash");
1365
+ const ic = equity + debt - cash;
1366
+ if (ic <= 0 || ebit <= 0) return null;
1367
+ const ebt = g2(d, "ebt") || ebit - g2(d, "interestExpense");
1368
+ const tax = g2(d, "incomeTaxExpense");
1369
+ const taxRate = ebt > 0 && tax > 0 ? Math.min(Math.max(tax / ebt, 0), 0.5) : 0.21;
1370
+ return ebit * (1 - taxRate) / ic;
1371
+ }
1372
+ function estimateWacc(series, override) {
1373
+ if (override !== void 0) return override;
1374
+ const d = series[series.length - 1];
1375
+ const equity = g2(d, "totalEquity");
1376
+ const debt = g2(d, "totalDebt");
1377
+ const totalCap = equity + debt;
1378
+ if (totalCap <= 0) return 0.09;
1379
+ const we = equity / totalCap;
1380
+ const wd = debt / totalCap;
1381
+ const costEquity = 0.045 + 1 * 0.055;
1382
+ const interest = g2(d, "interestExpense");
1383
+ const preCostDebt = debt > 0 && interest > 0 ? Math.min(interest / debt, 0.15) : 0.05;
1384
+ const ebt = g2(d, "ebt") || g2(d, "ebit") - interest;
1385
+ const tax = g2(d, "incomeTaxExpense");
1386
+ const taxRate = ebt > 0 && tax > 0 ? Math.min(Math.max(tax / ebt, 0), 0.4) : 0.21;
1387
+ const costDebt = preCostDebt * (1 - taxRate);
1388
+ return Math.max(Math.min(we * costEquity + wd * costDebt, 0.2), 0.06);
1389
+ }
1390
+ function scoreRoicPersistence(roicSeries, wacc) {
1391
+ if (!roicSeries.length) return [0, ["Insufficient ROIC data"]];
1392
+ const above = roicSeries.filter((r) => r > wacc).length;
1393
+ const pctAbove = above / roicSeries.length;
1394
+ const roicCv = cv(roicSeries);
1395
+ const meanRoic = mean2(roicSeries);
1396
+ const spread = meanRoic - wacc;
1397
+ const stability = Math.max(0, 1 - roicCv * 0.4);
1398
+ const score = Math.min(Math.max(pctAbove * stability, 0), 1);
1399
+ return [score, [
1400
+ `ROIC exceeded WACC in ${above}/${roicSeries.length} years (${(pctAbove * 100).toFixed(0)}%)`,
1401
+ `Mean ROIC ${(meanRoic * 100).toFixed(1)}% vs WACC ${(wacc * 100).toFixed(1)}% (spread ${spread >= 0 ? "+" : ""}${(spread * 100).toFixed(1)}%)`,
1402
+ `ROIC volatility (CV): ${roicCv.toFixed(2)} \u2014 ${roicCv < 0.15 ? "highly stable" : roicCv < 0.3 ? "stable" : "volatile"}`
1403
+ ]];
1404
+ }
1405
+ function scorePricingPower(gmSeries) {
1406
+ if (!gmSeries.length) return [0.5, ["Gross margin unavailable \u2014 pricing power assumed neutral"]];
1407
+ const meanGm = mean2(gmSeries);
1408
+ const gmCv = cv(gmSeries);
1409
+ const slope = olsSlope(gmSeries);
1410
+ const level = Math.min(Math.max((meanGm - 0.2) / 0.4, 0), 1);
1411
+ const stability = Math.max(0, 1 - gmCv * 2.5);
1412
+ const trendAdj = Math.max(Math.min(slope * 10, 0.1), -0.1);
1413
+ const score = Math.min(Math.max(0.6 * level + 0.4 * stability + trendAdj, 0), 1);
1414
+ const trendLbl = slope > 5e-3 ? "improving" : slope < -5e-3 ? "declining" : "stable";
1415
+ return [score, [
1416
+ `Mean gross margin ${(meanGm * 100).toFixed(1)}% over ${gmSeries.length} years`,
1417
+ `Gross margin CV ${gmCv.toFixed(3)} \u2014 ${gmCv < 0.05 ? "excellent stability" : gmCv < 0.15 ? "moderate" : "high variability"}`,
1418
+ `Gross margin trend: ${trendLbl}`
1419
+ ]];
1420
+ }
1421
+ function scoreReinvestmentQuality(series) {
1422
+ if (series.length < 2) return [0.5, ["Insufficient data for reinvestment quality \u2014 assumed neutral"]];
1423
+ const roiicVals = [];
1424
+ let negativeCount = 0;
1425
+ for (let i = 1; i < series.length; i++) {
1426
+ const prev = series[i - 1];
1427
+ const curr = series[i];
1428
+ const deltaEbit = g2(curr, "ebit") - g2(prev, "ebit");
1429
+ const netReinvest = g2(curr, "capex") - g2(curr, "depreciation") + (g2(curr, "currentAssets") - g2(curr, "currentLiabilities")) - (g2(prev, "currentAssets") - g2(prev, "currentLiabilities"));
1430
+ if (netReinvest > 1e6) {
1431
+ roiicVals.push(Math.max(Math.min(deltaEbit / netReinvest, 5), -1));
1432
+ } else {
1433
+ negativeCount++;
1434
+ }
1435
+ }
1436
+ const totalPeriods = series.length - 1;
1437
+ const capitalLight = !roiicVals.length || negativeCount / totalPeriods > 0.5;
1438
+ if (capitalLight) {
1439
+ const ebitGrowth = [];
1440
+ for (let i = 1; i < series.length; i++) {
1441
+ const prevEbit = g2(series[i - 1], "ebit");
1442
+ if (prevEbit > 0) ebitGrowth.push(g2(series[i], "ebit") / prevEbit - 1);
1443
+ }
1444
+ if (ebitGrowth.length && mean2(ebitGrowth) > 0.05) {
1445
+ return [0.82, [
1446
+ "Capital-light model: EBIT growing with minimal net reinvestment",
1447
+ "Low reinvestment requirement is a strong moat signal"
1448
+ ]];
1449
+ }
1450
+ return [0.5, ["Reinvestment quality indeterminate (limited reinvestment data)"]];
1451
+ }
1452
+ const meanRoiic = mean2(roiicVals);
1453
+ const score = Math.min(Math.max(0.3 + meanRoiic * 0.7, 0), 1);
1454
+ return [score, [
1455
+ `Mean incremental ROIC (ROIIC): ${(meanRoiic * 100).toFixed(1)}%`,
1456
+ `Based on ${roiicVals.length} reinvestment period(s)`,
1457
+ meanRoiic > 0.5 ? "Excellent capital reinvestment efficiency" : meanRoiic > 0.15 ? "Adequate reinvestment returns" : "Low incremental returns on reinvestment"
1458
+ ]];
1459
+ }
1460
+ function scoreOperatingLeverage(series) {
1461
+ if (series.length < 2) return [0.5, ["Insufficient data for operating leverage"]];
1462
+ const dolVals = [];
1463
+ for (let i = 1; i < series.length; i++) {
1464
+ const prev = series[i - 1];
1465
+ const curr = series[i];
1466
+ if (g2(prev, "revenue") <= 0 || Math.abs(g2(prev, "ebit")) < 1e5) continue;
1467
+ const pctRev = (g2(curr, "revenue") - g2(prev, "revenue")) / g2(prev, "revenue");
1468
+ const pctEbit = (g2(curr, "ebit") - g2(prev, "ebit")) / Math.abs(g2(prev, "ebit"));
1469
+ if (Math.abs(pctRev) > 5e-3) {
1470
+ const dol = pctEbit / pctRev;
1471
+ if (dol > 0 && dol < 25) dolVals.push(dol);
1472
+ }
1473
+ }
1474
+ if (!dolVals.length) return [0.4, ["Operating leverage indeterminate (insufficient revenue variance)"]];
1475
+ const meanDol = mean2(dolVals);
1476
+ const cvDol = cv(dolVals);
1477
+ const level = Math.min(Math.max((meanDol - 1) / 5, 0), 1);
1478
+ const consistency = Math.max(0, 1 - cvDol * 0.5);
1479
+ const score = Math.min(Math.max(0.65 * level + 0.35 * consistency, 0), 1);
1480
+ const quality = meanDol > 3 ? "High fixed-cost structure \u2014 strong scale advantages" : meanDol > 1.5 ? "Moderate operating leverage" : "Variable cost structure \u2014 limited scale advantage";
1481
+ return [score, [
1482
+ `Mean degree of operating leverage (DOL): ${meanDol.toFixed(2)}\xD7`,
1483
+ `DOL consistency: ${(consistency * 100).toFixed(0)}%`,
1484
+ quality
1485
+ ]];
1486
+ }
1487
+ function scoreCap(roicSeries, wacc) {
1488
+ if (!roicSeries.length) return [0.3, 5, ["Insufficient data for CAP estimate"]];
1489
+ const meanRoic = mean2(roicSeries);
1490
+ const spread = meanRoic - wacc;
1491
+ if (spread <= 0) {
1492
+ return [0, 0, [`ROIC ${(meanRoic * 100).toFixed(1)}% \u2264 WACC ${(wacc * 100).toFixed(1)}%: no competitive advantage detected`]];
1493
+ }
1494
+ const slope = olsSlope(roicSeries);
1495
+ let capYears;
1496
+ let direction;
1497
+ if (slope < -5e-3) {
1498
+ capYears = Math.min(Math.max(spread / Math.abs(slope), 0), 30);
1499
+ direction = "declining";
1500
+ } else if (slope > 5e-3) {
1501
+ capYears = Math.min(spread * 80 + 5, 30);
1502
+ direction = "improving";
1503
+ } else {
1504
+ const roicCv = cv(roicSeries);
1505
+ capYears = Math.min(Math.max(spread * 60 / Math.max(roicCv, 0.1), 3), 25);
1506
+ direction = "stable";
1507
+ }
1508
+ const capScore = Math.min(capYears / 20, 1);
1509
+ return [capScore, capYears, [
1510
+ `Estimated competitive advantage period: ${capYears.toFixed(1)} years`,
1511
+ `ROIC trend: ${direction} (slope ${slope >= 0 ? "+" : ""}${slope.toFixed(3)}/yr)`,
1512
+ `ROIC spread above WACC: ${spread >= 0 ? "+" : ""}${(spread * 100).toFixed(1)}%`
1513
+ ]];
1514
+ }
1515
+ var W = {
1516
+ roicPersistence: 0.3,
1517
+ pricingPower: 0.25,
1518
+ reinvestmentQuality: 0.2,
1519
+ operatingLeverage: 0.15,
1520
+ cap: 0.1
1521
+ };
1522
+ function moatScore(annualData, options = {}) {
1523
+ if (annualData.length < 2) {
1524
+ throw new Error("moatScore requires at least 2 years of data (3\u201310 recommended).");
1525
+ }
1526
+ const estWacc = estimateWacc(annualData, options.wacc);
1527
+ const roicSeries = annualData.map(yearRoic).filter((r) => r !== null);
1528
+ const gmSeries = annualData.filter((d) => g2(d, "revenue") > 0).map((d) => safeDivide(g2(d, "grossProfit"), g2(d, "revenue")) ?? 0);
1529
+ const [sRp, evRp] = scoreRoicPersistence(roicSeries, estWacc);
1530
+ const [sPp, evPp] = scorePricingPower(gmSeries);
1531
+ const [sRq, evRq] = scoreReinvestmentQuality(annualData);
1532
+ const [sOl, evOl] = scoreOperatingLeverage(annualData);
1533
+ const [sCp, capYears, evCp] = scoreCap(roicSeries, estWacc);
1534
+ const raw = W.roicPersistence * sRp + W.pricingPower * sPp + W.reinvestmentQuality * sRq + W.operatingLeverage * sOl + W.cap * sCp;
1535
+ const score = Math.round(Math.min(Math.max(raw * 100, 0), 100));
1536
+ const width = score >= 70 ? "wide" : score >= 40 ? "narrow" : "none";
1537
+ const desc = {
1538
+ wide: "Durable competitive advantage likely to persist for 10+ years",
1539
+ narrow: "Real but limited or potentially fading competitive advantage",
1540
+ none: "No detectable financial signature of a durable economic moat"
1541
+ };
1542
+ return {
1543
+ score,
1544
+ width,
1545
+ components: {
1546
+ roicPersistence: parseFloat(sRp.toFixed(4)),
1547
+ pricingPower: parseFloat(sPp.toFixed(4)),
1548
+ reinvestmentQuality: parseFloat(sRq.toFixed(4)),
1549
+ operatingLeverage: parseFloat(sOl.toFixed(4)),
1550
+ capScore: parseFloat(sCp.toFixed(4))
1551
+ },
1552
+ capEstimateYears: parseFloat(capYears.toFixed(1)),
1553
+ waccUsed: parseFloat(estWacc.toFixed(4)),
1554
+ yearsAnalyzed: annualData.length,
1555
+ evidence: [...evRp, ...evPp, ...evRq, ...evOl, ...evCp],
1556
+ interpretation: `Score ${score}/100: ${desc[width]}`
1557
+ };
1558
+ }
1559
+
1560
+ // src/utils/capital-allocation.ts
1561
+ function mean3(xs) {
1562
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
1563
+ }
1564
+ function std2(xs) {
1565
+ if (xs.length < 2) return 0;
1566
+ const m = mean3(xs);
1567
+ return Math.sqrt(xs.reduce((a, x) => a + (x - m) ** 2, 0) / (xs.length - 1));
1568
+ }
1569
+ function cv2(xs) {
1570
+ const m = mean3(xs);
1571
+ return Math.abs(m) > 1e-9 ? std2(xs) / Math.abs(m) : 1;
1572
+ }
1573
+ function olsSlope2(ys) {
1574
+ const n = ys.length;
1575
+ if (n < 2) return 0;
1576
+ const xm = (n - 1) / 2;
1577
+ const ym = mean3(ys);
1578
+ const ssXX = Array.from({ length: n }, (_, i) => (i - xm) ** 2).reduce((a, b) => a + b, 0);
1579
+ const ssXY = Array.from({ length: n }, (_, i) => (i - xm) * ((ys[i] ?? 0) - ym)).reduce((a, b) => a + b, 0);
1580
+ return ssXX ? ssXY / ssXX : 0;
1581
+ }
1582
+ function clamp(x, lo, hi) {
1583
+ return Math.max(lo, Math.min(hi, x));
1584
+ }
1585
+ function g3(d, k) {
1586
+ const v = d[k];
1587
+ return typeof v === "number" && isFinite(v) ? v : 0;
1588
+ }
1589
+ function estimateWacc2(series, provided) {
1590
+ if (provided !== void 0) return provided;
1591
+ const d = series[series.length - 1];
1592
+ const equity = g3(d, "totalEquity");
1593
+ const debt = g3(d, "totalDebt");
1594
+ const cash = g3(d, "cash");
1595
+ const totalCap = equity + debt - cash;
1596
+ if (totalCap <= 0) return 0.1;
1597
+ const costEquity = 0.045 + 0.055;
1598
+ const interest = g3(d, "interestExpense");
1599
+ let costDebt = 0.04;
1600
+ if (debt > 0 && interest > 0) {
1601
+ const preTax = clamp(interest / debt, 0.02, 0.15);
1602
+ const ebt = g3(d, "ebt") || g3(d, "ebit") - interest;
1603
+ const tax = g3(d, "incomeTaxExpense");
1604
+ const taxRate = ebt > 0 && tax > 0 ? clamp(tax / ebt, 0, 0.4) : 0.21;
1605
+ costDebt = preTax * (1 - taxRate);
1606
+ }
1607
+ const wE = clamp(equity / totalCap, 0, 1);
1608
+ return clamp(wE * costEquity + (1 - wE) * costDebt, 0.06, 0.2);
1609
+ }
1610
+ function yearRoic2(d) {
1611
+ const ebit = g3(d, "ebit");
1612
+ const ic = g3(d, "totalEquity") + g3(d, "totalDebt") - g3(d, "cash");
1613
+ if (ic <= 0) return null;
1614
+ const ebt = g3(d, "ebt") || ebit - g3(d, "interestExpense");
1615
+ const tax = g3(d, "incomeTaxExpense");
1616
+ const taxRate = ebt > 0 && tax > 0 ? clamp(tax / ebt, 0, 0.5) : 0.21;
1617
+ return ebit * (1 - taxRate) / ic;
1618
+ }
1619
+ function scoreValueCreation(series, wacc) {
1620
+ const roicVals = series.map(yearRoic2).filter((r) => r !== null);
1621
+ if (!roicVals.length) return [0.3, ["Value creation: insufficient ROIC data (neutral score)"]];
1622
+ const spreads = roicVals.map((r) => r - wacc);
1623
+ const ms = mean3(spreads);
1624
+ const slope = olsSlope2(spreads);
1625
+ const level = clamp((ms + 0.05) / 0.25, 0, 1);
1626
+ const trendAdj = slope > 0.01 ? 0.1 : slope < -0.01 ? -0.1 : 0;
1627
+ const consistency = Math.max(0, 1 - cv2(spreads) * 0.5);
1628
+ const score = clamp(0.6 * level + 0.4 * consistency + trendAdj, 0, 1);
1629
+ const pos = spreads.filter((s) => s > 0).length;
1630
+ const dir = slope > 0.01 ? "improving" : slope < -0.01 ? "declining" : "stable";
1631
+ return [score, [
1632
+ `Value creation: mean ROIC-WACC spread ${(ms * 100).toFixed(1)}% (${pos}/${spreads.length} years positive)`,
1633
+ `Spread trend: ${dir} (OLS ${(slope * 100).toFixed(2)}%/yr)`
1634
+ ]];
1635
+ }
1636
+ function scoreFcfQuality(series) {
1637
+ const conversions = [];
1638
+ for (const d of series) {
1639
+ const ebit = g3(d, "ebit");
1640
+ if (ebit <= 0) continue;
1641
+ const ebt = g3(d, "ebt") || ebit - g3(d, "interestExpense");
1642
+ const tax = g3(d, "incomeTaxExpense");
1643
+ const taxRate = ebt > 0 && tax > 0 ? clamp(tax / ebt, 0, 0.5) : 0.21;
1644
+ const nopat2 = ebit * (1 - taxRate);
1645
+ if (nopat2 <= 0) continue;
1646
+ const fcf = nopat2 + g3(d, "depreciation") - g3(d, "capex");
1647
+ conversions.push(fcf / nopat2);
1648
+ }
1649
+ if (!conversions.length) return [0.4, ["FCF quality: insufficient data (neutral score)"]];
1650
+ const mc = mean3(conversions);
1651
+ const level = clamp(mc / 1.2, 0, 1);
1652
+ const stability = Math.max(0, 1 - cv2(conversions) * 1.5);
1653
+ const score = clamp(0.65 * level + 0.35 * stability, 0, 1);
1654
+ const quality = mc >= 1 ? "capital-light" : mc < 0.5 ? "capital-heavy" : "moderate";
1655
+ return [score, [`FCF quality: mean NOPAT-to-FCF conversion ${(mc * 100).toFixed(0)}% (${quality})`]];
1656
+ }
1657
+ function scoreReinvestmentYield(series) {
1658
+ const yields = [];
1659
+ for (let i = 1; i < series.length; i++) {
1660
+ const prev = series[i - 1];
1661
+ const curr = series[i];
1662
+ const da = g3(curr, "totalAssets") - g3(prev, "totalAssets");
1663
+ const dr = g3(curr, "revenue") - g3(prev, "revenue");
1664
+ if (da > 1e6) yields.push(dr / da);
1665
+ }
1666
+ if (!yields.length) return [0.4, ["Reinvestment yield: insufficient data (neutral score)"]];
1667
+ const my = mean3(yields);
1668
+ const level = clamp((my + 0.2) / 1.7, 0, 1);
1669
+ const consistency = Math.max(0, 1 - cv2(yields) * 0.5);
1670
+ const score = clamp(0.65 * level + 0.35 * consistency, 0, 1);
1671
+ const pos = yields.filter((y) => y > 0).length;
1672
+ return [score, [
1673
+ `Reinvestment yield: mean incremental revenue/asset ${my.toFixed(2)}x (${pos}/${yields.length} periods productive)`
1674
+ ]];
1675
+ }
1676
+ function scorePayoutDiscipline(series, wacc) {
1677
+ const coverages = [];
1678
+ for (const d of series) {
1679
+ const ebit = g3(d, "ebit");
1680
+ if (ebit <= 0) continue;
1681
+ const ebt = g3(d, "ebt") || ebit - g3(d, "interestExpense");
1682
+ const tax = g3(d, "incomeTaxExpense");
1683
+ const taxRate = ebt > 0 && tax > 0 ? clamp(tax / ebt, 0, 0.5) : 0.21;
1684
+ const nopat2 = ebit * (1 - taxRate);
1685
+ const fcf = nopat2 + g3(d, "depreciation") - g3(d, "capex");
1686
+ const div = g3(d, "dividendsPaid");
1687
+ if (div > 0) coverages.push(fcf > 0 ? fcf / div : 0);
1688
+ }
1689
+ if (coverages.length) {
1690
+ const mc = mean3(coverages);
1691
+ const score = clamp((mc - 0.5) / 2, 0, 1);
1692
+ const health = mc >= 2 ? "healthy" : mc >= 1 ? "tight" : "stressed";
1693
+ return [score, [`Payout discipline: mean FCF/dividend coverage ${mc.toFixed(1)}x (${health})`]];
1694
+ }
1695
+ const roicVals = series.map(yearRoic2).filter((r) => r !== null);
1696
+ const mr = mean3(roicVals);
1697
+ if (mr > wacc * 1.2) return [0.7, ["Payout discipline: retaining FCF in above-WACC business (good reinvestment)"]];
1698
+ if (mr <= wacc) return [0.35, ["Payout discipline: retaining FCF despite below-WACC returns (concerning)"]];
1699
+ return [0.5, ["Payout discipline: no dividend data available (neutral score)"]];
1700
+ }
1701
+ function capitalAllocationScore(annualData, options = {}) {
1702
+ if (annualData.length < 2) {
1703
+ throw new Error("capitalAllocationScore requires at least 2 years of data.");
1704
+ }
1705
+ const wacc = estimateWacc2(annualData, options.wacc);
1706
+ const [vcScore, vcEv] = scoreValueCreation(annualData, wacc);
1707
+ const [fcfScore, fcfEv] = scoreFcfQuality(annualData);
1708
+ const [riScore, riEv] = scoreReinvestmentYield(annualData);
1709
+ const [pdScore, pdEv] = scorePayoutDiscipline(annualData, wacc);
1710
+ const raw = 0.35 * vcScore + 0.25 * fcfScore + 0.25 * riScore + 0.15 * pdScore;
1711
+ const score = Math.round(clamp(raw, 0, 1) * 100);
1712
+ const rating = score >= 75 ? "excellent" : score >= 50 ? "good" : score >= 25 ? "fair" : "poor";
1713
+ return {
1714
+ score,
1715
+ rating,
1716
+ components: {
1717
+ valueCreation: Math.round(vcScore * 1e4) / 1e4,
1718
+ fcfQuality: Math.round(fcfScore * 1e4) / 1e4,
1719
+ reinvestmentYield: Math.round(riScore * 1e4) / 1e4,
1720
+ payoutDiscipline: Math.round(pdScore * 1e4) / 1e4
1721
+ },
1722
+ waccUsed: wacc,
1723
+ yearsAnalyzed: annualData.length,
1724
+ evidence: [...vcEv, ...fcfEv, ...riEv, ...pdEv]
1725
+ };
1726
+ }
1727
+
1334
1728
  exports.affo = affo;
1335
1729
  exports.altmanZScore = altmanZScore;
1336
1730
  exports.annualizeReturn = annualizeReturn;
@@ -1348,6 +1742,7 @@ exports.calmarRatio = calmarRatio;
1348
1742
  exports.capRate = capRate;
1349
1743
  exports.capexToDepreciation = capexToDepreciation;
1350
1744
  exports.capexToRevenue = capexToRevenue;
1745
+ exports.capitalAllocationScore = capitalAllocationScore;
1351
1746
  exports.capitalTurnover = capitalTurnover;
1352
1747
  exports.cashConversionCycle = cashConversionCycle;
1353
1748
  exports.cashRatio = cashRatio;
@@ -1416,6 +1811,7 @@ exports.magicNumber = magicNumber;
1416
1811
  exports.maxDrawdown = maxDrawdown;
1417
1812
  exports.maximumDrawdown = maximumDrawdown;
1418
1813
  exports.mean = mean;
1814
+ exports.moatScore = moatScore;
1419
1815
  exports.montierCScore = montierCScore;
1420
1816
  exports.netDebtToEbitda = netDebtToEbitda;
1421
1817
  exports.netDebtToEquity = netDebtToEquity;