316 lines
9.9 KiB
TypeScript
316 lines
9.9 KiB
TypeScript
import type {
|
|
CompanyFinancialStatementsResponse,
|
|
DimensionBreakdownRow,
|
|
FilingFaithfulStatementRow,
|
|
FinancialHistoryWindow,
|
|
FinancialStatementKind,
|
|
FinancialStatementMode,
|
|
FinancialStatementPeriod,
|
|
StandardizedStatementRow
|
|
} from '@/lib/types';
|
|
import { listFilingsRecords } from '@/lib/server/repos/filings';
|
|
import {
|
|
countFilingStatementSnapshotStatuses,
|
|
type DimensionStatementSnapshotRow,
|
|
type FilingFaithfulStatementSnapshotRow,
|
|
type FilingStatementSnapshotRecord,
|
|
listFilingStatementSnapshotsByTicker,
|
|
type StandardizedStatementSnapshotRow
|
|
} from '@/lib/server/repos/filing-statements';
|
|
|
|
type GetCompanyFinancialStatementsInput = {
|
|
ticker: string;
|
|
mode: FinancialStatementMode;
|
|
statement: FinancialStatementKind;
|
|
window: FinancialHistoryWindow;
|
|
includeDimensions: boolean;
|
|
cursor?: string | null;
|
|
limit?: number;
|
|
v2Enabled: boolean;
|
|
queuedSync: boolean;
|
|
};
|
|
|
|
type FinancialStatementRowByMode = StandardizedStatementRow | FilingFaithfulStatementRow;
|
|
|
|
function safeTicker(input: string) {
|
|
return input.trim().toUpperCase();
|
|
}
|
|
|
|
function isFinancialForm(type: string): type is '10-K' | '10-Q' {
|
|
return type === '10-K' || type === '10-Q';
|
|
}
|
|
|
|
function rowDimensionMatcher(row: { key: string; concept: string | null }, item: DimensionStatementSnapshotRow) {
|
|
const concept = row.concept?.toLowerCase() ?? '';
|
|
const itemConcept = item.concept?.toLowerCase() ?? '';
|
|
if (item.rowKey === row.key) {
|
|
return true;
|
|
}
|
|
|
|
return Boolean(concept && itemConcept && concept === itemConcept);
|
|
}
|
|
|
|
function periodSorter(left: FinancialStatementPeriod, right: FinancialStatementPeriod) {
|
|
const byDate = Date.parse(left.filingDate) - Date.parse(right.filingDate);
|
|
if (Number.isFinite(byDate) && byDate !== 0) {
|
|
return byDate;
|
|
}
|
|
|
|
return left.id.localeCompare(right.id);
|
|
}
|
|
|
|
function resolveDimensionPeriodId(rawPeriodId: string, periods: FinancialStatementPeriod[]) {
|
|
const exact = periods.find((period) => period.id === rawPeriodId);
|
|
if (exact) {
|
|
return exact.id;
|
|
}
|
|
|
|
const byDate = periods.find((period) => period.filingDate === rawPeriodId || period.periodEnd === rawPeriodId);
|
|
return byDate?.id ?? null;
|
|
}
|
|
|
|
function getRowsForSnapshot(
|
|
snapshot: FilingStatementSnapshotRecord,
|
|
mode: FinancialStatementMode,
|
|
statement: FinancialStatementKind
|
|
) {
|
|
if (mode === 'standardized') {
|
|
return snapshot.standardized_bundle?.statements?.[statement] ?? [];
|
|
}
|
|
|
|
return snapshot.statement_bundle?.statements?.[statement] ?? [];
|
|
}
|
|
|
|
function buildPeriods(
|
|
snapshots: FilingStatementSnapshotRecord[],
|
|
mode: FinancialStatementMode,
|
|
statement: FinancialStatementKind
|
|
) {
|
|
const map = new Map<string, FinancialStatementPeriod>();
|
|
|
|
for (const snapshot of snapshots) {
|
|
const rows = getRowsForSnapshot(snapshot, mode, statement);
|
|
if (rows.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const sourcePeriods = mode === 'standardized'
|
|
? snapshot.standardized_bundle?.periods
|
|
: snapshot.statement_bundle?.periods;
|
|
|
|
for (const period of sourcePeriods ?? []) {
|
|
if (!map.has(period.id)) {
|
|
map.set(period.id, {
|
|
id: period.id,
|
|
filingId: period.filingId,
|
|
accessionNumber: period.accessionNumber,
|
|
filingDate: period.filingDate,
|
|
periodEnd: period.periodEnd,
|
|
filingType: period.filingType,
|
|
periodLabel: period.periodLabel
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...map.values()].sort(periodSorter);
|
|
}
|
|
|
|
function buildRows(
|
|
snapshots: FilingStatementSnapshotRecord[],
|
|
periods: FinancialStatementPeriod[],
|
|
mode: FinancialStatementMode,
|
|
statement: FinancialStatementKind,
|
|
includeDimensions: boolean
|
|
) {
|
|
const rowMap = new Map<string, FinancialStatementRowByMode>();
|
|
const dimensionMap = includeDimensions
|
|
? new Map<string, DimensionBreakdownRow[]>()
|
|
: null;
|
|
|
|
for (const snapshot of snapshots) {
|
|
const rows = getRowsForSnapshot(snapshot, mode, statement);
|
|
const dimensions = snapshot.dimension_bundle?.statements?.[statement] ?? [];
|
|
|
|
if (mode === 'standardized') {
|
|
for (const sourceRow of rows as StandardizedStatementSnapshotRow[]) {
|
|
const existing = rowMap.get(sourceRow.key) as StandardizedStatementRow | undefined;
|
|
const hasDimensions = dimensions.some((item) => rowDimensionMatcher(sourceRow, item));
|
|
|
|
if (!existing) {
|
|
rowMap.set(sourceRow.key, {
|
|
key: sourceRow.key,
|
|
label: sourceRow.label,
|
|
concept: sourceRow.concept,
|
|
category: sourceRow.category,
|
|
sourceConcepts: [...sourceRow.sourceConcepts],
|
|
values: { ...sourceRow.values },
|
|
hasDimensions
|
|
});
|
|
continue;
|
|
}
|
|
|
|
existing.hasDimensions = existing.hasDimensions || hasDimensions;
|
|
for (const concept of sourceRow.sourceConcepts) {
|
|
if (!existing.sourceConcepts.includes(concept)) {
|
|
existing.sourceConcepts.push(concept);
|
|
}
|
|
}
|
|
|
|
for (const [periodId, value] of Object.entries(sourceRow.values)) {
|
|
if (!(periodId in existing.values)) {
|
|
existing.values[periodId] = value;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for (const sourceRow of rows as FilingFaithfulStatementSnapshotRow[]) {
|
|
const rowKey = sourceRow.concept ? `concept-${sourceRow.concept.toLowerCase()}` : `label-${sourceRow.key}`;
|
|
const existing = rowMap.get(rowKey) as FilingFaithfulStatementRow | undefined;
|
|
const hasDimensions = dimensions.some((item) => rowDimensionMatcher(sourceRow, item));
|
|
|
|
if (!existing) {
|
|
rowMap.set(rowKey, {
|
|
key: rowKey,
|
|
label: sourceRow.label,
|
|
concept: sourceRow.concept,
|
|
order: sourceRow.order,
|
|
depth: sourceRow.depth,
|
|
isSubtotal: sourceRow.isSubtotal,
|
|
values: { ...sourceRow.values },
|
|
hasDimensions
|
|
});
|
|
continue;
|
|
}
|
|
|
|
existing.hasDimensions = existing.hasDimensions || hasDimensions;
|
|
existing.order = Math.min(existing.order, sourceRow.order);
|
|
existing.depth = Math.min(existing.depth, sourceRow.depth);
|
|
existing.isSubtotal = existing.isSubtotal || sourceRow.isSubtotal;
|
|
for (const [periodId, value] of Object.entries(sourceRow.values)) {
|
|
if (!(periodId in existing.values)) {
|
|
existing.values[periodId] = value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (dimensionMap) {
|
|
for (const item of dimensions) {
|
|
const periodId = resolveDimensionPeriodId(item.periodId, periods);
|
|
if (!periodId) {
|
|
continue;
|
|
}
|
|
|
|
const entry: DimensionBreakdownRow = {
|
|
rowKey: item.rowKey,
|
|
concept: item.concept,
|
|
periodId,
|
|
axis: item.axis,
|
|
member: item.member,
|
|
value: item.value,
|
|
unit: item.unit
|
|
};
|
|
|
|
const group = dimensionMap.get(item.rowKey);
|
|
if (group) {
|
|
group.push(entry);
|
|
} else {
|
|
dimensionMap.set(item.rowKey, [entry]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const rows = [...rowMap.values()].sort((a, b) => {
|
|
const left = mode === 'standardized' ? a.label : `${(a as FilingFaithfulStatementRow).order.toString().padStart(5, '0')}::${a.label}`;
|
|
const right = mode === 'standardized' ? b.label : `${(b as FilingFaithfulStatementRow).order.toString().padStart(5, '0')}::${b.label}`;
|
|
return left.localeCompare(right);
|
|
});
|
|
|
|
if (mode === 'standardized') {
|
|
const standardized = rows as StandardizedStatementRow[];
|
|
const core = standardized.filter((row) => row.category === 'core');
|
|
const nonCore = standardized.filter((row) => row.category !== 'core');
|
|
const orderedRows = [...core, ...nonCore];
|
|
|
|
return {
|
|
rows: orderedRows,
|
|
dimensions: dimensionMap ? Object.fromEntries(dimensionMap.entries()) : null
|
|
};
|
|
}
|
|
|
|
return {
|
|
rows: rows as FilingFaithfulStatementRow[],
|
|
dimensions: dimensionMap ? Object.fromEntries(dimensionMap.entries()) : null
|
|
};
|
|
}
|
|
|
|
export function defaultFinancialSyncLimit(window: FinancialHistoryWindow) {
|
|
return window === 'all' ? 120 : 60;
|
|
}
|
|
|
|
export async function getCompanyFinancialStatements(input: GetCompanyFinancialStatementsInput): Promise<CompanyFinancialStatementsResponse> {
|
|
const ticker = safeTicker(input.ticker);
|
|
const snapshotResult = await listFilingStatementSnapshotsByTicker({
|
|
ticker,
|
|
window: input.window,
|
|
limit: input.limit,
|
|
cursor: input.cursor
|
|
});
|
|
|
|
const statuses = await countFilingStatementSnapshotStatuses(ticker);
|
|
const filings = await listFilingsRecords({
|
|
ticker,
|
|
limit: input.window === 'all' ? 250 : 120
|
|
});
|
|
|
|
const financialFilings = filings.filter((filing) => isFinancialForm(filing.filing_type));
|
|
const periods = buildPeriods(snapshotResult.snapshots, input.mode, input.statement);
|
|
const rowResult = buildRows(
|
|
snapshotResult.snapshots,
|
|
periods,
|
|
input.mode,
|
|
input.statement,
|
|
input.includeDimensions
|
|
);
|
|
|
|
const latestFiling = filings[0] ?? null;
|
|
|
|
return {
|
|
company: {
|
|
ticker,
|
|
companyName: latestFiling?.company_name ?? ticker,
|
|
cik: latestFiling?.cik ?? null
|
|
},
|
|
mode: input.mode,
|
|
statement: input.statement,
|
|
window: input.window,
|
|
periods,
|
|
rows: rowResult.rows,
|
|
nextCursor: snapshotResult.nextCursor,
|
|
coverage: {
|
|
filings: periods.length,
|
|
rows: rowResult.rows.length,
|
|
dimensions: rowResult.dimensions
|
|
? Object.values(rowResult.dimensions).reduce((total, rows) => total + rows.length, 0)
|
|
: 0
|
|
},
|
|
dataSourceStatus: {
|
|
enabled: input.v2Enabled,
|
|
hydratedFilings: statuses.ready,
|
|
partialFilings: statuses.partial,
|
|
failedFilings: statuses.failed,
|
|
pendingFilings: Math.max(0, financialFilings.length - statuses.ready - statuses.partial - statuses.failed),
|
|
queuedSync: input.queuedSync
|
|
},
|
|
dimensionBreakdown: rowResult.dimensions
|
|
};
|
|
}
|
|
|
|
export const __financialStatementsInternals = {
|
|
buildPeriods,
|
|
buildRows,
|
|
defaultFinancialSyncLimit
|
|
};
|