Files
Neon-Desk/lib/server/repos/filing-statements.ts
2026-03-06 14:40:43 -05:00

231 lines
6.6 KiB
TypeScript

import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
import { db } from '@/lib/server/db';
import { filingStatementSnapshot } from '@/lib/server/db/schema';
type FilingStatementSnapshotRow = typeof filingStatementSnapshot.$inferSelect;
type ParseStatus = 'ready' | 'partial' | 'failed';
type SnapshotSource = 'sec_filing_summary' | 'xbrl_instance' | 'companyfacts_fallback';
type FinancialStatementKind = 'income' | 'balance' | 'cash_flow' | 'equity' | 'comprehensive_income';
type StatementValuesByPeriod = Record<string, number | null>;
export type FilingStatementSnapshotPeriod = {
id: string;
filingId: number;
accessionNumber: string;
filingDate: string;
periodStart: string | null;
periodEnd: string | null;
filingType: '10-K' | '10-Q';
periodLabel: string;
};
export type FilingFaithfulStatementSnapshotRow = {
key: string;
label: string;
concept: string | null;
order: number;
depth: number;
isSubtotal: boolean;
values: StatementValuesByPeriod;
};
export type StandardizedStatementSnapshotRow = {
key: string;
label: string;
concept: string;
category: string;
sourceConcepts: string[];
values: StatementValuesByPeriod;
};
export type DimensionStatementSnapshotRow = {
rowKey: string;
concept: string | null;
periodId: string;
axis: string;
member: string;
value: number | null;
unit: string | null;
};
export type FilingStatementBundle = {
periods: FilingStatementSnapshotPeriod[];
statements: Record<FinancialStatementKind, FilingFaithfulStatementSnapshotRow[]>;
};
export type StandardizedStatementBundle = {
periods: FilingStatementSnapshotPeriod[];
statements: Record<FinancialStatementKind, StandardizedStatementSnapshotRow[]>;
};
export type DimensionStatementBundle = {
statements: Record<FinancialStatementKind, DimensionStatementSnapshotRow[]>;
};
export type FilingStatementSnapshotRecord = {
id: number;
filing_id: number;
ticker: string;
filing_date: string;
filing_type: '10-K' | '10-Q';
period_end: string | null;
statement_bundle: FilingStatementBundle | null;
standardized_bundle: StandardizedStatementBundle | null;
dimension_bundle: DimensionStatementBundle | null;
parse_status: ParseStatus;
parse_error: string | null;
source: SnapshotSource;
created_at: string;
updated_at: string;
};
export type UpsertFilingStatementSnapshotInput = {
filing_id: number;
ticker: string;
filing_date: string;
filing_type: '10-K' | '10-Q';
period_end: string | null;
statement_bundle: FilingStatementBundle | null;
standardized_bundle: StandardizedStatementBundle | null;
dimension_bundle: DimensionStatementBundle | null;
parse_status: ParseStatus;
parse_error: string | null;
source: SnapshotSource;
};
function toSnapshotRecord(row: FilingStatementSnapshotRow): FilingStatementSnapshotRecord {
return {
id: row.id,
filing_id: row.filing_id,
ticker: row.ticker,
filing_date: row.filing_date,
filing_type: row.filing_type,
period_end: row.period_end,
statement_bundle: row.statement_bundle ?? null,
standardized_bundle: row.standardized_bundle ?? null,
dimension_bundle: row.dimension_bundle ?? null,
parse_status: row.parse_status,
parse_error: row.parse_error,
source: row.source,
created_at: row.created_at,
updated_at: row.updated_at
};
}
function tenYearsAgoIso() {
const date = new Date();
date.setUTCFullYear(date.getUTCFullYear() - 10);
return date.toISOString().slice(0, 10);
}
export async function getFilingStatementSnapshotByFilingId(filingId: number) {
const [row] = await db
.select()
.from(filingStatementSnapshot)
.where(eq(filingStatementSnapshot.filing_id, filingId))
.limit(1);
return row ? toSnapshotRecord(row) : null;
}
export async function upsertFilingStatementSnapshot(input: UpsertFilingStatementSnapshotInput) {
const now = new Date().toISOString();
const [saved] = await db
.insert(filingStatementSnapshot)
.values({
filing_id: input.filing_id,
ticker: input.ticker,
filing_date: input.filing_date,
filing_type: input.filing_type,
period_end: input.period_end,
statement_bundle: input.statement_bundle,
standardized_bundle: input.standardized_bundle,
dimension_bundle: input.dimension_bundle,
parse_status: input.parse_status,
parse_error: input.parse_error,
source: input.source,
created_at: now,
updated_at: now
})
.onConflictDoUpdate({
target: filingStatementSnapshot.filing_id,
set: {
ticker: input.ticker,
filing_date: input.filing_date,
filing_type: input.filing_type,
period_end: input.period_end,
statement_bundle: input.statement_bundle,
standardized_bundle: input.standardized_bundle,
dimension_bundle: input.dimension_bundle,
parse_status: input.parse_status,
parse_error: input.parse_error,
source: input.source,
updated_at: now
}
})
.returning();
return toSnapshotRecord(saved);
}
export async function listFilingStatementSnapshotsByTicker(input: {
ticker: string;
window: '10y' | 'all';
limit?: number;
cursor?: string | null;
}) {
const safeLimit = Math.min(Math.max(Math.trunc(input.limit ?? 40), 1), 120);
const cursorId = input.cursor ? Number.parseInt(input.cursor, 10) : null;
const constraints = [eq(filingStatementSnapshot.ticker, input.ticker.trim().toUpperCase())];
if (input.window === '10y') {
constraints.push(gte(filingStatementSnapshot.filing_date, tenYearsAgoIso()));
}
if (cursorId && Number.isFinite(cursorId) && cursorId > 0) {
constraints.push(lt(filingStatementSnapshot.id, cursorId));
}
const rows = await db
.select()
.from(filingStatementSnapshot)
.where(and(...constraints))
.orderBy(desc(filingStatementSnapshot.filing_date), desc(filingStatementSnapshot.id))
.limit(safeLimit + 1);
const hasMore = rows.length > safeLimit;
const usedRows = hasMore ? rows.slice(0, safeLimit) : rows;
const nextCursor = hasMore
? String(usedRows[usedRows.length - 1]?.id ?? '')
: null;
return {
snapshots: usedRows.map(toSnapshotRecord),
nextCursor
};
}
export async function countFilingStatementSnapshotStatuses(ticker: string) {
const rows = await db
.select({
status: filingStatementSnapshot.parse_status,
count: sql<string>`count(*)`
})
.from(filingStatementSnapshot)
.where(eq(filingStatementSnapshot.ticker, ticker.trim().toUpperCase()))
.groupBy(filingStatementSnapshot.parse_status);
return rows.reduce<Record<ParseStatus, number>>((acc, row) => {
acc[row.status] = Number(row.count);
return acc;
}, {
ready: 0,
partial: 0,
failed: 0
});
}