Files
Neon-Desk/lib/server/financials/ratios.ts
francy51 24aa8e33d4 Consolidate metric definitions with Rust JSON as single source of truth
- Add core.computed.json with 32 ratio definitions (filing + market derived)
- Add Rust types for ComputedDefinition and ComputationSpec
- Create generate-taxonomy.ts to generate TypeScript from Rust JSON
- Generate lib/generated/ (gitignored) with surfaces, computed, kpis
- Update financial-metrics.ts to use generated definitions
- Add build-time generation via 'bun run generate'
- Add taxonomy architecture documentation

Two-phase ratio computation:
- Filing-derived: margins, returns, per-share, growth (Rust computes)
- Market-derived: valuation ratios (TypeScript computes with price data)

All 32 ratios defined in core.computed.json:
- Margins: gross, operating, ebitda, net, fcf
- Returns: roa, roe, roic, roce
- Financial health: debt_to_equity, net_debt_to_ebitda, cash_to_debt, current_ratio
- Per-share: revenue, fcf, book_value
- Growth: yoy metrics + 3y/5y cagr
- Valuation: market_cap, ev, p/e, p/fcf, p/b, ev/sales, ev/ebitda, ev/fcf
2026-03-15 15:22:51 -04:00

321 lines
14 KiB
TypeScript

import type {
FinancialCadence,
FinancialStatementPeriod,
RatioRow,
StandardizedFinancialRow
} from '@/lib/types';
import {
RATIO_CATEGORIES,
RATIO_DEFINITIONS
} from '@/lib/financial-metrics';
type StatementRowMap = {
income: StandardizedFinancialRow[];
balance: StandardizedFinancialRow[];
cashFlow: StandardizedFinancialRow[];
};
function valueFor(row: StandardizedFinancialRow | undefined, periodId: string) {
return row?.values[periodId] ?? null;
}
function divideValues(left: number | null, right: number | null) {
if (left === null || right === null || right === 0) {
return null;
}
return left / right;
}
function averageValues(left: number | null, right: number | null) {
if (left === null || right === null) {
return null;
}
return (left + right) / 2;
}
function sumValues(values: Array<number | null>) {
if (values.some((value) => value === null)) {
return null;
}
return values.reduce<number>((sum, value) => sum + (value ?? 0), 0);
}
function subtractValues(left: number | null, right: number | null) {
if (left === null || right === null) {
return null;
}
return left - right;
}
function buildRowMap(rows: StatementRowMap) {
return new Map<string, StandardizedFinancialRow>([
...rows.income.map((row) => [row.key, row] as const),
...rows.balance.map((row) => [row.key, row] as const),
...rows.cashFlow.map((row) => [row.key, row] as const)
]);
}
function collectSourceRowData(rowsByKey: Map<string, StandardizedFinancialRow>, keys: string[]) {
const sourceConcepts = new Set<string>();
const sourceRowKeys = new Set<string>();
const sourceFactIds = new Set<number>();
let hasDimensions = false;
for (const key of keys) {
const row = rowsByKey.get(key);
if (!row) {
continue;
}
hasDimensions = hasDimensions || row.hasDimensions;
for (const concept of row.sourceConcepts) {
sourceConcepts.add(concept);
}
for (const sourceRowKey of row.sourceRowKeys) {
sourceRowKeys.add(sourceRowKey);
}
for (const sourceFactId of row.sourceFactIds) {
sourceFactIds.add(sourceFactId);
}
}
return {
sourceConcepts: [...sourceConcepts].sort((left, right) => left.localeCompare(right)),
sourceRowKeys: [...sourceRowKeys].sort((left, right) => left.localeCompare(right)),
sourceFactIds: [...sourceFactIds].sort((left, right) => left - right),
hasDimensions
};
}
function previousPeriodId(periods: FinancialStatementPeriod[], periodId: string) {
const index = periods.findIndex((period) => period.id === periodId);
return index > 0 ? periods[index - 1]?.id ?? null : null;
}
function cagr(current: number | null, previous: number | null, years: number) {
if (current === null || previous === null || previous <= 0 || years <= 0) {
return null;
}
return Math.pow(current / previous, 1 / years) - 1;
}
export function buildRatioRows(input: {
periods: FinancialStatementPeriod[];
cadence: FinancialCadence;
rows: StatementRowMap;
pricesByPeriodId: Record<string, number | null>;
}) {
const periods = [...input.periods].sort((left, right) => {
return Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate);
});
const rowsByKey = buildRowMap(input.rows);
const valuesByKey = new Map<string, Record<string, number | null>>();
const setValue = (key: string, periodId: string, value: number | null) => {
const existing = valuesByKey.get(key);
if (existing) {
existing[periodId] = value;
} else {
valuesByKey.set(key, { [periodId]: value });
}
};
for (const period of periods) {
const periodId = period.id;
const priorId = previousPeriodId(periods, periodId);
const revenue = valueFor(rowsByKey.get('revenue'), periodId);
const grossProfit = valueFor(rowsByKey.get('gross_profit'), periodId);
const operatingIncome = valueFor(rowsByKey.get('operating_income'), periodId);
const ebitda = valueFor(rowsByKey.get('ebitda'), periodId);
const netIncome = valueFor(rowsByKey.get('net_income'), periodId);
const freeCashFlow = valueFor(rowsByKey.get('free_cash_flow'), periodId);
const totalAssets = valueFor(rowsByKey.get('total_assets'), periodId);
const priorAssets = priorId ? valueFor(rowsByKey.get('total_assets'), priorId) : null;
const totalEquity = valueFor(rowsByKey.get('total_equity'), periodId);
const priorEquity = priorId ? valueFor(rowsByKey.get('total_equity'), priorId) : null;
const totalDebt = valueFor(rowsByKey.get('total_debt'), periodId);
const cash = valueFor(rowsByKey.get('cash_and_equivalents'), periodId);
const shortTermInvestments = valueFor(rowsByKey.get('short_term_investments'), periodId);
const currentAssets = valueFor(rowsByKey.get('current_assets'), periodId);
const currentLiabilities = valueFor(rowsByKey.get('current_liabilities'), periodId);
const dilutedShares = valueFor(rowsByKey.get('diluted_shares'), periodId);
const dilutedEps = valueFor(rowsByKey.get('diluted_eps'), periodId);
const effectiveTaxRate = valueFor(rowsByKey.get('effective_tax_rate'), periodId);
const pretaxIncome = valueFor(rowsByKey.get('pretax_income'), periodId);
const incomeTaxExpense = valueFor(rowsByKey.get('income_tax_expense'), periodId);
const priorRevenue = priorId ? valueFor(rowsByKey.get('revenue'), priorId) : null;
const priorNetIncome = priorId ? valueFor(rowsByKey.get('net_income'), priorId) : null;
const priorDilutedEps = priorId ? valueFor(rowsByKey.get('diluted_eps'), priorId) : null;
const priorFcf = priorId ? valueFor(rowsByKey.get('free_cash_flow'), priorId) : null;
const price = input.pricesByPeriodId[periodId] ?? null;
const fallbackTaxRate = divideValues(incomeTaxExpense, pretaxIncome);
const nopat = operatingIncome === null
? null
: (effectiveTaxRate ?? fallbackTaxRate) === null
? null
: operatingIncome * (1 - ((effectiveTaxRate ?? fallbackTaxRate) ?? 0));
const investedCapital = subtractValues(sumValues([totalDebt, totalEquity]), sumValues([cash, shortTermInvestments]));
const priorInvestedCapital = priorId
? subtractValues(
sumValues([
valueFor(rowsByKey.get('total_debt'), priorId),
valueFor(rowsByKey.get('total_equity'), priorId)
]),
sumValues([
valueFor(rowsByKey.get('cash_and_equivalents'), priorId),
valueFor(rowsByKey.get('short_term_investments'), priorId)
])
)
: null;
const averageInvestedCapital = averageValues(investedCapital, priorInvestedCapital);
const capitalEmployed = subtractValues(totalAssets, currentLiabilities);
const priorCapitalEmployed = priorId
? subtractValues(valueFor(rowsByKey.get('total_assets'), priorId), valueFor(rowsByKey.get('current_liabilities'), priorId))
: null;
const averageCapitalEmployed = averageValues(capitalEmployed, priorCapitalEmployed);
const marketCap = price === null || dilutedShares === null ? null : price * dilutedShares;
const enterpriseValue = marketCap === null ? null : subtractValues(sumValues([marketCap, totalDebt]), sumValues([cash, shortTermInvestments]));
setValue('gross_margin', periodId, divideValues(grossProfit, revenue));
setValue('operating_margin', periodId, divideValues(operatingIncome, revenue));
setValue('ebitda_margin', periodId, divideValues(ebitda, revenue));
setValue('net_margin', periodId, divideValues(netIncome, revenue));
setValue('fcf_margin', periodId, divideValues(freeCashFlow, revenue));
setValue('roa', periodId, divideValues(netIncome, averageValues(totalAssets, priorAssets)));
setValue('roe', periodId, divideValues(netIncome, averageValues(totalEquity, priorEquity)));
setValue('roic', periodId, divideValues(nopat, averageInvestedCapital));
setValue('roce', periodId, divideValues(operatingIncome, averageCapitalEmployed));
setValue('debt_to_equity', periodId, divideValues(totalDebt, totalEquity));
setValue('net_debt_to_ebitda', periodId, divideValues(subtractValues(totalDebt, sumValues([cash, shortTermInvestments])), ebitda));
setValue('cash_to_debt', periodId, divideValues(sumValues([cash, shortTermInvestments]), totalDebt));
setValue('current_ratio', periodId, divideValues(currentAssets, currentLiabilities));
setValue('revenue_per_share', periodId, divideValues(revenue, dilutedShares));
setValue('fcf_per_share', periodId, divideValues(freeCashFlow, dilutedShares));
setValue('book_value_per_share', periodId, divideValues(totalEquity, dilutedShares));
setValue('revenue_yoy', periodId, priorId ? divideValues(subtractValues(revenue, priorRevenue), priorRevenue) : null);
setValue('net_income_yoy', periodId, priorId ? divideValues(subtractValues(netIncome, priorNetIncome), priorNetIncome) : null);
setValue('eps_yoy', periodId, priorId ? divideValues(subtractValues(dilutedEps, priorDilutedEps), priorDilutedEps) : null);
setValue('fcf_yoy', periodId, priorId ? divideValues(subtractValues(freeCashFlow, priorFcf), priorFcf) : null);
setValue('market_cap', periodId, marketCap);
setValue('enterprise_value', periodId, enterpriseValue);
setValue('price_to_earnings', periodId, divideValues(price, dilutedEps));
setValue('price_to_fcf', periodId, divideValues(marketCap, freeCashFlow));
setValue('price_to_book', periodId, divideValues(marketCap, totalEquity));
setValue('ev_to_sales', periodId, divideValues(enterpriseValue, revenue));
setValue('ev_to_ebitda', periodId, divideValues(enterpriseValue, ebitda));
setValue('ev_to_fcf', periodId, divideValues(enterpriseValue, freeCashFlow));
}
if (input.cadence === 'annual') {
for (let index = 0; index < periods.length; index += 1) {
const period = periods[index];
const periodId = period.id;
const revenue = valueFor(rowsByKey.get('revenue'), periodId);
const eps = valueFor(rowsByKey.get('diluted_eps'), periodId);
setValue('3y_revenue_cagr', periodId, index >= 3 ? cagr(revenue, valueFor(rowsByKey.get('revenue'), periods[index - 3]?.id ?? ''), 3) : null);
setValue('5y_revenue_cagr', periodId, index >= 5 ? cagr(revenue, valueFor(rowsByKey.get('revenue'), periods[index - 5]?.id ?? ''), 5) : null);
setValue('3y_eps_cagr', periodId, index >= 3 ? cagr(eps, valueFor(rowsByKey.get('diluted_eps'), periods[index - 3]?.id ?? ''), 3) : null);
setValue('5y_eps_cagr', periodId, index >= 5 ? cagr(eps, valueFor(rowsByKey.get('diluted_eps'), periods[index - 5]?.id ?? ''), 5) : null);
}
}
return RATIO_DEFINITIONS.map((definition) => {
const dependencyKeys = (() => {
switch (definition.key) {
case 'gross_margin':
return ['gross_profit', 'revenue'];
case 'operating_margin':
return ['operating_income', 'revenue'];
case 'ebitda_margin':
return ['ebitda', 'revenue'];
case 'net_margin':
return ['net_income', 'revenue'];
case 'fcf_margin':
return ['free_cash_flow', 'revenue'];
case 'roa':
return ['net_income', 'total_assets'];
case 'roe':
return ['net_income', 'total_equity'];
case 'roic':
return ['operating_income', 'effective_tax_rate', 'income_tax_expense', 'pretax_income', 'total_debt', 'total_equity', 'cash_and_equivalents', 'short_term_investments'];
case 'roce':
return ['operating_income', 'total_assets', 'current_liabilities'];
case 'debt_to_equity':
return ['total_debt', 'total_equity'];
case 'net_debt_to_ebitda':
return ['total_debt', 'cash_and_equivalents', 'short_term_investments', 'ebitda'];
case 'cash_to_debt':
return ['cash_and_equivalents', 'short_term_investments', 'total_debt'];
case 'current_ratio':
return ['current_assets', 'current_liabilities'];
case 'revenue_per_share':
return ['revenue', 'diluted_shares'];
case 'fcf_per_share':
return ['free_cash_flow', 'diluted_shares'];
case 'book_value_per_share':
return ['total_equity', 'diluted_shares'];
case 'revenue_yoy':
case '3y_revenue_cagr':
case '5y_revenue_cagr':
return ['revenue'];
case 'net_income_yoy':
return ['net_income'];
case 'eps_yoy':
case '3y_eps_cagr':
case '5y_eps_cagr':
return ['diluted_eps'];
case 'fcf_yoy':
return ['free_cash_flow'];
case 'market_cap':
return ['diluted_shares'];
case 'enterprise_value':
return ['diluted_shares', 'total_debt', 'cash_and_equivalents', 'short_term_investments'];
case 'price_to_earnings':
return ['diluted_eps'];
case 'price_to_fcf':
return ['diluted_shares', 'free_cash_flow'];
case 'price_to_book':
return ['diluted_shares', 'total_equity'];
case 'ev_to_sales':
return ['diluted_shares', 'total_debt', 'cash_and_equivalents', 'short_term_investments', 'revenue'];
case 'ev_to_ebitda':
return ['diluted_shares', 'total_debt', 'cash_and_equivalents', 'short_term_investments', 'ebitda'];
case 'ev_to_fcf':
return ['diluted_shares', 'total_debt', 'cash_and_equivalents', 'short_term_investments', 'free_cash_flow'];
default:
return [];
}
})();
const sources = collectSourceRowData(rowsByKey, dependencyKeys);
return {
key: definition.key,
label: definition.label,
category: definition.category,
order: definition.order,
unit: definition.unit,
values: valuesByKey.get(definition.key) ?? {},
sourceConcepts: sources.sourceConcepts,
sourceRowKeys: sources.sourceRowKeys,
sourceFactIds: sources.sourceFactIds,
formulaKey: definition.key,
hasDimensions: false,
resolvedSourceRowKeys: Object.fromEntries(periods.map((period) => [period.id, null])),
denominatorKey: definition.denominatorKey
} satisfies RatioRow;
}).filter((row) => {
if (row.key.includes('_cagr')) {
return Object.values(row.values).some((value) => value !== null);
}
return true;
});
}