Expand financials surfaces with ratios, KPIs, and cadence support

- Add bundled financial modeling pipeline (ratios, KPI dimensions/notes, trend series, standardization)
- Introduce company financial bundles storage (Drizzle migration + repo wiring)
- Refactor financials page/API/query flow to use surfaceKind + cadence and new response shapes
This commit is contained in:
2026-03-07 15:16:35 -05:00
parent a42622ba6e
commit db01f207a5
33 changed files with 3589 additions and 1643 deletions

View File

@@ -0,0 +1,369 @@
import type {
FinancialCadence,
FinancialStatementPeriod,
RatioRow,
StandardizedFinancialRow
} from '@/lib/types';
type StatementRowMap = {
income: StandardizedFinancialRow[];
balance: StandardizedFinancialRow[];
cashFlow: StandardizedFinancialRow[];
};
type RatioDefinition = {
key: string;
label: string;
category: string;
order: number;
unit: RatioRow['unit'];
denominatorKey: string | null;
};
const RATIO_DEFINITIONS: RatioDefinition[] = [
{ key: 'gross_margin', label: 'Gross Margin', category: 'margins', order: 10, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'operating_margin', label: 'Operating Margin', category: 'margins', order: 20, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'ebitda_margin', label: 'EBITDA Margin', category: 'margins', order: 30, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'net_margin', label: 'Net Margin', category: 'margins', order: 40, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'fcf_margin', label: 'FCF Margin', category: 'margins', order: 50, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'roa', label: 'ROA', category: 'returns', order: 60, unit: 'percent', denominatorKey: 'total_assets' },
{ key: 'roe', label: 'ROE', category: 'returns', order: 70, unit: 'percent', denominatorKey: 'total_equity' },
{ key: 'roic', label: 'ROIC', category: 'returns', order: 80, unit: 'percent', denominatorKey: 'average_invested_capital' },
{ key: 'roce', label: 'ROCE', category: 'returns', order: 90, unit: 'percent', denominatorKey: 'average_capital_employed' },
{ key: 'debt_to_equity', label: 'Debt to Equity', category: 'financial_health', order: 100, unit: 'ratio', denominatorKey: 'total_equity' },
{ key: 'net_debt_to_ebitda', label: 'Net Debt to EBITDA', category: 'financial_health', order: 110, unit: 'ratio', denominatorKey: 'ebitda' },
{ key: 'cash_to_debt', label: 'Cash to Debt', category: 'financial_health', order: 120, unit: 'ratio', denominatorKey: 'total_debt' },
{ key: 'current_ratio', label: 'Current Ratio', category: 'financial_health', order: 130, unit: 'ratio', denominatorKey: 'current_liabilities' },
{ key: 'revenue_per_share', label: 'Revenue per Share', category: 'per_share', order: 140, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'fcf_per_share', label: 'FCF per Share', category: 'per_share', order: 150, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'book_value_per_share', label: 'Book Value per Share', category: 'per_share', order: 160, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'revenue_yoy', label: 'Revenue YoY', category: 'growth', order: 170, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'net_income_yoy', label: 'Net Income YoY', category: 'growth', order: 180, unit: 'percent', denominatorKey: 'net_income' },
{ key: 'eps_yoy', label: 'EPS YoY', category: 'growth', order: 190, unit: 'percent', denominatorKey: 'diluted_eps' },
{ key: 'fcf_yoy', label: 'FCF YoY', category: 'growth', order: 200, unit: 'percent', denominatorKey: 'free_cash_flow' },
{ key: '3y_revenue_cagr', label: '3Y Revenue CAGR', category: 'growth', order: 210, unit: 'percent', denominatorKey: 'revenue' },
{ key: '5y_revenue_cagr', label: '5Y Revenue CAGR', category: 'growth', order: 220, unit: 'percent', denominatorKey: 'revenue' },
{ key: '3y_eps_cagr', label: '3Y EPS CAGR', category: 'growth', order: 230, unit: 'percent', denominatorKey: 'diluted_eps' },
{ key: '5y_eps_cagr', label: '5Y EPS CAGR', category: 'growth', order: 240, unit: 'percent', denominatorKey: 'diluted_eps' },
{ key: 'market_cap', label: 'Market Cap', category: 'valuation', order: 250, unit: 'currency', denominatorKey: null },
{ key: 'enterprise_value', label: 'Enterprise Value', category: 'valuation', order: 260, unit: 'currency', denominatorKey: null },
{ key: 'price_to_earnings', label: 'Price to Earnings', category: 'valuation', order: 270, unit: 'ratio', denominatorKey: 'diluted_eps' },
{ key: 'price_to_fcf', label: 'Price to FCF', category: 'valuation', order: 280, unit: 'ratio', denominatorKey: 'free_cash_flow' },
{ key: 'price_to_book', label: 'Price to Book', category: 'valuation', order: 290, unit: 'ratio', denominatorKey: 'total_equity' },
{ key: 'ev_to_sales', label: 'EV to Sales', category: 'valuation', order: 300, unit: 'ratio', denominatorKey: 'revenue' },
{ key: 'ev_to_ebitda', label: 'EV to EBITDA', category: 'valuation', order: 310, unit: 'ratio', denominatorKey: 'ebitda' },
{ key: 'ev_to_fcf', label: 'EV to FCF', category: 'valuation', order: 320, unit: 'ratio', denominatorKey: 'free_cash_flow' }
];
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;
});
}
export const RATIO_CATEGORY_ORDER = [
'margins',
'returns',
'financial_health',
'per_share',
'growth',
'valuation'
] as const;