Files
Neon-Desk/lib/server/financials/cadence.ts

426 lines
13 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[];
};
type CandidateSelectionInput = {
rows: TaxonomyStatementRow[];
periods: FinancialStatementPeriod[];
};
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 isPresentedStatementRow(row: TaxonomyStatementRow) {
return row.roleUri !== null && row.order !== Number.MAX_SAFE_INTEGER;
}
function isPlausiblePrimaryPeriod(period: FinancialStatementPeriod, filingDate: string) {
const filingEpoch = parseEpoch(filingDate);
const periodEndEpoch = parseEpoch(period.periodEnd ?? period.filingDate);
if (Number.isFinite(filingEpoch) && Number.isFinite(periodEndEpoch) && periodEndEpoch > filingEpoch) {
return false;
}
const periodStartEpoch = parseEpoch(period.periodStart);
if (Number.isFinite(filingEpoch) && Number.isFinite(periodStartEpoch) && periodStartEpoch > filingEpoch) {
return false;
}
return true;
}
function candidatePeriodsForRows(input: CandidateSelectionInput) {
const usedPeriodIds = new Set<string>();
for (const row of input.rows) {
for (const periodId of Object.keys(row.values)) {
usedPeriodIds.add(periodId);
}
}
return input.periods.filter((period) => usedPeriodIds.has(period.id));
}
function coverageScore(rows: TaxonomyStatementRow[], periodId: string) {
let count = 0;
for (const row of rows) {
if (periodId in row.values) {
count += 1;
}
}
return count;
}
function compareBalancePeriods(
left: FinancialStatementPeriod,
right: FinancialStatementPeriod,
rowCoverage: Map<string, number>
) {
const leftInstant = isInstantPeriod(left) ? 1 : 0;
const rightInstant = isInstantPeriod(right) ? 1 : 0;
if (leftInstant !== rightInstant) {
return rightInstant - leftInstant;
}
const leftCoverage = rowCoverage.get(left.id) ?? 0;
const rightCoverage = rowCoverage.get(right.id) ?? 0;
if (leftCoverage !== rightCoverage) {
return rightCoverage - leftCoverage;
}
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;
}
return left.id.localeCompare(right.id);
}
function compareFlowPeriods(
left: FinancialStatementPeriod,
right: FinancialStatementPeriod,
rowCoverage: Map<string, number>,
targetDays: number,
preferLaterBeforeDistance: boolean
) {
const leftDuration = isInstantPeriod(left) ? 0 : 1;
const rightDuration = isInstantPeriod(right) ? 0 : 1;
if (leftDuration !== rightDuration) {
return rightDuration - leftDuration;
}
const leftDate = parseEpoch(left.periodEnd ?? left.filingDate);
const rightDate = parseEpoch(right.periodEnd ?? right.filingDate);
if (preferLaterBeforeDistance && Number.isFinite(leftDate) && Number.isFinite(rightDate) && leftDate !== rightDate) {
return rightDate - leftDate;
}
const leftCoverage = rowCoverage.get(left.id) ?? 0;
const rightCoverage = rowCoverage.get(right.id) ?? 0;
if (leftCoverage !== rightCoverage) {
return rightCoverage - leftCoverage;
}
const leftDistance = Math.abs((periodDurationDays(left) ?? targetDays) - targetDays);
const rightDistance = Math.abs((periodDurationDays(right) ?? targetDays) - targetDays);
if (leftDistance !== rightDistance) {
return leftDistance - rightDistance;
}
if (Number.isFinite(leftDate) && Number.isFinite(rightDate) && leftDate !== rightDate) {
return rightDate - leftDate;
}
return left.id.localeCompare(right.id);
}
function chooseCandidates(snapshot: FilingTaxonomySnapshotRecord, rows: TaxonomyStatementRow[]) {
const periods = snapshot.periods ?? [];
const presentedRows = rows.filter(isPresentedStatementRow);
const plausiblePresented = candidatePeriodsForRows({
rows: presentedRows,
periods
}).filter((period) => isPlausiblePrimaryPeriod(period, snapshot.filing_date));
if (plausiblePresented.length > 0) {
return {
candidates: plausiblePresented,
coverageRows: presentedRows,
fallbackMode: false
};
}
const allCandidates = candidatePeriodsForRows({
rows,
periods
});
const plausibleAll = allCandidates.filter((period) => isPlausiblePrimaryPeriod(period, snapshot.filing_date));
if (plausibleAll.length > 0) {
return {
candidates: plausibleAll,
coverageRows: rows,
fallbackMode: true
};
}
return {
candidates: allCandidates,
coverageRows: rows,
fallbackMode: true
};
}
function selectPrimaryPeriodFromSnapshot(
snapshot: FilingTaxonomySnapshotRecord,
statement: FinancialStatementKind
) {
const rows = snapshot.statement_rows?.[statement] ?? [];
if (rows.length === 0) {
return null;
}
const { candidates, coverageRows, fallbackMode } = chooseCandidates(snapshot, rows);
if (candidates.length === 0) {
return null;
}
const rowCoverage = new Map<string, number>(
candidates.map((period) => [period.id, coverageScore(coverageRows, period.id)])
);
if (statement === 'balance') {
return [...candidates].sort((left, right) => compareBalancePeriods(left, right, rowCoverage))[0] ?? null;
}
const targetDays = preferredDurationDays(snapshot.filing_type);
return [...candidates].sort((left, right) => compareFlowPeriods(left, right, rowCoverage, targetDays, fallbackMode))[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);
}