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,53 @@
import type {
FinancialCadence,
FinancialSurfaceKind
} from '@/lib/types';
import {
getCompanyFinancialBundle,
upsertCompanyFinancialBundle
} from '@/lib/server/repos/company-financial-bundles';
import type { FilingTaxonomySnapshotRecord } from '@/lib/server/repos/filing-taxonomy';
export function computeSourceSignature(snapshots: FilingTaxonomySnapshotRecord[]) {
return snapshots
.map((snapshot) => `${snapshot.id}:${snapshot.updated_at}`)
.sort((left, right) => left.localeCompare(right))
.join('|');
}
export async function readCachedFinancialBundle(input: {
ticker: string;
surfaceKind: FinancialSurfaceKind;
cadence: FinancialCadence;
snapshots: FilingTaxonomySnapshotRecord[];
}) {
const sourceSignature = computeSourceSignature(input.snapshots);
const cached = await getCompanyFinancialBundle({
ticker: input.ticker,
surfaceKind: input.surfaceKind,
cadence: input.cadence
});
if (!cached || cached.source_signature !== sourceSignature) {
return null;
}
return cached.payload;
}
export async function writeFinancialBundle(input: {
ticker: string;
surfaceKind: FinancialSurfaceKind;
cadence: FinancialCadence;
snapshots: FilingTaxonomySnapshotRecord[];
payload: Record<string, unknown>;
}) {
return await upsertCompanyFinancialBundle({
ticker: input.ticker,
surfaceKind: input.surfaceKind,
cadence: input.cadence,
sourceSnapshotIds: input.snapshots.map((snapshot) => snapshot.id),
sourceSignature: computeSourceSignature(input.snapshots),
payload: input.payload
});
}

View File

@@ -0,0 +1,303 @@
import type {
FinancialCadence,
FinancialStatementKind,
FinancialStatementPeriod,
FinancialSurfaceKind,
TaxonomyStatementRow
} from '@/lib/types';
import type { FilingTaxonomySnapshotRecord } from '@/lib/server/repos/filing-taxonomy';
type PrimaryPeriodSelection = {
periods: FinancialStatementPeriod[];
selectedPeriodIds: Set<string>;
snapshots: FilingTaxonomySnapshotRecord[];
};
function parseEpoch(value: string | null) {
if (!value) {
return Number.NaN;
}
return Date.parse(value);
}
export function periodSorter(left: FinancialStatementPeriod, right: FinancialStatementPeriod) {
const leftDate = parseEpoch(left.periodEnd ?? left.filingDate);
const rightDate = parseEpoch(right.periodEnd ?? right.filingDate);
if (Number.isFinite(leftDate) && Number.isFinite(rightDate) && leftDate !== rightDate) {
return leftDate - rightDate;
}
return left.id.localeCompare(right.id);
}
export function isInstantPeriod(period: FinancialStatementPeriod) {
return period.periodStart === null;
}
function periodDurationDays(period: FinancialStatementPeriod) {
if (!period.periodStart || !period.periodEnd) {
return null;
}
const start = Date.parse(period.periodStart);
const end = Date.parse(period.periodEnd);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
return null;
}
return Math.round((end - start) / 86_400_000) + 1;
}
function preferredDurationDays(filingType: FinancialStatementPeriod['filingType']) {
return filingType === '10-K' ? 365 : 90;
}
function selectPrimaryPeriodFromSnapshot(
snapshot: FilingTaxonomySnapshotRecord,
statement: FinancialStatementKind
) {
const rows = snapshot.statement_rows?.[statement] ?? [];
if (rows.length === 0) {
return null;
}
const usedPeriodIds = new Set<string>();
for (const row of rows) {
for (const periodId of Object.keys(row.values)) {
usedPeriodIds.add(periodId);
}
}
const candidates = (snapshot.periods ?? []).filter((period) => usedPeriodIds.has(period.id));
if (candidates.length === 0) {
return null;
}
if (statement === 'balance') {
const instantCandidates = candidates.filter(isInstantPeriod);
return (instantCandidates.length > 0 ? instantCandidates : candidates)
.sort((left, right) => periodSorter(right, left))[0] ?? null;
}
const durationCandidates = candidates.filter((period) => !isInstantPeriod(period));
if (durationCandidates.length === 0) {
return candidates.sort((left, right) => periodSorter(right, left))[0] ?? null;
}
const targetDays = preferredDurationDays(snapshot.filing_type);
return durationCandidates.sort((left, right) => {
const leftDate = parseEpoch(left.periodEnd ?? left.filingDate);
const rightDate = parseEpoch(right.periodEnd ?? right.filingDate);
if (Number.isFinite(leftDate) && Number.isFinite(rightDate) && leftDate !== rightDate) {
return rightDate - leftDate;
}
const leftDistance = Math.abs((periodDurationDays(left) ?? targetDays) - targetDays);
const rightDistance = Math.abs((periodDurationDays(right) ?? targetDays) - targetDays);
if (leftDistance !== rightDistance) {
return leftDistance - rightDistance;
}
return left.id.localeCompare(right.id);
})[0] ?? null;
}
function filingTypeForCadence(cadence: FinancialCadence) {
return cadence === 'annual' ? '10-K' : '10-Q';
}
export function surfaceToStatementKind(surfaceKind: FinancialSurfaceKind): FinancialStatementKind | null {
switch (surfaceKind) {
case 'income_statement':
return 'income';
case 'balance_sheet':
return 'balance';
case 'cash_flow_statement':
return 'cash_flow';
default:
return null;
}
}
export function isStatementSurface(surfaceKind: FinancialSurfaceKind) {
return surfaceToStatementKind(surfaceKind) !== null;
}
export function selectPrimaryPeriodsByCadence(
snapshots: FilingTaxonomySnapshotRecord[],
statement: FinancialStatementKind,
cadence: FinancialCadence
): PrimaryPeriodSelection {
const filingType = filingTypeForCadence(cadence);
const filteredSnapshots = snapshots.filter((snapshot) => snapshot.filing_type === filingType);
const selected = filteredSnapshots
.map((snapshot) => ({
snapshot,
period: selectPrimaryPeriodFromSnapshot(snapshot, statement)
}))
.filter((entry): entry is { snapshot: FilingTaxonomySnapshotRecord; period: FinancialStatementPeriod } => entry.period !== null)
.sort((left, right) => periodSorter(left.period, right.period));
const periods = selected.map((entry) => entry.period);
return {
periods,
selectedPeriodIds: new Set(periods.map((period) => period.id)),
snapshots: selected.map((entry) => entry.snapshot)
};
}
export function buildRows(
snapshots: FilingTaxonomySnapshotRecord[],
statement: FinancialStatementKind,
selectedPeriodIds: Set<string>
) {
const rowMap = new Map<string, TaxonomyStatementRow>();
for (const snapshot of snapshots) {
const rows = snapshot.statement_rows?.[statement] ?? [];
for (const row of rows) {
const existing = rowMap.get(row.key);
if (!existing) {
rowMap.set(row.key, {
...row,
values: Object.fromEntries(
Object.entries(row.values).filter(([periodId]) => selectedPeriodIds.has(periodId))
),
units: Object.fromEntries(
Object.entries(row.units).filter(([periodId]) => selectedPeriodIds.has(periodId))
),
sourceFactIds: [...row.sourceFactIds]
});
if (Object.keys(rowMap.get(row.key)?.values ?? {}).length === 0) {
rowMap.delete(row.key);
}
continue;
}
existing.hasDimensions = existing.hasDimensions || row.hasDimensions;
existing.order = Math.min(existing.order, row.order);
existing.depth = Math.min(existing.depth, row.depth);
if (!existing.parentKey && row.parentKey) {
existing.parentKey = row.parentKey;
}
for (const [periodId, value] of Object.entries(row.values)) {
if (selectedPeriodIds.has(periodId) && !(periodId in existing.values)) {
existing.values[periodId] = value;
}
}
for (const [periodId, unit] of Object.entries(row.units)) {
if (selectedPeriodIds.has(periodId) && !(periodId in existing.units)) {
existing.units[periodId] = unit;
}
}
for (const factId of row.sourceFactIds) {
if (!existing.sourceFactIds.includes(factId)) {
existing.sourceFactIds.push(factId);
}
}
}
}
return [...rowMap.values()].sort((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
return left.label.localeCompare(right.label);
});
}
function canBuildRollingFour(periods: FinancialStatementPeriod[]) {
if (periods.length < 4) {
return false;
}
const sorted = [...periods].sort(periodSorter);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const spanDays = Math.round((Date.parse(last.periodEnd ?? last.filingDate) - Date.parse(first.periodEnd ?? first.filingDate)) / 86_400_000);
return spanDays >= 250 && spanDays <= 460;
}
function aggregateValues(values: Array<number | null>) {
if (values.some((value) => value === null)) {
return null;
}
return values.reduce<number>((sum, value) => sum + (value ?? 0), 0);
}
export function buildLtmPeriods(periods: FinancialStatementPeriod[]) {
const sorted = [...periods].sort(periodSorter);
const windows: FinancialStatementPeriod[] = [];
for (let index = 3; index < sorted.length; index += 1) {
const slice = sorted.slice(index - 3, index + 1);
if (!canBuildRollingFour(slice)) {
continue;
}
const last = slice[slice.length - 1];
windows.push({
...last,
id: `ltm:${last.id}`,
periodStart: slice[0]?.periodStart ?? null,
periodEnd: last.periodEnd,
periodLabel: `LTM ending ${last.periodEnd ?? last.filingDate}`
});
}
return windows;
}
export function buildLtmFaithfulRows(
quarterlyRows: TaxonomyStatementRow[],
quarterlyPeriods: FinancialStatementPeriod[],
ltmPeriods: FinancialStatementPeriod[],
statement: FinancialStatementKind
) {
const sourceRows = new Map(quarterlyRows.map((row) => [row.key, row]));
const rowMap = new Map<string, TaxonomyStatementRow>();
const sortedQuarterlyPeriods = [...quarterlyPeriods].sort(periodSorter);
for (const row of quarterlyRows) {
rowMap.set(row.key, {
...row,
values: {},
units: {}
});
}
for (const ltmPeriod of ltmPeriods) {
const anchorIndex = sortedQuarterlyPeriods.findIndex((period) => `ltm:${period.id}` === ltmPeriod.id);
if (anchorIndex < 3) {
continue;
}
const slice = sortedQuarterlyPeriods.slice(anchorIndex - 3, anchorIndex + 1);
for (const row of rowMap.values()) {
const sourceRow = sourceRows.get(row.key);
if (!sourceRow) {
continue;
}
const sourceValues = slice.map((period) => sourceRow.values[period.id] ?? null);
const sourceUnits = slice.map((period) => sourceRow.units[period.id] ?? null).filter((unit): unit is string => unit !== null);
row.values[ltmPeriod.id] = statement === 'balance'
? sourceValues[sourceValues.length - 1] ?? null
: aggregateValues(sourceValues);
row.units[ltmPeriod.id] = sourceUnits[sourceUnits.length - 1] ?? null;
}
}
return [...rowMap.values()].filter((row) => Object.keys(row.values).length > 0);
}

View File

@@ -0,0 +1,85 @@
import type {
FinancialStatementKind,
FinancialUnit
} from '@/lib/types';
export type CanonicalRowDefinition = {
key: string;
label: string;
category: string;
order: number;
unit: FinancialUnit;
localNames?: readonly string[];
labelIncludes?: readonly string[];
};
const INCOME_DEFINITIONS: CanonicalRowDefinition[] = [
{ key: 'revenue', label: 'Revenue', category: 'revenue', order: 10, unit: 'currency', localNames: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet', 'TotalRevenuesAndOtherIncome'], labelIncludes: ['revenue', 'sales'] },
{ key: 'cost_of_revenue', label: 'Cost of Revenue', category: 'expense', order: 20, unit: 'currency', localNames: ['CostOfRevenue', 'CostOfGoodsSold', 'CostOfSales', 'CostOfProductsSold', 'CostOfServices'], labelIncludes: ['cost of revenue', 'cost of sales', 'cost of goods sold'] },
{ key: 'gross_profit', label: 'Gross Profit', category: 'profit', order: 30, unit: 'currency', localNames: ['GrossProfit'] },
{ key: 'gross_margin', label: 'Gross Margin', category: 'margin', order: 40, unit: 'percent', labelIncludes: ['gross margin'] },
{ key: 'research_and_development', label: 'Research & Development', category: 'opex', order: 50, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development'] },
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 70, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 80, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
{ key: 'operating_income', label: 'Operating Income', category: 'profit', order: 90, unit: 'currency', localNames: ['OperatingIncomeLoss', 'IncomeLossFromOperations'], labelIncludes: ['operating income'] },
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 100, unit: 'percent', labelIncludes: ['operating margin'] },
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 110, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 120, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 140, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 150, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 160, unit: 'percent', labelIncludes: ['effective tax rate'] },
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 170, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 180, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 190, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 200, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 210, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 220, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 230, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 240, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
];
const BALANCE_DEFINITIONS: CanonicalRowDefinition[] = [
{ key: 'cash_and_equivalents', label: 'Cash & Equivalents', category: 'asset', order: 10, unit: 'currency', localNames: ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsAndShortTermInvestments', 'CashAndShortTermInvestments'], labelIncludes: ['cash and cash equivalents'] },
{ key: 'short_term_investments', label: 'Short-Term Investments', category: 'asset', order: 20, unit: 'currency', localNames: ['AvailableForSaleSecuritiesCurrent', 'ShortTermInvestments'], labelIncludes: ['short-term investments', 'marketable securities'] },
{ key: 'accounts_receivable', label: 'Accounts Receivable', category: 'asset', order: 30, unit: 'currency', localNames: ['AccountsReceivableNetCurrent', 'ReceivablesNetCurrent'], labelIncludes: ['accounts receivable'] },
{ key: 'inventory', label: 'Inventory', category: 'asset', order: 40, unit: 'currency', localNames: ['InventoryNet'], labelIncludes: ['inventory'] },
{ key: 'other_current_assets', label: 'Other Current Assets', category: 'asset', order: 50, unit: 'currency', localNames: ['OtherAssetsCurrent'], labelIncludes: ['other current assets'] },
{ key: 'current_assets', label: 'Current Assets', category: 'asset', order: 60, unit: 'currency', localNames: ['AssetsCurrent'], labelIncludes: ['current assets'] },
{ key: 'property_plant_equipment', label: 'Property, Plant & Equipment', category: 'asset', order: 70, unit: 'currency', localNames: ['PropertyPlantAndEquipmentNet'], labelIncludes: ['property, plant and equipment', 'property and equipment'] },
{ key: 'goodwill', label: 'Goodwill', category: 'asset', order: 80, unit: 'currency', localNames: ['Goodwill'], labelIncludes: ['goodwill'] },
{ key: 'intangible_assets', label: 'Intangible Assets', category: 'asset', order: 90, unit: 'currency', localNames: ['FiniteLivedIntangibleAssetsNet', 'IndefiniteLivedIntangibleAssetsExcludingGoodwill', 'IntangibleAssetsNetExcludingGoodwill'], labelIncludes: ['intangible assets'] },
{ key: 'total_assets', label: 'Total Assets', category: 'asset', order: 100, unit: 'currency', localNames: ['Assets'], labelIncludes: ['total assets'] },
{ key: 'accounts_payable', label: 'Accounts Payable', category: 'liability', order: 110, unit: 'currency', localNames: ['AccountsPayableCurrent'], labelIncludes: ['accounts payable'] },
{ key: 'accrued_liabilities', label: 'Accrued Liabilities', category: 'liability', order: 120, unit: 'currency', localNames: ['AccruedLiabilitiesCurrent'], labelIncludes: ['accrued liabilities'] },
{ key: 'deferred_revenue_current', label: 'Deferred Revenue, Current', category: 'liability', order: 130, unit: 'currency', localNames: ['ContractWithCustomerLiabilityCurrent', 'DeferredRevenueCurrent'], labelIncludes: ['deferred revenue current', 'current deferred revenue'] },
{ key: 'current_liabilities', label: 'Current Liabilities', category: 'liability', order: 140, unit: 'currency', localNames: ['LiabilitiesCurrent'], labelIncludes: ['current liabilities'] },
{ key: 'long_term_debt', label: 'Long-Term Debt', category: 'liability', order: 150, unit: 'currency', localNames: ['LongTermDebtNoncurrent', 'LongTermDebt', 'DebtNoncurrent', 'LongTermDebtAndCapitalLeaseObligations'], labelIncludes: ['long-term debt'] },
{ key: 'current_debt', label: 'Current Debt', category: 'liability', order: 160, unit: 'currency', localNames: ['DebtCurrent', 'ShortTermBorrowings', 'LongTermDebtCurrent'], labelIncludes: ['current debt', 'short-term debt'] },
{ key: 'lease_liabilities', label: 'Lease Liabilities', category: 'liability', order: 170, unit: 'currency', localNames: ['OperatingLeaseLiabilityNoncurrent', 'FinanceLeaseLiabilityNoncurrent', 'LesseeOperatingLeaseLiability'], labelIncludes: ['lease liabilities'] },
{ key: 'total_debt', label: 'Total Debt', category: 'liability', order: 180, unit: 'currency', localNames: ['DebtAndFinanceLeaseLiabilities', 'Debt'], labelIncludes: ['total debt'] },
{ key: 'deferred_revenue_noncurrent', label: 'Deferred Revenue, Noncurrent', category: 'liability', order: 190, unit: 'currency', localNames: ['ContractWithCustomerLiabilityNoncurrent', 'DeferredRevenueNoncurrent'], labelIncludes: ['deferred revenue noncurrent'] },
{ key: 'total_liabilities', label: 'Total Liabilities', category: 'liability', order: 200, unit: 'currency', localNames: ['Liabilities'], labelIncludes: ['total liabilities'] },
{ key: 'retained_earnings', label: 'Retained Earnings', category: 'equity', order: 210, unit: 'currency', localNames: ['RetainedEarningsAccumulatedDeficit'], labelIncludes: ['retained earnings', 'accumulated deficit'] },
{ key: 'total_equity', label: 'Total Equity', category: 'equity', order: 220, unit: 'currency', localNames: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest', 'PartnersCapital'], labelIncludes: ['total equity', 'stockholders equity', 'stockholders equity'] },
{ key: 'net_cash_position', label: 'Net Cash Position', category: 'liquidity', order: 230, unit: 'currency', labelIncludes: ['net cash position'] }
];
const CASH_FLOW_DEFINITIONS: CanonicalRowDefinition[] = [
{ key: 'operating_cash_flow', label: 'Operating Cash Flow', category: 'cash_flow', order: 10, unit: 'currency', localNames: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'], labelIncludes: ['operating cash flow'] },
{ key: 'capital_expenditures', label: 'Capital Expenditures', category: 'cash_flow', order: 20, unit: 'currency', localNames: ['PaymentsToAcquirePropertyPlantAndEquipment', 'CapitalExpendituresIncurredButNotYetPaid'], labelIncludes: ['capital expenditures', 'capital expenditure'] },
{ key: 'free_cash_flow', label: 'Free Cash Flow', category: 'cash_flow', order: 30, unit: 'currency', labelIncludes: ['free cash flow'] },
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 40, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] },
{ key: 'acquisitions', label: 'Acquisitions', category: 'investing', order: 50, unit: 'currency', localNames: ['PaymentsToAcquireBusinessesNetOfCashAcquired'], labelIncludes: ['acquisitions'] },
{ key: 'share_repurchases', label: 'Share Repurchases', category: 'financing', order: 60, unit: 'currency', localNames: ['PaymentsForRepurchaseOfCommonStock', 'PaymentsForRepurchaseOfEquity'], labelIncludes: ['share repurchases', 'repurchase of common stock'] },
{ key: 'dividends_paid', label: 'Dividends Paid', category: 'financing', order: 70, unit: 'currency', localNames: ['PaymentsOfDividends', 'PaymentsOfDividendsCommonStock'], labelIncludes: ['dividends paid'] },
{ key: 'debt_issued', label: 'Debt Issued', category: 'financing', order: 80, unit: 'currency', localNames: ['ProceedsFromIssuanceOfLongTermDebt'], labelIncludes: ['debt issued'] },
{ key: 'debt_repaid', label: 'Debt Repaid', category: 'financing', order: 90, unit: 'currency', localNames: ['RepaymentsOfLongTermDebt', 'RepaymentsOfDebt'], labelIncludes: ['debt repaid', 'repayment of debt'] }
];
export const CANONICAL_ROW_DEFINITIONS: Record<Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>, CanonicalRowDefinition[]> = {
income: INCOME_DEFINITIONS,
balance: BALANCE_DEFINITIONS,
cash_flow: CASH_FLOW_DEFINITIONS
};

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'bun:test';
import type {
FinancialStatementPeriod,
TaxonomyFactRow
} from '@/lib/types';
import { extractStructuredKpisFromDimensions } from './kpi-dimensions';
const PERIOD: FinancialStatementPeriod = {
id: '2025-q4',
filingId: 1,
accessionNumber: '0000-1',
filingDate: '2026-01-31',
periodStart: '2025-10-01',
periodEnd: '2025-12-31',
filingType: '10-Q',
periodLabel: 'Q4 2025'
};
const FACT: TaxonomyFactRow = {
id: 10,
snapshotId: 5,
filingId: 1,
filingDate: '2026-01-31',
statement: 'income',
roleUri: 'income',
conceptKey: 'us-gaap:Revenues',
qname: 'us-gaap:Revenues',
namespaceUri: 'http://fasb.org/us-gaap/2024',
localName: 'Revenues',
value: 50000,
contextId: 'ctx-1',
unit: 'iso4217:USD',
decimals: null,
periodStart: '2025-10-01',
periodEnd: '2025-12-31',
periodInstant: null,
dimensions: [{
axis: 'srt:ProductOrServiceAxis',
member: 'msft:CloudMember'
}],
isDimensionless: false,
sourceFile: null
};
describe('dimension KPI extraction', () => {
it('builds stable taxonomy KPI keys and provenance', () => {
const rows = extractStructuredKpisFromDimensions({
facts: [FACT],
periods: [PERIOD],
definitions: [{
key: 'segment_revenue',
label: 'Segment Revenue',
category: 'segment_revenue',
unit: 'currency',
preferredConceptNames: ['Revenues']
}]
});
expect(rows).toHaveLength(1);
expect(rows[0]?.key).toBe('segment_revenue__srt_productorserviceaxis__msft_cloudmember');
expect(rows[0]?.provenanceType).toBe('taxonomy');
expect(rows[0]?.values['2025-q4']).toBe(50000);
expect(rows[0]?.sourceFactIds).toEqual([10]);
});
});

View File

@@ -0,0 +1,159 @@
import type {
FinancialStatementPeriod,
StructuredKpiRow,
TaxonomyFactRow
} from '@/lib/types';
import type { KpiDefinition } from '@/lib/server/financials/kpi-registry';
import { factMatchesPeriod } from '@/lib/server/financials/standardize';
function normalizeSegmentToken(value: string) {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
function humanizeMember(value: string) {
const source = value.split(':').pop() ?? value;
return source
.replace(/Member$/i, '')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.trim();
}
function factMatchesDefinition(fact: TaxonomyFactRow, definition: KpiDefinition) {
if (definition.preferredConceptNames && !definition.preferredConceptNames.includes(fact.localName)) {
return false;
}
if (!definition.preferredAxisIncludes || definition.preferredAxisIncludes.length === 0) {
return fact.dimensions.length > 0;
}
return fact.dimensions.some((dimension) => {
const axisMatch = definition.preferredAxisIncludes?.some((token) => dimension.axis.toLowerCase().includes(token.toLowerCase())) ?? false;
const memberMatch = definition.preferredMemberIncludes && definition.preferredMemberIncludes.length > 0
? definition.preferredMemberIncludes.some((token) => dimension.member.toLowerCase().includes(token.toLowerCase()))
: true;
return axisMatch && memberMatch;
});
}
function categoryForDefinition(definition: KpiDefinition, axis: string) {
if (definition.key === 'segment_revenue' && /geo|country|region|area/i.test(axis)) {
return 'geographic_mix';
}
return definition.category;
}
export function extractStructuredKpisFromDimensions(input: {
facts: TaxonomyFactRow[];
periods: FinancialStatementPeriod[];
definitions: KpiDefinition[];
}) {
const rowMap = new Map<string, StructuredKpiRow>();
const orderByKey = new Map<string, number>();
input.definitions.forEach((definition, index) => {
orderByKey.set(definition.key, (index + 1) * 10);
});
for (const definition of input.definitions) {
for (const fact of input.facts) {
if (fact.dimensions.length === 0 || !factMatchesDefinition(fact, definition)) {
continue;
}
const matchedPeriod = input.periods.find((period) => period.filingId === fact.filingId && factMatchesPeriod(fact, period));
if (!matchedPeriod) {
continue;
}
for (const dimension of fact.dimensions) {
const axis = dimension.axis;
const member = dimension.member;
const normalizedAxis = normalizeSegmentToken(axis);
const normalizedMember = normalizeSegmentToken(member);
const key = `${definition.key}__${normalizedAxis}__${normalizedMember}`;
const labelSuffix = humanizeMember(member);
const existing = rowMap.get(key);
if (existing) {
existing.values[matchedPeriod.id] = fact.value;
if (!existing.sourceConcepts.includes(fact.qname)) {
existing.sourceConcepts.push(fact.qname);
}
if (!existing.sourceFactIds.includes(fact.id)) {
existing.sourceFactIds.push(fact.id);
}
continue;
}
rowMap.set(key, {
key,
label: `${definition.label} - ${labelSuffix}`,
category: categoryForDefinition(definition, axis),
unit: definition.unit,
order: orderByKey.get(definition.key) ?? 999,
segment: labelSuffix || null,
axis,
member,
values: { [matchedPeriod.id]: fact.value },
sourceConcepts: [fact.qname],
sourceFactIds: [fact.id],
provenanceType: 'taxonomy',
hasDimensions: true
});
}
}
}
const rows = [...rowMap.values()].sort((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
return left.label.localeCompare(right.label);
});
const marginRows = new Map<string, StructuredKpiRow>();
for (const row of rows.filter((entry) => entry.category === 'segment_profit')) {
const revenueKey = row.key.replace(/^segment_profit__/, 'segment_revenue__');
const revenueRow = rowMap.get(revenueKey);
if (!revenueRow) {
continue;
}
const values: Record<string, number | null> = {};
for (const period of input.periods) {
const revenue = revenueRow.values[period.id] ?? null;
const profit = row.values[period.id] ?? null;
values[period.id] = revenue === null || profit === null || revenue === 0
? null
: profit / revenue;
}
marginRows.set(row.key.replace(/^segment_profit__/, 'segment_margin__'), {
key: row.key.replace(/^segment_profit__/, 'segment_margin__'),
label: row.label.replace(/^Segment Profit/, 'Segment Margin'),
category: 'segment_margin',
unit: 'percent',
order: 25,
segment: row.segment,
axis: row.axis,
member: row.member,
values,
sourceConcepts: [...new Set([...row.sourceConcepts, ...revenueRow.sourceConcepts])],
sourceFactIds: [...new Set([...row.sourceFactIds, ...revenueRow.sourceFactIds])],
provenanceType: 'taxonomy',
hasDimensions: true
});
}
return [...rows, ...marginRows.values()].sort((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
return left.label.localeCompare(right.label);
});
}

View File

@@ -0,0 +1,131 @@
import { load } from 'cheerio';
import type {
FinancialStatementPeriod,
StructuredKpiRow
} from '@/lib/types';
import { resolvePrimaryFilingUrl } from '@/lib/server/sec';
import type { KpiDefinition } from '@/lib/server/financials/kpi-registry';
type FilingDocumentRef = {
filingId: number;
cik: string;
accessionNumber: string;
filingUrl: string | null;
primaryDocument: string | null;
};
function parseNumericCell(value: string) {
const normalized = value.replace(/[$,%]/g, '').replace(/[(),]/g, '').trim();
if (!normalized) {
return null;
}
const numeric = Number(normalized);
return Number.isFinite(numeric) ? numeric : null;
}
function buildRowKey(definition: KpiDefinition, label: string) {
const normalized = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
return normalized ? `${definition.key}__note__${normalized}` : definition.key;
}
async function fetchHtml(ref: FilingDocumentRef) {
const url = resolvePrimaryFilingUrl({
filingUrl: ref.filingUrl,
cik: ref.cik,
accessionNumber: ref.accessionNumber,
primaryDocument: ref.primaryDocument
});
if (!url) {
return null;
}
try {
const response = await fetch(url, {
headers: {
'User-Agent': process.env.SEC_USER_AGENT || 'Fiscal Clone <support@fiscal.local>'
},
cache: 'no-store'
});
if (!response.ok) {
return null;
}
return await response.text();
} catch {
return null;
}
}
export async function extractStructuredKpisFromNotes(input: {
ticker: string;
periods: FinancialStatementPeriod[];
filings: FilingDocumentRef[];
definitions: KpiDefinition[];
}) {
const rows = new Map<string, StructuredKpiRow>();
for (const definition of input.definitions) {
if (!definition.noteLabelIncludes || definition.noteLabelIncludes.length === 0) {
continue;
}
for (const period of input.periods) {
const filing = input.filings.find((entry) => entry.filingId === period.filingId);
if (!filing) {
continue;
}
const html = await fetchHtml(filing);
if (!html) {
continue;
}
const $ = load(html);
$('table tr').each((_index, element) => {
const cells = $(element).find('th,td').toArray().map((node) => $(node).text().replace(/\s+/g, ' ').trim()).filter(Boolean);
if (cells.length < 2) {
return;
}
const label = cells[0] ?? '';
const normalizedLabel = label.toLowerCase();
if (!definition.noteLabelIncludes?.some((token) => normalizedLabel.includes(token.toLowerCase()))) {
return;
}
const numericCell = cells.slice(1).map(parseNumericCell).find((value) => value !== null) ?? null;
if (numericCell === null) {
return;
}
const key = buildRowKey(definition, label === definition.label ? '' : label);
const existing = rows.get(key);
if (existing) {
existing.values[period.id] = numericCell;
return;
}
rows.set(key, {
key,
label: label || definition.label,
category: definition.category,
unit: definition.unit,
order: 500,
segment: null,
axis: null,
member: null,
values: { [period.id]: numericCell },
sourceConcepts: [],
sourceFactIds: [],
provenanceType: 'structured_note',
hasDimensions: false
});
});
}
}
return [...rows.values()].sort((left, right) => left.label.localeCompare(right.label));
}

View File

@@ -0,0 +1,120 @@
import type {
FinancialCategory,
FinancialUnit
} from '@/lib/types';
export type IndustryTemplate =
| 'internet_platforms'
| 'software_saas'
| 'semiconductors_industrial_auto';
export type KpiDefinition = {
key: string;
label: string;
category: FinancialCategory;
unit: FinancialUnit;
preferredConceptNames?: string[];
preferredAxisIncludes?: string[];
preferredMemberIncludes?: string[];
noteLabelIncludes?: string[];
};
type RegistryBundle = {
tickerTemplates: Record<string, IndustryTemplate>;
globalDefinitions: KpiDefinition[];
industryDefinitions: Record<IndustryTemplate, KpiDefinition[]>;
tickerDefinitions: Record<string, KpiDefinition[]>;
};
const KPI_REGISTRY: RegistryBundle = {
tickerTemplates: {
GOOG: 'internet_platforms',
META: 'internet_platforms',
NFLX: 'internet_platforms',
MSFT: 'software_saas',
CRM: 'software_saas',
NOW: 'software_saas',
NVDA: 'semiconductors_industrial_auto',
TSLA: 'semiconductors_industrial_auto',
CAT: 'semiconductors_industrial_auto'
},
globalDefinitions: [
{
key: 'segment_revenue',
label: 'Segment Revenue',
category: 'segment_revenue',
unit: 'currency',
preferredConceptNames: ['Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax', 'SalesRevenueNet']
},
{
key: 'segment_profit',
label: 'Segment Profit',
category: 'segment_profit',
unit: 'currency',
preferredConceptNames: ['OperatingIncomeLoss', 'SegmentProfitLoss']
}
],
industryDefinitions: {
internet_platforms: [
{ key: 'tac', label: 'TAC', category: 'operating_kpi', unit: 'currency', preferredConceptNames: ['TrafficAcquisitionCosts'], noteLabelIncludes: ['traffic acquisition costs', 'tac'] },
{ key: 'paid_clicks', label: 'Paid Clicks', category: 'operating_kpi', unit: 'count', noteLabelIncludes: ['paid clicks'] },
{ key: 'cpc', label: 'CPC', category: 'operating_kpi', unit: 'currency', noteLabelIncludes: ['cost per click', 'cpc'] },
{ key: 'dau', label: 'DAU', category: 'user_metric', unit: 'count', preferredConceptNames: ['DailyActiveUsers'], noteLabelIncludes: ['daily active users', 'dau'] },
{ key: 'mau', label: 'MAU', category: 'user_metric', unit: 'count', preferredConceptNames: ['MonthlyActiveUsers'], noteLabelIncludes: ['monthly active users', 'mau'] },
{ key: 'arpu', label: 'ARPU', category: 'operating_kpi', unit: 'currency', noteLabelIncludes: ['average revenue per user', 'arpu'] },
{ key: 'arpp', label: 'ARPP', category: 'operating_kpi', unit: 'currency', noteLabelIncludes: ['average revenue per paying user', 'arpp'] },
{ key: 'backlog', label: 'Backlog', category: 'backlog', unit: 'currency', noteLabelIncludes: ['backlog'] }
],
software_saas: [
{ key: 'arr', label: 'ARR', category: 'operating_kpi', unit: 'currency', noteLabelIncludes: ['annual recurring revenue', 'arr'] },
{ key: 'rpo', label: 'RPO', category: 'backlog', unit: 'currency', preferredConceptNames: ['RemainingPerformanceObligation'], noteLabelIncludes: ['remaining performance obligations', 'rpo'] },
{ key: 'remaining_performance_obligations', label: 'Remaining Performance Obligations', category: 'backlog', unit: 'currency', preferredConceptNames: ['RemainingPerformanceObligation'], noteLabelIncludes: ['remaining performance obligations'] },
{ key: 'large_customer_count', label: 'Large Customer Count', category: 'operating_kpi', unit: 'count', noteLabelIncludes: ['customers contributing', 'large customers'] }
],
semiconductors_industrial_auto: [
{ key: 'deliveries', label: 'Deliveries', category: 'operating_kpi', unit: 'count', noteLabelIncludes: ['deliveries'] },
{ key: 'utilization', label: 'Utilization', category: 'operating_kpi', unit: 'percent', noteLabelIncludes: ['utilization'] },
{ key: 'backlog', label: 'Backlog', category: 'backlog', unit: 'currency', noteLabelIncludes: ['backlog'] }
]
},
tickerDefinitions: {}
};
export function getTickerIndustryTemplate(ticker: string): IndustryTemplate | null {
return KPI_REGISTRY.tickerTemplates[ticker.trim().toUpperCase()] ?? null;
}
export function resolveKpiDefinitions(ticker: string) {
const normalizedTicker = ticker.trim().toUpperCase();
const template = getTickerIndustryTemplate(normalizedTicker);
const definitionsByKey = new Map<string, KpiDefinition>();
for (const definition of KPI_REGISTRY.globalDefinitions) {
definitionsByKey.set(definition.key, definition);
}
if (template) {
for (const definition of KPI_REGISTRY.industryDefinitions[template]) {
definitionsByKey.set(definition.key, definition);
}
}
for (const definition of KPI_REGISTRY.tickerDefinitions[normalizedTicker] ?? []) {
definitionsByKey.set(definition.key, definition);
}
return {
template,
definitions: [...definitionsByKey.values()]
};
}
export const KPI_CATEGORY_ORDER = [
'segment_revenue',
'segment_profit',
'segment_margin',
'operating_kpi',
'geographic_mix',
'capital_returns',
'backlog',
'user_metric',
'other'
] as const;

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'bun:test';
import type {
FinancialCadence,
FinancialStatementPeriod,
StandardizedFinancialRow
} from '@/lib/types';
import { buildRatioRows } from './ratios';
function createPeriod(id: string, filingId: number, filingDate: string, periodEnd: string): FinancialStatementPeriod {
return {
id,
filingId,
accessionNumber: `0000-${filingId}`,
filingDate,
periodStart: '2025-01-01',
periodEnd,
filingType: '10-Q',
periodLabel: id
};
}
function createRow(key: string, values: Record<string, number | null>): StandardizedFinancialRow {
return {
key,
label: key,
category: 'test',
order: 10,
unit: 'currency',
values,
sourceConcepts: [`us-gaap:${key}`],
sourceRowKeys: [key],
sourceFactIds: [1],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key]))
};
}
describe('ratio engine', () => {
it('nulls valuation ratios when price data is unavailable', () => {
const periods = [createPeriod('2025-q4', 1, '2026-01-31', '2025-12-31')];
const rows = {
income: [createRow('revenue', { '2025-q4': 100 }), createRow('diluted_eps', { '2025-q4': 2 }), createRow('ebitda', { '2025-q4': 20 }), createRow('net_income', { '2025-q4': 10 })],
balance: [createRow('total_equity', { '2025-q4': 50 }), createRow('total_debt', { '2025-q4': 30 }), createRow('cash_and_equivalents', { '2025-q4': 5 }), createRow('short_term_investments', { '2025-q4': 5 }), createRow('diluted_shares', { '2025-q4': 10 })],
cashFlow: [createRow('free_cash_flow', { '2025-q4': 12 })]
};
const ratioRows = buildRatioRows({
periods,
cadence: 'quarterly' satisfies FinancialCadence,
rows,
pricesByPeriodId: { '2025-q4': null }
});
expect(ratioRows.find((row) => row.key === 'market_cap')?.values['2025-q4']).toBeNull();
expect(ratioRows.find((row) => row.key === 'price_to_earnings')?.values['2025-q4']).toBeNull();
expect(ratioRows.find((row) => row.key === 'ev_to_sales')?.values['2025-q4']).toBeNull();
});
it('nulls ratios on zero denominators', () => {
const periods = [
createPeriod('2024-q4', 1, '2025-01-31', '2024-12-31'),
createPeriod('2025-q4', 2, '2026-01-31', '2025-12-31')
];
const rows = {
income: [createRow('net_income', { '2024-q4': 5, '2025-q4': 10 }), createRow('revenue', { '2024-q4': 100, '2025-q4': 120 }), createRow('diluted_eps', { '2024-q4': 1, '2025-q4': 2 }), createRow('ebitda', { '2024-q4': 10, '2025-q4': 12 })],
balance: [createRow('total_equity', { '2024-q4': 0, '2025-q4': 0 }), createRow('total_assets', { '2024-q4': 50, '2025-q4': 60 }), createRow('total_debt', { '2024-q4': 20, '2025-q4': 25 }), createRow('cash_and_equivalents', { '2024-q4': 2, '2025-q4': 3 }), createRow('short_term_investments', { '2024-q4': 1, '2025-q4': 1 }), createRow('current_assets', { '2024-q4': 10, '2025-q4': 12 }), createRow('current_liabilities', { '2024-q4': 0, '2025-q4': 0 }), createRow('diluted_shares', { '2024-q4': 10, '2025-q4': 10 })],
cashFlow: [createRow('free_cash_flow', { '2024-q4': 6, '2025-q4': 7 })]
};
const ratioRows = buildRatioRows({
periods,
cadence: 'quarterly',
rows,
pricesByPeriodId: { '2024-q4': 10, '2025-q4': 12 }
});
expect(ratioRows.find((row) => row.key === 'debt_to_equity')?.values['2025-q4']).toBeNull();
expect(ratioRows.find((row) => row.key === 'current_ratio')?.values['2025-q4']).toBeNull();
});
});

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;

View File

@@ -0,0 +1,450 @@
import type {
DerivedFinancialRow,
DimensionBreakdownRow,
FinancialStatementKind,
FinancialStatementPeriod,
FinancialUnit,
StandardizedFinancialRow,
TaxonomyFactRow,
TaxonomyStatementRow
} from '@/lib/types';
import {
CANONICAL_ROW_DEFINITIONS,
type CanonicalRowDefinition
} from '@/lib/server/financials/canonical-definitions';
function normalizeToken(value: string) {
return value.trim().toLowerCase();
}
function valueOrNull(values: Record<string, number | null>, periodId: string) {
return periodId in values ? values[periodId] : null;
}
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 divideValues(left: number | null, right: number | null) {
if (left === null || right === null || right === 0) {
return null;
}
return left / right;
}
function matchesDefinition(row: TaxonomyStatementRow, definition: CanonicalRowDefinition) {
const rowLocalName = normalizeToken(row.localName);
if (definition.localNames?.some((localName) => normalizeToken(localName) === rowLocalName)) {
return true;
}
const label = normalizeToken(row.label);
return definition.labelIncludes?.some((token) => label.includes(normalizeToken(token))) ?? false;
}
function matchesDefinitionFact(fact: TaxonomyFactRow, definition: CanonicalRowDefinition) {
const localName = normalizeToken(fact.localName);
return definition.localNames?.some((entry) => normalizeToken(entry) === localName) ?? false;
}
function inferUnit(rawUnit: string | null, fallback: FinancialUnit) {
const normalized = (rawUnit ?? '').toLowerCase();
if (!normalized) {
return fallback;
}
if (normalized.includes('usd') || normalized.includes('iso4217')) {
return 'currency';
}
if (normalized.includes('shares')) {
return 'shares';
}
if (normalized.includes('pure') || normalized.includes('percent')) {
return fallback === 'percent' ? 'percent' : 'ratio';
}
return fallback;
}
export function factMatchesPeriod(fact: TaxonomyFactRow, period: FinancialStatementPeriod) {
if (period.periodStart) {
return fact.periodStart === period.periodStart && fact.periodEnd === period.periodEnd;
}
return (fact.periodInstant ?? fact.periodEnd) === period.periodEnd;
}
function buildCanonicalRow(
definition: CanonicalRowDefinition,
matches: TaxonomyStatementRow[],
facts: TaxonomyFactRow[],
periods: FinancialStatementPeriod[]
) {
const sortedMatches = [...matches].sort((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
return left.label.localeCompare(right.label);
});
const matchedFacts = facts.filter((fact) => matchesDefinitionFact(fact, definition) && fact.isDimensionless);
const sourceConcepts = new Set<string>();
const sourceRowKeys = new Set<string>();
const sourceFactIds = new Set<number>();
for (const row of sortedMatches) {
sourceConcepts.add(row.qname);
sourceRowKeys.add(row.key);
for (const factId of row.sourceFactIds) {
sourceFactIds.add(factId);
}
}
const values: Record<string, number | null> = {};
const resolvedSourceRowKeys: Record<string, string | null> = {};
let unit = definition.unit;
for (const period of periods) {
const directMatch = sortedMatches.find((row) => period.id in row.values);
if (directMatch) {
values[period.id] = directMatch.values[period.id] ?? null;
unit = inferUnit(directMatch.units[period.id] ?? null, definition.unit);
resolvedSourceRowKeys[period.id] = directMatch.key;
continue;
}
const factMatch = matchedFacts.find((fact) => factMatchesPeriod(fact, period));
values[period.id] = factMatch?.value ?? null;
unit = inferUnit(factMatch?.unit ?? null, definition.unit);
resolvedSourceRowKeys[period.id] = factMatch?.conceptKey ?? null;
if (factMatch) {
sourceConcepts.add(factMatch.qname);
sourceRowKeys.add(factMatch.conceptKey);
sourceFactIds.add(factMatch.id);
}
}
return {
key: definition.key,
label: definition.label,
category: definition.category,
order: definition.order,
unit,
values,
sourceConcepts: [...sourceConcepts].sort((left, right) => left.localeCompare(right)),
sourceRowKeys: [...sourceRowKeys].sort((left, right) => left.localeCompare(right)),
sourceFactIds: [...sourceFactIds].sort((left, right) => left - right),
formulaKey: null,
hasDimensions: sortedMatches.some((row) => row.hasDimensions),
resolvedSourceRowKeys
} satisfies StandardizedFinancialRow;
}
type FormulaDefinition = {
key: string;
formulaKey: string;
compute: (rowsByKey: Map<string, StandardizedFinancialRow>, periodId: string) => number | null;
};
const FORMULAS: Record<Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>, FormulaDefinition[]> = {
income: [
{
key: 'gross_profit',
formulaKey: 'gross_profit',
compute: (rowsByKey, periodId) => subtractValues(
valueOrNull(rowsByKey.get('revenue')?.values ?? {}, periodId),
valueOrNull(rowsByKey.get('cost_of_revenue')?.values ?? {}, periodId)
)
},
{
key: 'gross_margin',
formulaKey: 'gross_margin',
compute: (rowsByKey, periodId) => divideValues(
valueOrNull(rowsByKey.get('gross_profit')?.values ?? {}, periodId),
valueOrNull(rowsByKey.get('revenue')?.values ?? {}, periodId)
)
},
{
key: 'operating_margin',
formulaKey: 'operating_margin',
compute: (rowsByKey, periodId) => divideValues(
valueOrNull(rowsByKey.get('operating_income')?.values ?? {}, periodId),
valueOrNull(rowsByKey.get('revenue')?.values ?? {}, periodId)
)
},
{
key: 'effective_tax_rate',
formulaKey: 'effective_tax_rate',
compute: (rowsByKey, periodId) => divideValues(
valueOrNull(rowsByKey.get('income_tax_expense')?.values ?? {}, periodId),
valueOrNull(rowsByKey.get('pretax_income')?.values ?? {}, periodId)
)
},
{
key: 'ebitda',
formulaKey: 'ebitda',
compute: (rowsByKey, periodId) => sumValues([
valueOrNull(rowsByKey.get('operating_income')?.values ?? {}, periodId),
valueOrNull(rowsByKey.get('depreciation_and_amortization')?.values ?? {}, periodId)
])
}
],
balance: [
{
key: 'total_debt',
formulaKey: 'total_debt',
compute: (rowsByKey, periodId) => sumValues([
valueOrNull(rowsByKey.get('long_term_debt')?.values ?? {}, periodId),
valueOrNull(rowsByKey.get('current_debt')?.values ?? {}, periodId),
valueOrNull(rowsByKey.get('lease_liabilities')?.values ?? {}, periodId)
])
},
{
key: 'net_cash_position',
formulaKey: 'net_cash_position',
compute: (rowsByKey, periodId) => subtractValues(
sumValues([
valueOrNull(rowsByKey.get('cash_and_equivalents')?.values ?? {}, periodId),
valueOrNull(rowsByKey.get('short_term_investments')?.values ?? {}, periodId)
]),
valueOrNull(rowsByKey.get('total_debt')?.values ?? {}, periodId)
)
}
],
cash_flow: [
{
key: 'free_cash_flow',
formulaKey: 'free_cash_flow',
compute: (rowsByKey, periodId) => subtractValues(
valueOrNull(rowsByKey.get('operating_cash_flow')?.values ?? {}, periodId),
valueOrNull(rowsByKey.get('capital_expenditures')?.values ?? {}, periodId)
)
}
]
};
function applyFormulas(
rowsByKey: Map<string, StandardizedFinancialRow>,
statement: Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>,
periods: FinancialStatementPeriod[]
) {
for (const formula of FORMULAS[statement]) {
const target = rowsByKey.get(formula.key);
if (!target) {
continue;
}
let usedFormula = target.formulaKey !== null;
for (const period of periods) {
if (target.values[period.id] !== null) {
continue;
}
const computed = formula.compute(rowsByKey, period.id);
if (computed === null) {
continue;
}
target.values[period.id] = computed;
target.resolvedSourceRowKeys[period.id] = null;
usedFormula = true;
}
if (usedFormula) {
target.formulaKey = formula.formulaKey;
}
}
}
export function buildStandardizedRows(input: {
rows: TaxonomyStatementRow[];
statement: Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>;
periods: FinancialStatementPeriod[];
facts: TaxonomyFactRow[];
}) {
const definitions = CANONICAL_ROW_DEFINITIONS[input.statement];
const rowsByKey = new Map<string, StandardizedFinancialRow>();
const matchedRowKeys = new Set<string>();
for (const definition of definitions) {
const matches = input.rows.filter((row) => matchesDefinition(row, definition));
for (const row of matches) {
matchedRowKeys.add(row.key);
}
const canonical = buildCanonicalRow(definition, matches, input.facts, input.periods);
const hasAnyValue = Object.values(canonical.values).some((value) => value !== null);
if (hasAnyValue || definition.key.startsWith('gross_') || definition.key === 'operating_margin' || definition.key === 'effective_tax_rate' || definition.key === 'ebitda' || definition.key === 'total_debt' || definition.key === 'net_cash_position' || definition.key === 'free_cash_flow') {
rowsByKey.set(definition.key, canonical);
}
}
applyFormulas(rowsByKey, input.statement, input.periods);
const unmatchedRows = input.rows
.filter((row) => !matchedRowKeys.has(row.key))
.map((row) => ({
key: `other:${row.key}`,
label: row.label,
category: 'other',
order: 10_000 + row.order,
unit: inferUnit(Object.values(row.units)[0] ?? null, 'currency'),
values: { ...row.values },
sourceConcepts: [row.qname],
sourceRowKeys: [row.key],
sourceFactIds: [...row.sourceFactIds],
formulaKey: null,
hasDimensions: row.hasDimensions,
resolvedSourceRowKeys: Object.fromEntries(
input.periods.map((period) => [period.id, period.id in row.values ? row.key : null])
)
} satisfies StandardizedFinancialRow));
return [...rowsByKey.values(), ...unmatchedRows].sort((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
return left.label.localeCompare(right.label);
});
}
export function buildDimensionBreakdown(
facts: TaxonomyFactRow[],
periods: FinancialStatementPeriod[],
faithfulRows: TaxonomyStatementRow[],
standardizedRows: StandardizedFinancialRow[]
) {
const periodByFilingId = new Map<number, FinancialStatementPeriod>();
for (const period of periods) {
periodByFilingId.set(period.filingId, period);
}
const faithfulRowByKey = new Map(faithfulRows.map((row) => [row.key, row]));
const standardizedRowsBySource = new Map<string, StandardizedFinancialRow[]>();
for (const row of standardizedRows) {
for (const sourceRowKey of row.sourceRowKeys) {
const existing = standardizedRowsBySource.get(sourceRowKey);
if (existing) {
existing.push(row);
} else {
standardizedRowsBySource.set(sourceRowKey, [row]);
}
}
}
const map = new Map<string, DimensionBreakdownRow[]>();
const pushRow = (key: string, row: DimensionBreakdownRow) => {
const existing = map.get(key);
if (existing) {
existing.push(row);
} else {
map.set(key, [row]);
}
};
for (const fact of facts) {
if (fact.dimensions.length === 0) {
continue;
}
const period = periodByFilingId.get(fact.filingId) ?? null;
if (!period || !factMatchesPeriod(fact, period)) {
continue;
}
const faithfulRow = faithfulRowByKey.get(fact.conceptKey) ?? null;
const standardizedMatches = standardizedRowsBySource.get(fact.conceptKey) ?? [];
for (const dimension of fact.dimensions) {
const faithfulDimensionRow: DimensionBreakdownRow = {
rowKey: fact.conceptKey,
concept: fact.qname,
sourceRowKey: fact.conceptKey,
sourceLabel: faithfulRow?.label ?? null,
periodId: period.id,
axis: dimension.axis,
member: dimension.member,
value: fact.value,
unit: fact.unit,
provenanceType: 'taxonomy'
};
pushRow(fact.conceptKey, faithfulDimensionRow);
for (const standardizedRow of standardizedMatches) {
pushRow(standardizedRow.key, {
...faithfulDimensionRow,
rowKey: standardizedRow.key
});
}
}
}
return map.size > 0 ? Object.fromEntries(map.entries()) : null;
}
export function cloneStandardizedRows(rows: StandardizedFinancialRow[]) {
return rows.map((row) => ({
...row,
values: { ...row.values },
sourceConcepts: [...row.sourceConcepts],
sourceRowKeys: [...row.sourceRowKeys],
sourceFactIds: [...row.sourceFactIds],
resolvedSourceRowKeys: { ...row.resolvedSourceRowKeys }
}));
}
export function buildLtmStandardizedRows(
quarterlyRows: StandardizedFinancialRow[],
quarterlyPeriods: FinancialStatementPeriod[],
ltmPeriods: FinancialStatementPeriod[],
statement: Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>
) {
const sortedQuarterlyPeriods = [...quarterlyPeriods].sort((left, right) => {
return Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate);
});
const result = cloneStandardizedRows(quarterlyRows).map((row) => ({
...row,
values: {} as Record<string, number | null>,
resolvedSourceRowKeys: {} as Record<string, string | null>
}));
for (const row of result) {
const source = quarterlyRows.find((entry) => entry.key === row.key);
if (!source) {
continue;
}
for (const ltmPeriod of ltmPeriods) {
const anchorIndex = sortedQuarterlyPeriods.findIndex((period) => `ltm:${period.id}` === ltmPeriod.id);
if (anchorIndex < 3) {
continue;
}
const slice = sortedQuarterlyPeriods.slice(anchorIndex - 3, anchorIndex + 1);
const sourceValues = slice.map((period) => source.values[period.id] ?? null);
row.values[ltmPeriod.id] = statement === 'balance'
? sourceValues[sourceValues.length - 1] ?? null
: sumValues(sourceValues);
row.resolvedSourceRowKeys[ltmPeriod.id] = source.formulaKey ? null : source.resolvedSourceRowKeys[slice[slice.length - 1]?.id ?? ''] ?? null;
}
}
return result;
}

View File

@@ -0,0 +1,82 @@
import type {
FinancialSurfaceKind,
RatioRow,
StandardizedFinancialRow,
StructuredKpiRow,
TrendSeries
} from '@/lib/types';
import { KPI_CATEGORY_ORDER } from '@/lib/server/financials/kpi-registry';
import { RATIO_CATEGORY_ORDER } from '@/lib/server/financials/ratios';
function toTrendSeriesRow(row: {
key: string;
label: string;
category: string;
unit: TrendSeries['unit'];
values: Record<string, number | null>;
}) {
return {
key: row.key,
label: row.label,
category: row.category,
unit: row.unit,
values: row.values
} satisfies TrendSeries;
}
export function buildFinancialCategories(rows: Array<{ category: string }>, surfaceKind: FinancialSurfaceKind) {
const counts = new Map<string, number>();
for (const row of rows) {
counts.set(row.category, (counts.get(row.category) ?? 0) + 1);
}
const order = surfaceKind === 'ratios'
? [...RATIO_CATEGORY_ORDER]
: surfaceKind === 'segments_kpis'
? [...KPI_CATEGORY_ORDER]
: [...counts.keys()];
return order
.filter((key) => (counts.get(key) ?? 0) > 0)
.map((key) => ({
key,
label: key.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()),
count: counts.get(key) ?? 0
}));
}
export function buildTrendSeries(input: {
surfaceKind: FinancialSurfaceKind;
statementRows?: StandardizedFinancialRow[];
ratioRows?: RatioRow[];
kpiRows?: StructuredKpiRow[];
}) {
switch (input.surfaceKind) {
case 'income_statement':
return (input.statementRows ?? [])
.filter((row) => row.key === 'revenue' || row.key === 'net_income')
.map(toTrendSeriesRow);
case 'balance_sheet':
return (input.statementRows ?? [])
.filter((row) => row.key === 'total_assets' || row.key === 'cash_and_equivalents' || row.key === 'total_debt')
.map(toTrendSeriesRow);
case 'cash_flow_statement':
return (input.statementRows ?? [])
.filter((row) => row.key === 'operating_cash_flow' || row.key === 'free_cash_flow' || row.key === 'capital_expenditures')
.map(toTrendSeriesRow);
case 'ratios':
return (input.ratioRows ?? [])
.filter((row) => row.category === 'margins')
.map(toTrendSeriesRow);
case 'segments_kpis': {
const rows = input.kpiRows ?? [];
const firstCategory = buildFinancialCategories(rows, 'segments_kpis')[0]?.key ?? null;
return rows
.filter((row) => row.category === firstCategory)
.slice(0, 4)
.map(toTrendSeriesRow);
}
default:
return [];
}
}