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 +400 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +149 -1
- package/dist/index.d.ts +149 -1
- package/dist/index.js +399 -5
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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 = (
|
|
122
|
+
const computeEV = (g4) => {
|
|
123
123
|
const result = dcf2Stage({
|
|
124
124
|
baseFcf: input.baseFcf,
|
|
125
|
-
growthRate:
|
|
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
|
|
456
|
-
const variance = values.reduce((sum, v) => sum + Math.pow(v -
|
|
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;
|