231 lines
6.6 KiB
TypeScript
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
|
|
});
|
|
}
|