426 lines
13 KiB
TypeScript
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);
|
|
}
|