feat(financials-v2): hydrate filing statements and aggregate history
This commit is contained in:
229
lib/server/repos/filing-statements.ts
Normal file
229
lib/server/repos/filing-statements.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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;
|
||||
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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user