fin-ratios 0.7.3 → 1.0.2

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 = (g5) => {
122
+ const computeEV = (g7) => {
123
123
  const result = dcf2Stage({
124
124
  baseFcf: input.baseFcf,
125
- growthRate: g5,
125
+ growthRate: g7,
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 mean5 = values.reduce((a, b) => a + b, 0) / values.length;
456
- const variance = values.reduce((sum, v) => sum + Math.pow(v - mean5, 2), 0) / (values.length - ddof);
455
+ const mean7 = values.reduce((a, b) => a + b, 0) / values.length;
456
+ const variance = values.reduce((sum, v) => sum + Math.pow(v - mean7, 2), 0) / (values.length - ddof);
457
457
  return Math.sqrt(variance);
458
458
  }
459
459
  function mean(values) {
@@ -1947,8 +1947,8 @@ function fairValueRange(options) {
1947
1947
  if (fy && fy > 0) estimates[`FCF Yield (${(targetYield * 100).toFixed(0)}%)`] = fy;
1948
1948
  }
1949
1949
  if (eps && bvps) {
1950
- const g5 = grahamValue(eps, bvps);
1951
- if (g5 && g5 > 0) estimates["Graham Number"] = g5;
1950
+ const g7 = grahamValue(eps, bvps);
1951
+ if (g7 && g7 > 0) estimates["Graham Number"] = g7;
1952
1952
  }
1953
1953
  if (ebitda && ebitda > 0 && shares && shares > 0) {
1954
1954
  const v = evEbitdaValue(ebitda, totalDebt, cash, shares, evEbitdaMultiple);
@@ -2059,6 +2059,552 @@ function portfolioQuality(holdings, options = {}) {
2059
2059
  };
2060
2060
  }
2061
2061
 
2062
+ // src/utils/valuation-score.ts
2063
+ function _clamp(x, lo, hi) {
2064
+ return Math.max(lo, Math.min(hi, x));
2065
+ }
2066
+ function _lerp(x, x0, x1, y0, y1) {
2067
+ if (x1 === x0) return y0;
2068
+ const t = (x - x0) / (x1 - x0);
2069
+ return y0 + _clamp(t, 0, 1) * (y1 - y0);
2070
+ }
2071
+ function scoreEarningsYield(params, rf) {
2072
+ let ey = null;
2073
+ if (params.earningsYieldPct !== void 0) {
2074
+ ey = params.earningsYieldPct / 100;
2075
+ } else if (params.peRatio !== void 0 && params.peRatio > 0) {
2076
+ ey = 1 / params.peRatio;
2077
+ }
2078
+ if (ey === null) {
2079
+ return [0.5, ["Earnings yield: no P/E or earnings yield data (neutral score)"]];
2080
+ }
2081
+ const excess = ey - rf;
2082
+ const score = _lerp(excess, -0.04, 0.04, 0, 1);
2083
+ const label = excess >= 0.02 ? "attractive" : excess >= 0 ? "slight premium" : "below risk-free";
2084
+ return [score, [
2085
+ `Earnings yield: ${(ey * 100).toFixed(2)}% vs risk-free ${(rf * 100).toFixed(2)}% (spread ${excess >= 0 ? "+" : ""}${(excess * 100).toFixed(2)}%)`,
2086
+ `Earnings yield spread: ${label}`
2087
+ ]];
2088
+ }
2089
+ function scoreFcfYield(params) {
2090
+ let fy = null;
2091
+ if (params.fcfYieldPct !== void 0) {
2092
+ fy = params.fcfYieldPct / 100;
2093
+ } else if (params.pFcf !== void 0 && params.pFcf > 0) {
2094
+ fy = 1 / params.pFcf;
2095
+ }
2096
+ if (fy === null) {
2097
+ return [0.5, ["FCF yield: no P/FCF or FCF yield data (neutral score)"]];
2098
+ }
2099
+ let score;
2100
+ if (fy < 0.02) {
2101
+ score = _lerp(fy, -0.02, 0.02, 0, 0.3);
2102
+ } else if (fy < 0.05) {
2103
+ score = _lerp(fy, 0.02, 0.05, 0.3, 0.7);
2104
+ } else {
2105
+ score = _lerp(fy, 0.05, 0.08, 0.7, 1);
2106
+ }
2107
+ const label = fy >= 0.06 ? "excellent" : fy >= 0.04 ? "good" : fy >= 0.02 ? "modest" : "thin";
2108
+ return [score, [
2109
+ `FCF yield: ${(fy * 100).toFixed(2)}% (${label})`
2110
+ ]];
2111
+ }
2112
+ function scoreEvEbitda(params) {
2113
+ if (params.evEbitda === void 0) {
2114
+ return [0.5, ["EV/EBITDA: not provided (neutral score)"]];
2115
+ }
2116
+ const ev = params.evEbitda;
2117
+ if (ev <= 0) {
2118
+ return [0.2, ["EV/EBITDA: negative or zero (uninvestable or distressed)"]];
2119
+ }
2120
+ let score;
2121
+ if (ev < 12) {
2122
+ score = _lerp(ev, 5, 12, 1, 0.6);
2123
+ } else if (ev < 20) {
2124
+ score = _lerp(ev, 12, 20, 0.6, 0.2);
2125
+ } else {
2126
+ score = _lerp(ev, 20, 30, 0.2, 0.05);
2127
+ }
2128
+ const label = ev < 10 ? "deep value" : ev < 14 ? "reasonable" : ev < 20 ? "full valued" : "expensive";
2129
+ return [score, [
2130
+ `EV/EBITDA: ${ev.toFixed(1)}\xD7 (${label})`
2131
+ ]];
2132
+ }
2133
+ function scorePbRatio(params) {
2134
+ if (params.pbRatio === void 0) {
2135
+ return [0.5, ["P/B ratio: not provided (neutral score)"]];
2136
+ }
2137
+ const pb2 = params.pbRatio;
2138
+ if (pb2 <= 0) {
2139
+ return [0.15, ["P/B ratio: negative book value \u2014 balance sheet impaired"]];
2140
+ }
2141
+ let score;
2142
+ if (pb2 < 2) {
2143
+ score = _lerp(pb2, 0.5, 2, 1, 0.65);
2144
+ } else if (pb2 < 4) {
2145
+ score = _lerp(pb2, 2, 4, 0.65, 0.3);
2146
+ } else {
2147
+ score = _lerp(pb2, 4, 8, 0.3, 0.05);
2148
+ }
2149
+ const label = pb2 < 1.5 ? "near book value" : pb2 < 3 ? "moderate premium" : pb2 < 5 ? "high premium" : "very high premium";
2150
+ return [score, [
2151
+ `P/B ratio: ${pb2.toFixed(2)}\xD7 (${label})`
2152
+ ]];
2153
+ }
2154
+ function scoreDcfUpside(params) {
2155
+ if (params.dcfUpsidePct === void 0) {
2156
+ return [0.5, ["DCF upside: not provided (neutral score)"]];
2157
+ }
2158
+ const u = params.dcfUpsidePct / 100;
2159
+ let score;
2160
+ if (u < 0) {
2161
+ score = _lerp(u, -0.5, 0, 0, 0.3);
2162
+ } else if (u < 0.3) {
2163
+ score = _lerp(u, 0, 0.3, 0.3, 0.75);
2164
+ } else {
2165
+ score = _lerp(u, 0.3, 0.6, 0.75, 1);
2166
+ }
2167
+ const label = u >= 0.3 ? "significant margin of safety" : u >= 0.1 ? "modest upside" : u >= 0 ? "fairly valued" : "trading above intrinsic value";
2168
+ return [score, [
2169
+ `DCF upside: ${(u * 100).toFixed(1)}% (${label})`
2170
+ ]];
2171
+ }
2172
+ function valuationAttractivenessScore(params) {
2173
+ const rf = params.riskFreeRate ?? 0.045;
2174
+ const [eyScore, eyEv] = scoreEarningsYield(params, rf);
2175
+ const [fyScore, fyEv] = scoreFcfYield(params);
2176
+ const [evScore, evEv] = scoreEvEbitda(params);
2177
+ const [pbScore, pbEv] = scorePbRatio(params);
2178
+ const [dcfScore, dcfEv] = scoreDcfUpside(params);
2179
+ const raw = 0.25 * eyScore + 0.25 * fyScore + 0.2 * evScore + 0.15 * pbScore + 0.15 * dcfScore;
2180
+ const score = Math.round(_clamp(raw, 0, 1) * 100);
2181
+ const rating = score >= 65 ? "attractive" : score >= 40 ? "fair" : score >= 20 ? "expensive" : "overvalued";
2182
+ const desc = {
2183
+ attractive: "Multiple signals indicate price is below intrinsic value \u2014 margin of safety present",
2184
+ fair: "Price appears broadly in line with fundamental value",
2185
+ expensive: "Price embeds optimistic assumptions \u2014 limited margin of safety",
2186
+ overvalued: "Price materially exceeds indicated intrinsic value across multiple metrics"
2187
+ };
2188
+ return {
2189
+ score,
2190
+ rating,
2191
+ components: {
2192
+ earningsYield: Math.round(eyScore * 1e4) / 1e4,
2193
+ fcfYield: Math.round(fyScore * 1e4) / 1e4,
2194
+ evEbitda: Math.round(evScore * 1e4) / 1e4,
2195
+ pbRatio: Math.round(pbScore * 1e4) / 1e4,
2196
+ dcfUpside: Math.round(dcfScore * 1e4) / 1e4
2197
+ },
2198
+ riskFreeRate: rf,
2199
+ evidence: [...eyEv, ...fyEv, ...evEv, ...pbEv, ...dcfEv],
2200
+ interpretation: `Score ${score}/100: ${desc[rating]}`
2201
+ };
2202
+ }
2203
+
2204
+ // src/utils/management-score.ts
2205
+ function mean5(xs) {
2206
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
2207
+ }
2208
+ function std4(xs) {
2209
+ if (xs.length < 2) return 0;
2210
+ const m = mean5(xs);
2211
+ return Math.sqrt(xs.reduce((a, x) => a + (x - m) ** 2, 0) / (xs.length - 1));
2212
+ }
2213
+ function cv4(xs) {
2214
+ const m = mean5(xs);
2215
+ return Math.abs(m) > 1e-9 ? std4(xs) / Math.abs(m) : 1;
2216
+ }
2217
+ function olsSlope4(ys) {
2218
+ const n = ys.length;
2219
+ if (n < 2) return 0;
2220
+ const xm = (n - 1) / 2;
2221
+ const ym = mean5(ys);
2222
+ const ssXX = Array.from({ length: n }, (_, i) => (i - xm) ** 2).reduce((a, b) => a + b, 0);
2223
+ const ssXY = Array.from({ length: n }, (_, i) => (i - xm) * ((ys[i] ?? 0) - ym)).reduce((a, b) => a + b, 0);
2224
+ return ssXX ? ssXY / ssXX : 0;
2225
+ }
2226
+ function clamp3(x, lo, hi) {
2227
+ return Math.max(lo, Math.min(hi, x));
2228
+ }
2229
+ function lerp(x, x0, x1, y0, y1) {
2230
+ if (x1 === x0) return y0;
2231
+ return y0 + clamp3((x - x0) / (x1 - x0), 0, 1) * (y1 - y0);
2232
+ }
2233
+ function g5(d, k) {
2234
+ const v = d[k];
2235
+ return typeof v === "number" && isFinite(v) ? v : 0;
2236
+ }
2237
+ function yearRoic3(d) {
2238
+ const ebit = g5(d, "ebit");
2239
+ const ic = g5(d, "totalEquity") + g5(d, "totalDebt") - g5(d, "cash");
2240
+ if (ic <= 0) return null;
2241
+ const ebt = g5(d, "ebt") || ebit - g5(d, "interestExpense");
2242
+ const tax = g5(d, "incomeTaxExpense");
2243
+ const taxRate = ebt > 0 && tax > 0 ? clamp3(tax / ebt, 0, 0.5) : 0.21;
2244
+ return ebit * (1 - taxRate) / ic;
2245
+ }
2246
+ function estimateHurdleRate(series, provided) {
2247
+ if (provided !== void 0) return provided;
2248
+ const d = series[series.length - 1];
2249
+ const equity = g5(d, "totalEquity");
2250
+ const debt = g5(d, "totalDebt");
2251
+ const cash = g5(d, "cash");
2252
+ const totalCap = equity + debt - cash;
2253
+ if (totalCap <= 0) return 0.1;
2254
+ const costEquity = 0.045 + 0.055;
2255
+ const interest = g5(d, "interestExpense");
2256
+ let costDebt = 0.04;
2257
+ if (debt > 0 && interest > 0) {
2258
+ const preTax = clamp3(interest / debt, 0.02, 0.15);
2259
+ const ebt = g5(d, "ebt") || g5(d, "ebit") - interest;
2260
+ const tax = g5(d, "incomeTaxExpense");
2261
+ const taxRate = ebt > 0 && tax > 0 ? clamp3(tax / ebt, 0, 0.4) : 0.21;
2262
+ costDebt = preTax * (1 - taxRate);
2263
+ }
2264
+ const wE = clamp3(equity / totalCap, 0, 1);
2265
+ return clamp3(wE * costEquity + (1 - wE) * costDebt, 0.06, 0.2);
2266
+ }
2267
+ function scoreRoicExcellence(series, hurdle) {
2268
+ const roicVals = series.map(yearRoic3).filter((r) => r !== null);
2269
+ if (!roicVals.length) return [0.35, ["ROIC excellence: insufficient data (neutral score)"]];
2270
+ const meanRoic = mean5(roicVals);
2271
+ const slope = olsSlope4(roicVals);
2272
+ const consistency = clamp3(1 - cv4(roicVals) * 0.5, 0, 1);
2273
+ const level = lerp(meanRoic, 0.05, 0.3, 0, 1);
2274
+ const trendAdj = slope > 0.01 ? 0.08 : slope < -0.01 ? -0.08 : 0;
2275
+ const score = clamp3(0.55 * level + 0.35 * consistency + trendAdj, 0, 1);
2276
+ const aboveHurdle = roicVals.filter((r) => r > hurdle).length;
2277
+ const dir = slope > 0.01 ? "improving" : slope < -0.01 ? "declining" : "stable";
2278
+ return [score, [
2279
+ `ROIC excellence: mean ${(meanRoic * 100).toFixed(1)}% (hurdle ${(hurdle * 100).toFixed(1)}%, ${aboveHurdle}/${roicVals.length} years above)`,
2280
+ `ROIC trend: ${dir} (OLS ${(slope * 100).toFixed(2)}%/yr)`
2281
+ ]];
2282
+ }
2283
+ function scoreMarginStability(series) {
2284
+ const margins = series.filter((d) => g5(d, "revenue") > 0 && g5(d, "ebit") !== 0).map((d) => g5(d, "ebit") / g5(d, "revenue"));
2285
+ if (margins.length < 2) return [0.4, ["Margin stability: insufficient revenue/EBIT data (neutral score)"]];
2286
+ const meanMargin = mean5(margins);
2287
+ const slope = olsSlope4(margins);
2288
+ const consistency = clamp3(1 - cv4(margins) * 1, 0, 1);
2289
+ const level = lerp(meanMargin, 0, 0.3, 0, 1);
2290
+ const trendAdj = slope > 5e-3 ? 0.08 : slope < -5e-3 ? -0.08 : 0;
2291
+ const score = clamp3(0.5 * level + 0.4 * consistency + trendAdj, 0, 1);
2292
+ const dir = slope > 5e-3 ? "expanding" : slope < -5e-3 ? "contracting" : "stable";
2293
+ const quality = meanMargin >= 0.2 ? "high" : meanMargin >= 0.1 ? "moderate" : "thin";
2294
+ return [score, [
2295
+ `Operating margin: mean ${(meanMargin * 100).toFixed(1)}% (${quality} quality, CV ${cv4(margins).toFixed(3)})`,
2296
+ `Margin trend: ${dir} (OLS ${(slope * 100).toFixed(3)}%/yr)`
2297
+ ]];
2298
+ }
2299
+ function scoreShareholderOrientation(series) {
2300
+ const shares = series.map((d) => g5(d, "sharesOutstanding")).filter((s) => s > 0);
2301
+ if (shares.length < 2) return [0.5, ["Shareholder orientation: share count data unavailable (neutral score)"]];
2302
+ const meanShares = mean5(shares);
2303
+ const slope = olsSlope4(shares);
2304
+ const slopePct = meanShares > 0 ? slope / meanShares : 0;
2305
+ const score = clamp3(0.5 - slopePct * 5, 0, 1);
2306
+ const pctChange = (shares[shares.length - 1] - shares[0]) / shares[0];
2307
+ const dir = slopePct < -0.01 ? "declining (buybacks)" : slopePct > 0.01 ? "growing (dilution)" : "roughly flat";
2308
+ return [score, [
2309
+ `Share count: ${dir} (${pctChange >= 0 ? "+" : ""}${(pctChange * 100).toFixed(1)}% over period)`,
2310
+ slopePct < -5e-3 ? "Management is reducing share count \u2014 shareholder friendly" : slopePct > 0.02 ? "Material share dilution detected \u2014 value transfer risk" : "Share count broadly stable"
2311
+ ]];
2312
+ }
2313
+ function scoreRevenueExecution(series) {
2314
+ const growthRates = [];
2315
+ for (let i = 1; i < series.length; i++) {
2316
+ const prevRev = g5(series[i - 1], "revenue");
2317
+ const currRev = g5(series[i], "revenue");
2318
+ if (prevRev > 0) growthRates.push((currRev - prevRev) / prevRev);
2319
+ }
2320
+ if (!growthRates.length) return [0.4, ["Revenue execution: insufficient revenue data (neutral score)"]];
2321
+ const meanGrowth = mean5(growthRates);
2322
+ const level = lerp(meanGrowth, -0.05, 0.2, 0, 1);
2323
+ const consistency = clamp3(1 - cv4(growthRates) * 0.4, 0, 1);
2324
+ const score = clamp3(0.65 * level + 0.35 * consistency, 0, 1);
2325
+ const posYears = growthRates.filter((r) => r > 0).length;
2326
+ const quality = meanGrowth >= 0.1 ? "strong" : meanGrowth >= 0.04 ? "adequate" : meanGrowth >= 0 ? "sluggish" : "declining";
2327
+ return [score, [
2328
+ `Revenue growth: mean ${(meanGrowth * 100).toFixed(1)}%/yr (${quality}, ${posYears}/${growthRates.length} years positive)`
2329
+ ]];
2330
+ }
2331
+ function managementQualityScoreFromSeries(annualData, hurdleRate) {
2332
+ if (annualData.length < 3) {
2333
+ throw new Error("managementQualityScoreFromSeries requires at least 3 years of data.");
2334
+ }
2335
+ const hurdle = estimateHurdleRate(annualData, hurdleRate);
2336
+ const [roicScore, roicEv] = scoreRoicExcellence(annualData, hurdle);
2337
+ const [marginScore, marginEv] = scoreMarginStability(annualData);
2338
+ const [soScore, soEv] = scoreShareholderOrientation(annualData);
2339
+ const [revScore, revEv] = scoreRevenueExecution(annualData);
2340
+ const raw = 0.35 * roicScore + 0.25 * marginScore + 0.25 * soScore + 0.15 * revScore;
2341
+ const score = Math.round(clamp3(raw, 0, 1) * 100);
2342
+ const rating = score >= 75 ? "excellent" : score >= 50 ? "good" : score >= 25 ? "fair" : "poor";
2343
+ const desc = {
2344
+ excellent: "Management consistently earns high ROIC, protects margins, and treats shareholders well",
2345
+ good: "Solid operational track record with most quality signals positive",
2346
+ fair: "Mixed signals \u2014 some areas of strength but meaningful shortfalls elsewhere",
2347
+ poor: "Weak returns, margin deterioration, or material shareholder dilution detected"
2348
+ };
2349
+ return {
2350
+ score,
2351
+ rating,
2352
+ components: {
2353
+ roicExcellence: Math.round(roicScore * 1e4) / 1e4,
2354
+ marginStability: Math.round(marginScore * 1e4) / 1e4,
2355
+ shareholderOrientation: Math.round(soScore * 1e4) / 1e4,
2356
+ revenueExecution: Math.round(revScore * 1e4) / 1e4
2357
+ },
2358
+ hurdleRateUsed: hurdle,
2359
+ yearsAnalyzed: annualData.length,
2360
+ evidence: [...roicEv, ...marginEv, ...soEv, ...revEv],
2361
+ interpretation: `Score ${score}/100: ${desc[rating]}`
2362
+ };
2363
+ }
2364
+
2365
+ // src/utils/dividend-score.ts
2366
+ function mean6(xs) {
2367
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
2368
+ }
2369
+ function clamp4(x, lo, hi) {
2370
+ return Math.max(lo, Math.min(hi, x));
2371
+ }
2372
+ function g6(d, k) {
2373
+ const v = d[k];
2374
+ return typeof v === "number" && isFinite(v) ? v : 0;
2375
+ }
2376
+ function isDividendPayer(series) {
2377
+ return series.some((d) => {
2378
+ const div = d.dividendsPaid;
2379
+ return typeof div === "number" && isFinite(div) && div > 0;
2380
+ });
2381
+ }
2382
+ function scoreFcfPayout(series) {
2383
+ const ratios = [];
2384
+ for (const d of series) {
2385
+ const div = g6(d, "dividendsPaid");
2386
+ if (div <= 0) continue;
2387
+ const cfo = g6(d, "operatingCashFlow");
2388
+ const capex = g6(d, "capex");
2389
+ const fcf = cfo - capex;
2390
+ if (fcf !== 0) ratios.push(div / fcf);
2391
+ }
2392
+ if (!ratios.length) return [0.5, ["FCF payout ratio: no dividend/FCF overlap (neutral score)"]];
2393
+ const avg = mean6(ratios);
2394
+ let score;
2395
+ if (avg < 0.4) score = 1;
2396
+ else if (avg < 0.6) score = 0.75;
2397
+ else if (avg < 0.8) score = 0.45;
2398
+ else if (avg <= 1) score = 0.15;
2399
+ else score = 0;
2400
+ const quality = avg < 0.4 ? "very well covered" : avg < 0.6 ? "adequately covered" : avg < 0.8 ? "tight" : avg <= 1 ? "stressed" : "uncovered by FCF";
2401
+ return [score, [
2402
+ `FCF payout ratio: mean ${(avg * 100).toFixed(0)}% (${quality})`
2403
+ ]];
2404
+ }
2405
+ function scoreEarningsPayout(series) {
2406
+ const ratios = [];
2407
+ for (const d of series) {
2408
+ const div = g6(d, "dividendsPaid");
2409
+ const ni = g6(d, "netIncome");
2410
+ if (div > 0 && ni > 0) ratios.push(div / ni);
2411
+ }
2412
+ if (!ratios.length) return [0.5, ["Earnings payout ratio: no dividend/earnings overlap (neutral score)"]];
2413
+ const avg = mean6(ratios);
2414
+ let score;
2415
+ if (avg < 0.35) score = 1;
2416
+ else if (avg < 0.55) score = 0.75;
2417
+ else if (avg < 0.75) score = 0.45;
2418
+ else if (avg <= 1) score = 0.2;
2419
+ else score = 0;
2420
+ const quality = avg < 0.35 ? "conservative" : avg < 0.55 ? "moderate" : avg < 0.75 ? "elevated" : avg <= 1 ? "high" : "exceeds earnings";
2421
+ return [score, [
2422
+ `Earnings payout ratio: mean ${(avg * 100).toFixed(0)}% (${quality})`
2423
+ ]];
2424
+ }
2425
+ function scoreBalanceSheet(series) {
2426
+ const d = series[series.length - 1];
2427
+ const totalDebt = g6(d, "totalDebt");
2428
+ const cash = g6(d, "cash");
2429
+ const netDebt = totalDebt - cash;
2430
+ const ebit = g6(d, "ebit");
2431
+ const dep = g6(d, "depreciation");
2432
+ const ebitda = ebit + dep;
2433
+ if (ebitda <= 0) {
2434
+ return [0.3, ["Balance sheet strength: EBITDA unavailable or negative (below-neutral score)"]];
2435
+ }
2436
+ const ratio = netDebt / ebitda;
2437
+ let score;
2438
+ if (ratio < 0) score = 1;
2439
+ else if (ratio < 1) score = 0.85;
2440
+ else if (ratio < 2) score = 0.65;
2441
+ else if (ratio < 3) score = 0.4;
2442
+ else if (ratio < 4) score = 0.2;
2443
+ else score = 0.05;
2444
+ const quality = ratio < 0 ? "net cash position" : ratio < 1 ? "very low leverage" : ratio < 2 ? "modest leverage" : ratio < 3 ? "moderate leverage" : ratio < 4 ? "elevated leverage" : "highly levered";
2445
+ return [score, [
2446
+ `Net debt / EBITDA: ${ratio.toFixed(2)}\xD7 (${quality})`
2447
+ ]];
2448
+ }
2449
+ function scoreDividendGrowthTrack(series) {
2450
+ const divs = series.map((d) => g6(d, "dividendsPaid")).filter((d) => d > 0);
2451
+ if (divs.length < 2) {
2452
+ return [0.15, ["Dividend growth track: insufficient history (below-neutral score)"]];
2453
+ }
2454
+ let consecutive = 0;
2455
+ for (let i = 1; i < divs.length; i++) {
2456
+ if ((divs[i] ?? 0) >= (divs[i - 1] ?? 0)) {
2457
+ consecutive++;
2458
+ } else {
2459
+ consecutive = 0;
2460
+ }
2461
+ }
2462
+ let score;
2463
+ if (consecutive >= 10) score = 1;
2464
+ else if (consecutive >= 7) score = 0.75;
2465
+ else if (consecutive >= 4) score = 0.55;
2466
+ else if (consecutive >= 2) score = 0.35;
2467
+ else score = 0.15;
2468
+ const label = consecutive >= 10 ? "dividend aristocrat-level streak" : consecutive >= 7 ? "strong track record" : consecutive >= 4 ? "solid track record" : consecutive >= 2 ? "limited track record" : "recent cut or minimal history";
2469
+ return [score, [
2470
+ `Dividend growth track: ${consecutive} consecutive non-declining year(s) (${label})`
2471
+ ]];
2472
+ }
2473
+ function dividendSafetyScoreFromSeries(annualData) {
2474
+ const payer = isDividendPayer(annualData);
2475
+ if (!payer) {
2476
+ return {
2477
+ score: 50,
2478
+ rating: "non-payer",
2479
+ isDividendPayer: false,
2480
+ yearsAnalyzed: annualData.length,
2481
+ components: {
2482
+ fcfPayoutRatio: 0.5,
2483
+ earningsPayoutRatio: 0.5,
2484
+ balanceSheetStrength: 0.5,
2485
+ dividendGrowthTrack: 0.5
2486
+ },
2487
+ evidence: ["No dividends detected across the provided data series"],
2488
+ interpretation: "Score 50/100: Company does not pay a dividend \u2014 score is not meaningful"
2489
+ };
2490
+ }
2491
+ const [fcfScore, fcfEv] = scoreFcfPayout(annualData);
2492
+ const [epScore, epEv] = scoreEarningsPayout(annualData);
2493
+ const [bsScore, bsEv] = scoreBalanceSheet(annualData);
2494
+ const [dgtScore, dgtEv] = scoreDividendGrowthTrack(annualData);
2495
+ const raw = 0.35 * fcfScore + 0.25 * epScore + 0.25 * bsScore + 0.15 * dgtScore;
2496
+ const score = Math.round(clamp4(raw, 0, 1) * 100);
2497
+ const rating = score >= 70 ? "safe" : score >= 45 ? "adequate" : score >= 20 ? "risky" : "danger";
2498
+ const desc = {
2499
+ safe: "Dividend well-covered by cash flows with a strong balance sheet",
2500
+ adequate: "Dividend appears sustainable but headroom is limited in some areas",
2501
+ risky: "Coverage is strained \u2014 dividend could be at risk in a downturn",
2502
+ danger: "Dividend likely unsustainable; cut probability is high"
2503
+ };
2504
+ return {
2505
+ score,
2506
+ rating,
2507
+ components: {
2508
+ fcfPayoutRatio: Math.round(fcfScore * 1e4) / 1e4,
2509
+ earningsPayoutRatio: Math.round(epScore * 1e4) / 1e4,
2510
+ balanceSheetStrength: Math.round(bsScore * 1e4) / 1e4,
2511
+ dividendGrowthTrack: Math.round(dgtScore * 1e4) / 1e4
2512
+ },
2513
+ isDividendPayer: true,
2514
+ yearsAnalyzed: annualData.length,
2515
+ evidence: [...fcfEv, ...epEv, ...bsEv, ...dgtEv],
2516
+ interpretation: `Score ${score}/100: ${desc[rating]}`
2517
+ };
2518
+ }
2519
+
2520
+ // src/utils/investment-score.ts
2521
+ function clamp5(x, lo, hi) {
2522
+ return Math.max(lo, Math.min(hi, x));
2523
+ }
2524
+ function gradeFromScore(score) {
2525
+ if (score >= 90) return "A+";
2526
+ if (score >= 80) return "A";
2527
+ if (score >= 70) return "B+";
2528
+ if (score >= 60) return "B";
2529
+ if (score >= 45) return "C";
2530
+ if (score >= 25) return "D";
2531
+ return "F";
2532
+ }
2533
+ function convictionFromScore(score) {
2534
+ if (score >= 80) return "strongBuy";
2535
+ if (score >= 65) return "buy";
2536
+ if (score >= 45) return "hold";
2537
+ if (score >= 30) return "sell";
2538
+ return "strongSell";
2539
+ }
2540
+ function computeWeightedScore(inputs) {
2541
+ const hasValuation = inputs.valuationScore !== void 0;
2542
+ if (hasValuation) {
2543
+ return 0.25 * inputs.moatScore + 0.2 * inputs.capitalAllocationScore + 0.2 * inputs.earningsQualityScore + 0.15 * inputs.managementScore + 0.2 * inputs.valuationScore;
2544
+ }
2545
+ return 0.3125 * inputs.moatScore + 0.25 * inputs.capitalAllocationScore + 0.25 * inputs.earningsQualityScore + 0.1875 * inputs.managementScore;
2546
+ }
2547
+ function investmentScoreFromScores(inputs) {
2548
+ const rawScore = computeWeightedScore(inputs);
2549
+ const score = Math.round(clamp5(rawScore, 0, 100));
2550
+ const grade = gradeFromScore(score);
2551
+ const conviction = convictionFromScore(score);
2552
+ const hasVal = inputs.valuationScore !== void 0;
2553
+ const evidence = [
2554
+ `Economic Moat: ${inputs.moatScore}/100 (weight ${hasVal ? "25" : "31"}%)`,
2555
+ `Capital Allocation: ${inputs.capitalAllocationScore}/100 (weight ${hasVal ? "20" : "25"}%)`,
2556
+ `Earnings Quality: ${inputs.earningsQualityScore}/100 (weight ${hasVal ? "20" : "25"}%)`,
2557
+ `Management Quality: ${inputs.managementScore}/100 (weight ${hasVal ? "15" : "19"}%)`
2558
+ ];
2559
+ if (hasVal) {
2560
+ evidence.push(`Valuation: ${inputs.valuationScore}/100 (weight 20%)`);
2561
+ } else {
2562
+ evidence.push("Valuation: not provided (weight redistributed)");
2563
+ }
2564
+ const convictionLabel = {
2565
+ strongBuy: "Strong Buy \u2014 exceptional quality at an attractive price",
2566
+ buy: "Buy \u2014 above-average quality with reasonable valuation",
2567
+ hold: "Hold \u2014 average quality or price adequately reflects value",
2568
+ sell: "Sell \u2014 below-average quality or price significantly exceeds value",
2569
+ strongSell: "Strong Sell \u2014 poor quality and/or materially overpriced"
2570
+ };
2571
+ return {
2572
+ score,
2573
+ grade,
2574
+ conviction,
2575
+ components: {
2576
+ moat: inputs.moatScore,
2577
+ capitalAllocation: inputs.capitalAllocationScore,
2578
+ earningsQuality: inputs.earningsQualityScore,
2579
+ management: inputs.managementScore,
2580
+ valuation: inputs.valuationScore ?? null
2581
+ },
2582
+ evidence,
2583
+ interpretation: `Grade ${grade} | Score ${score}/100 | ${convictionLabel[conviction]}`
2584
+ };
2585
+ }
2586
+ function investmentScoreFromSeries(annualData, valuationParams, wacc) {
2587
+ if (annualData.length < 3) {
2588
+ throw new Error("investmentScoreFromSeries requires at least 3 years of data.");
2589
+ }
2590
+ const waccOpt = wacc !== void 0 ? { wacc } : {};
2591
+ const moatResult = moatScore(annualData, waccOpt);
2592
+ const caResult = capitalAllocationScore(annualData, waccOpt);
2593
+ const eqResult = earningsQualityScore(annualData);
2594
+ const mgmtResult = managementQualityScoreFromSeries(annualData, wacc);
2595
+ const inputs = {
2596
+ moatScore: moatResult.score,
2597
+ capitalAllocationScore: caResult.score,
2598
+ earningsQualityScore: eqResult.score,
2599
+ managementScore: mgmtResult.score
2600
+ };
2601
+ if (valuationParams !== void 0) {
2602
+ const valResult = valuationAttractivenessScore(valuationParams);
2603
+ inputs.valuationScore = valResult.score;
2604
+ }
2605
+ return investmentScoreFromScores(inputs);
2606
+ }
2607
+
2062
2608
  exports.affo = affo;
2063
2609
  exports.altmanZScore = altmanZScore;
2064
2610
  exports.annualizeReturn = annualizeReturn;
@@ -2097,6 +2643,7 @@ exports.debtToEquity = debtToEquity;
2097
2643
  exports.defensiveIntervalRatio = defensiveIntervalRatio;
2098
2644
  exports.dio = dio;
2099
2645
  exports.dividendGrowthRate = dividendGrowthRate;
2646
+ exports.dividendSafetyScoreFromSeries = dividendSafetyScoreFromSeries;
2100
2647
  exports.downsideCaptureRatio = downsideCaptureRatio;
2101
2648
  exports.dpo = dpo;
2102
2649
  exports.dso = dso;
@@ -2137,6 +2684,8 @@ exports.interestCoverageRatio = interestCoverageRatio;
2137
2684
  exports.invalidateCache = invalidate;
2138
2685
  exports.inventoryTurnover = inventoryTurnover;
2139
2686
  exports.investedCapital = investedCapital;
2687
+ exports.investmentScoreFromScores = investmentScoreFromScores;
2688
+ exports.investmentScoreFromSeries = investmentScoreFromSeries;
2140
2689
  exports.jensensAlpha = jensensAlpha;
2141
2690
  exports.leveredFcf = leveredFcf;
2142
2691
  exports.loanToDepositRatio = loanToDepositRatio;
@@ -2144,6 +2693,7 @@ exports.lossRatio = lossRatio;
2144
2693
  exports.ltvCacRatio = ltvCacRatio;
2145
2694
  exports.magicFormula = magicFormula;
2146
2695
  exports.magicNumber = magicNumber;
2696
+ exports.managementQualityScoreFromSeries = managementQualityScoreFromSeries;
2147
2697
  exports.maxDrawdown = maxDrawdown;
2148
2698
  exports.maximumDrawdown = maximumDrawdown;
2149
2699
  exports.mean = mean;
@@ -2211,6 +2761,5 @@ exports.ulcerIndex = ulcerIndex;
2211
2761
  exports.underwritingProfitMargin = underwritingProfitMargin;
2212
2762
  exports.unleveredFcf = unleveredFcf;
2213
2763
  exports.upsideCaptureRatio = upsideCaptureRatio;
2764
+ exports.valuationAttractivenessScore = valuationAttractivenessScore;
2214
2765
  exports.workingCapitalTurnover = workingCapitalTurnover;
2215
- //# sourceMappingURL=index.cjs.map
2216
- //# sourceMappingURL=index.cjs.map