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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user