diff --git a/hooks/use-link-prefetch.ts b/hooks/use-link-prefetch.ts index 6980b20..ad73092 100644 --- a/hooks/use-link-prefetch.ts +++ b/hooks/use-link-prefetch.ts @@ -6,6 +6,7 @@ import { useCallback } from 'react'; import { aiReportQueryOptions, companyAnalysisQueryOptions, + companyFinancialStatementsQueryOptions, filingsQueryOptions, holdingsQueryOptions, latestPortfolioInsightQueryOptions, @@ -37,6 +38,13 @@ export function useLinkPrefetch() { router.prefetch(financialsHref); void queryClient.prefetchQuery(companyAnalysisQueryOptions(normalizedTicker)); + void queryClient.prefetchQuery(companyFinancialStatementsQueryOptions({ + ticker: normalizedTicker, + mode: 'standardized', + statement: 'income', + window: '10y', + includeDimensions: false + })); void queryClient.prefetchQuery(filingsQueryOptions({ ticker: normalizedTicker, limit: 120 })); }, [queryClient, router]); diff --git a/lib/api.ts b/lib/api.ts index d0a69fe..2e06939 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -3,8 +3,12 @@ import type { App } from '@/lib/server/api/app'; import type { CompanyAiReportDetail, CompanyAnalysis, + CompanyFinancialStatementsResponse, Filing, Holding, + FinancialHistoryWindow, + FinancialStatementKind, + FinancialStatementMode, PortfolioInsight, PortfolioSummary, Task, @@ -185,6 +189,39 @@ export async function getCompanyAnalysis(ticker: string) { return await unwrapData<{ analysis: CompanyAnalysis }>(result, 'Unable to fetch company analysis'); } +export async function getCompanyFinancialStatements(input: { + ticker: string; + mode: FinancialStatementMode; + statement: FinancialStatementKind; + window: FinancialHistoryWindow; + includeDimensions?: boolean; + cursor?: string | null; + limit?: number; +}) { + const query = { + ticker: input.ticker.trim().toUpperCase(), + mode: input.mode, + statement: input.statement, + window: input.window, + includeDimensions: input.includeDimensions ? 'true' : 'false', + ...(typeof input.cursor === 'string' && input.cursor.trim().length > 0 + ? { cursor: input.cursor.trim() } + : {}), + ...(typeof input.limit === 'number' && Number.isFinite(input.limit) + ? { limit: input.limit } + : {}) + }; + + const result = await client.api.financials.company.get({ + $query: query + }); + + return await unwrapData<{ financials: CompanyFinancialStatementsResponse }>( + result, + 'Unable to fetch company financial statements' + ); +} + export async function getCompanyAiReport(accessionNumber: string) { const normalizedAccession = accessionNumber.trim(); diff --git a/lib/query/keys.ts b/lib/query/keys.ts index b2e642e..55a31c3 100644 --- a/lib/query/keys.ts +++ b/lib/query/keys.ts @@ -1,5 +1,14 @@ export const queryKeys = { companyAnalysis: (ticker: string) => ['analysis', ticker] as const, + companyFinancialStatements: ( + ticker: string, + mode: string, + statement: string, + window: string, + includeDimensions: boolean, + cursor: string | null, + limit: number + ) => ['financials-v2', ticker, mode, statement, window, includeDimensions ? 'dims' : 'no-dims', cursor ?? '', limit] as const, filings: (ticker: string | null, limit: number) => ['filings', ticker ?? '', limit] as const, report: (accessionNumber: string) => ['report', accessionNumber] as const, watchlist: () => ['watchlist'] as const, diff --git a/lib/query/options.ts b/lib/query/options.ts index 3cf3e61..8282cee 100644 --- a/lib/query/options.ts +++ b/lib/query/options.ts @@ -2,6 +2,7 @@ import { queryOptions } from '@tanstack/react-query'; import { getCompanyAiReport, getCompanyAnalysis, + getCompanyFinancialStatements, getLatestPortfolioInsight, getPortfolioSummary, getTask, @@ -11,6 +12,11 @@ import { listWatchlist } from '@/lib/api'; import { queryKeys } from '@/lib/query/keys'; +import type { + FinancialHistoryWindow, + FinancialStatementKind, + FinancialStatementMode +} from '@/lib/types'; export function companyAnalysisQueryOptions(ticker: string) { const normalizedTicker = ticker.trim().toUpperCase(); @@ -22,6 +28,43 @@ export function companyAnalysisQueryOptions(ticker: string) { }); } +export function companyFinancialStatementsQueryOptions(input: { + ticker: string; + mode: FinancialStatementMode; + statement: FinancialStatementKind; + window: FinancialHistoryWindow; + includeDimensions?: boolean; + cursor?: string | null; + limit?: number; +}) { + const normalizedTicker = input.ticker.trim().toUpperCase(); + const includeDimensions = input.includeDimensions ?? false; + const cursor = input.cursor ?? null; + const limit = input.limit ?? 40; + + return queryOptions({ + queryKey: queryKeys.companyFinancialStatements( + normalizedTicker, + input.mode, + input.statement, + input.window, + includeDimensions, + cursor, + limit + ), + queryFn: () => getCompanyFinancialStatements({ + ticker: normalizedTicker, + mode: input.mode, + statement: input.statement, + window: input.window, + includeDimensions, + cursor, + limit + }), + staleTime: 60_000 + }); +} + export function filingsQueryOptions(input: { ticker?: string; limit?: number } = {}) { const normalizedTicker = input.ticker?.trim().toUpperCase() ?? null; const limit = input.limit ?? 120; diff --git a/lib/server/api/app.ts b/lib/server/api/app.ts index e0e40b0..434d9d8 100644 --- a/lib/server/api/app.ts +++ b/lib/server/api/app.ts @@ -1,9 +1,19 @@ import { Elysia, t } from 'elysia'; -import type { Filing, TaskStatus } from '@/lib/types'; +import type { + Filing, + FinancialHistoryWindow, + FinancialStatementKind, + FinancialStatementMode, + TaskStatus +} from '@/lib/types'; import { auth } from '@/lib/auth'; import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { asErrorMessage, jsonError } from '@/lib/server/http'; import { buildPortfolioSummary } from '@/lib/server/portfolio'; +import { + defaultFinancialSyncLimit, + getCompanyFinancialStatements +} from '@/lib/server/financial-statements'; import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction'; import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings'; import { @@ -29,6 +39,16 @@ import { const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed']; const FINANCIAL_FORMS: ReadonlySet = new Set(['10-K', '10-Q']); const AUTO_FILING_SYNC_LIMIT = 20; +const FINANCIALS_V2_ENABLED = process.env.FINANCIALS_V2?.trim().toLowerCase() !== 'false'; +const FINANCIAL_STATEMENT_MODES: FinancialStatementMode[] = ['standardized', 'filing_faithful']; +const FINANCIAL_STATEMENT_KINDS: FinancialStatementKind[] = [ + 'income', + 'balance', + 'cash_flow', + 'equity', + 'comprehensive_income' +]; +const FINANCIAL_HISTORY_WINDOWS: FinancialHistoryWindow[] = ['10y', 'all']; function asRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -43,6 +63,43 @@ function asPositiveNumber(value: unknown) { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +function asBoolean(value: unknown, fallback = false) { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes') { + return true; + } + + if (normalized === 'false' || normalized === '0' || normalized === 'no') { + return false; + } + } + + return fallback; +} + +function asStatementMode(value: unknown): FinancialStatementMode { + return FINANCIAL_STATEMENT_MODES.includes(value as FinancialStatementMode) + ? value as FinancialStatementMode + : 'standardized'; +} + +function asStatementKind(value: unknown): FinancialStatementKind { + return FINANCIAL_STATEMENT_KINDS.includes(value as FinancialStatementKind) + ? value as FinancialStatementKind + : 'income'; +} + +function asHistoryWindow(value: unknown): FinancialHistoryWindow { + return FINANCIAL_HISTORY_WINDOWS.includes(value as FinancialHistoryWindow) + ? value as FinancialHistoryWindow + : '10y'; +} + function withFinancialMetricsPolicy(filing: Filing): Filing { if (FINANCIAL_FORMS.has(filing.filing_type)) { return filing; @@ -430,6 +487,98 @@ export const app = new Elysia({ prefix: '/api' }) ticker: t.String({ minLength: 1 }) }) }) + .get('/financials/company', async ({ query }) => { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + + if (!FINANCIALS_V2_ENABLED) { + return jsonError('Financial statements v2 is disabled', 404); + } + + const ticker = typeof query.ticker === 'string' + ? query.ticker.trim().toUpperCase() + : ''; + if (!ticker) { + return jsonError('ticker is required'); + } + + const mode = asStatementMode(query.mode); + const statement = asStatementKind(query.statement); + const window = asHistoryWindow(query.window); + const includeDimensions = asBoolean(query.includeDimensions, false); + const cursor = typeof query.cursor === 'string' && query.cursor.trim().length > 0 + ? query.cursor.trim() + : null; + const limit = Number.isFinite(Number(query.limit)) + ? Number(query.limit) + : undefined; + + let payload = await getCompanyFinancialStatements({ + ticker, + mode, + statement, + window, + includeDimensions, + cursor, + limit, + v2Enabled: FINANCIALS_V2_ENABLED, + queuedSync: false + }); + + let queuedSync = false; + const shouldQueueSync = cursor === null && ( + payload.dataSourceStatus.pendingFilings > 0 + || payload.coverage.filings === 0 + || (window === 'all' && payload.nextCursor !== null) + ); + + if (shouldQueueSync) { + try { + await enqueueTask({ + userId: session.user.id, + taskType: 'sync_filings', + payload: { + ticker, + limit: defaultFinancialSyncLimit(window) + }, + priority: 88 + }); + queuedSync = true; + } catch (error) { + console.error(`[financials-v2-sync] failed for ${ticker}:`, error); + } + } + + if (queuedSync) { + payload = { + ...payload, + dataSourceStatus: { + ...payload.dataSourceStatus, + queuedSync: true + } + }; + } + + return Response.json({ financials: payload }); + }, { + query: t.Object({ + ticker: t.String({ minLength: 1 }), + mode: t.Optional(t.Union([t.Literal('standardized'), t.Literal('filing_faithful')])), + statement: t.Optional(t.Union([ + t.Literal('income'), + t.Literal('balance'), + t.Literal('cash_flow'), + t.Literal('equity'), + t.Literal('comprehensive_income') + ])), + window: t.Optional(t.Union([t.Literal('10y'), t.Literal('all')])), + includeDimensions: t.Optional(t.Union([t.String(), t.Boolean()])), + cursor: t.Optional(t.String()), + limit: t.Optional(t.Numeric()) + }) + }) .get('/analysis/reports/:accessionNumber', async ({ params }) => { const { response } = await requireAuthenticatedSession(); if (response) {