Files
Neon-Desk/lib/server/financials/cadence.ts
francy51 db01f207a5 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
2026-03-07 15:16:35 -05:00

304 lines
9.4 KiB
TypeScript

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);
}