Files
Neon-Desk/lib/server/repos/filing-statements.ts

260 lines
6.8 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"
| "disclosure"
| "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[]>;
};
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;
};
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);
}
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,
};
}
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,
},
);
}