260 lines
6.8 KiB
TypeScript
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,
|
|
},
|
|
);
|
|
}
|