fin-ratios 0.3.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 +173 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +62 -1
- package/dist/index.d.ts +62 -1
- package/dist/index.js +173 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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) {
|
|
@@ -1557,6 +1557,174 @@ function moatScore(annualData, options = {}) {
|
|
|
1557
1557
|
};
|
|
1558
1558
|
}
|
|
1559
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
|
+
|
|
1560
1728
|
exports.affo = affo;
|
|
1561
1729
|
exports.altmanZScore = altmanZScore;
|
|
1562
1730
|
exports.annualizeReturn = annualizeReturn;
|
|
@@ -1574,6 +1742,7 @@ exports.calmarRatio = calmarRatio;
|
|
|
1574
1742
|
exports.capRate = capRate;
|
|
1575
1743
|
exports.capexToDepreciation = capexToDepreciation;
|
|
1576
1744
|
exports.capexToRevenue = capexToRevenue;
|
|
1745
|
+
exports.capitalAllocationScore = capitalAllocationScore;
|
|
1577
1746
|
exports.capitalTurnover = capitalTurnover;
|
|
1578
1747
|
exports.cashConversionCycle = cashConversionCycle;
|
|
1579
1748
|
exports.cashRatio = cashRatio;
|