fin-ratios 0.2.0 → 0.3.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 +231 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +88 -1
- package/dist/index.d.ts +88 -1
- package/dist/index.js +231 -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 = (g3) => {
|
|
123
123
|
const result = dcf2Stage({
|
|
124
124
|
baseFcf: input.baseFcf,
|
|
125
|
-
growthRate:
|
|
125
|
+
growthRate: g3,
|
|
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 mean3 = values.reduce((a, b) => a + b, 0) / values.length;
|
|
456
|
+
const variance = values.reduce((sum, v) => sum + Math.pow(v - mean3, 2), 0) / (values.length - ddof);
|
|
457
457
|
return Math.sqrt(variance);
|
|
458
458
|
}
|
|
459
459
|
function mean(values) {
|
|
@@ -1331,6 +1331,232 @@ 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
|
+
|
|
1334
1560
|
exports.affo = affo;
|
|
1335
1561
|
exports.altmanZScore = altmanZScore;
|
|
1336
1562
|
exports.annualizeReturn = annualizeReturn;
|
|
@@ -1416,6 +1642,7 @@ exports.magicNumber = magicNumber;
|
|
|
1416
1642
|
exports.maxDrawdown = maxDrawdown;
|
|
1417
1643
|
exports.maximumDrawdown = maximumDrawdown;
|
|
1418
1644
|
exports.mean = mean;
|
|
1645
|
+
exports.moatScore = moatScore;
|
|
1419
1646
|
exports.montierCScore = montierCScore;
|
|
1420
1647
|
exports.netDebtToEbitda = netDebtToEbitda;
|
|
1421
1648
|
exports.netDebtToEquity = netDebtToEquity;
|