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:
53
lib/server/financials/bundles.ts
Normal file
53
lib/server/financials/bundles.ts
Normal 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
|
||||
});
|
||||
}
|
||||
303
lib/server/financials/cadence.ts
Normal file
303
lib/server/financials/cadence.ts
Normal 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);
|
||||
}
|
||||
85
lib/server/financials/canonical-definitions.ts
Normal file
85
lib/server/financials/canonical-definitions.ts
Normal 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
|
||||
};
|
||||
65
lib/server/financials/kpi-dimensions.test.ts
Normal file
65
lib/server/financials/kpi-dimensions.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
159
lib/server/financials/kpi-dimensions.ts
Normal file
159
lib/server/financials/kpi-dimensions.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
131
lib/server/financials/kpi-notes.ts
Normal file
131
lib/server/financials/kpi-notes.ts
Normal 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));
|
||||
}
|
||||
120
lib/server/financials/kpi-registry.ts
Normal file
120
lib/server/financials/kpi-registry.ts
Normal 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;
|
||||
81
lib/server/financials/ratios.test.ts
Normal file
81
lib/server/financials/ratios.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
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;
|
||||
450
lib/server/financials/standardize.ts
Normal file
450
lib/server/financials/standardize.ts
Normal 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;
|
||||
}
|
||||
82
lib/server/financials/trend-series.ts
Normal file
82
lib/server/financials/trend-series.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user