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:
369
lib/server/financials/ratios.ts
Normal file
369
lib/server/financials/ratios.ts
Normal 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;
|
||||
Reference in New Issue
Block a user