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; 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(); 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 ) { 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, 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( 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 ) { const rowMap = new Map(); 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) { if (values.some((value) => value === null)) { return null; } return values.reduce((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(); 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); }