- 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
321 lines
14 KiB
TypeScript
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;
|
|
});
|
|
}
|