import { env } from '../config'; import type { FilingMetrics, FilingType } from '../types'; type TickerDirectoryRecord = { cik_str: number; ticker: string; title: string; }; type RecentFilingsPayload = { filings?: { recent?: { accessionNumber?: string[]; filingDate?: string[]; form?: string[]; primaryDocument?: string[]; }; }; cik?: string; name?: string; }; type CompanyFactsPayload = { facts?: { 'us-gaap'?: Record> }>; }; }; export type SecFiling = { ticker: string; cik: string; companyName: string; filingType: FilingType; filingDate: string; accessionNumber: string; filingUrl: string | null; }; const SUPPORTED_FORMS: FilingType[] = ['10-K', '10-Q', '8-K']; const TICKER_CACHE_TTL_MS = 1000 * 60 * 60 * 24; const FACTS_CACHE_TTL_MS = 1000 * 60 * 10; export class SecService { private tickerCache: Map = new Map(); private tickerCacheLoadedAt = 0; private factsCache: Map = new Map(); private async fetchJson(url: string): Promise { const response = await fetch(url, { headers: { 'User-Agent': env.SEC_USER_AGENT, Accept: 'application/json' } }); if (!response.ok) { throw new Error(`SEC request failed (${response.status}) for ${url}`); } return await response.json() as T; } private async ensureTickerCache() { const isFresh = Date.now() - this.tickerCacheLoadedAt < TICKER_CACHE_TTL_MS; if (isFresh && this.tickerCache.size > 0) { return; } const payload = await this.fetchJson>('https://www.sec.gov/files/company_tickers.json'); const nextCache = new Map(); for (const record of Object.values(payload)) { nextCache.set(record.ticker.toUpperCase(), record); } this.tickerCache = nextCache; this.tickerCacheLoadedAt = Date.now(); } async resolveTicker(ticker: string) { await this.ensureTickerCache(); const normalizedTicker = ticker.trim().toUpperCase(); const record = this.tickerCache.get(normalizedTicker); if (!record) { throw new Error(`Ticker ${normalizedTicker} was not found in SEC directory`); } return { ticker: normalizedTicker, cik: String(record.cik_str), companyName: record.title }; } async fetchRecentFilings(ticker: string, limit = 20): Promise { const company = await this.resolveTicker(ticker); const cikPadded = company.cik.padStart(10, '0'); const payload = await this.fetchJson(`https://data.sec.gov/submissions/CIK${cikPadded}.json`); const recent = payload.filings?.recent; if (!recent) { return []; } const forms = recent.form ?? []; const accessionNumbers = recent.accessionNumber ?? []; const filingDates = recent.filingDate ?? []; const primaryDocuments = recent.primaryDocument ?? []; const filings: SecFiling[] = []; for (let i = 0; i < forms.length; i += 1) { const filingType = forms[i] as FilingType; if (!SUPPORTED_FORMS.includes(filingType)) { continue; } const accessionNumber = accessionNumbers[i]; if (!accessionNumber) { continue; } const compactAccession = accessionNumber.replace(/-/g, ''); const documentName = primaryDocuments[i]; const filingUrl = documentName ? `https://www.sec.gov/Archives/edgar/data/${Number(company.cik)}/${compactAccession}/${documentName}` : null; filings.push({ ticker: company.ticker, cik: company.cik, companyName: payload.name ?? company.companyName, filingType, filingDate: filingDates[i] ?? new Date().toISOString().slice(0, 10), accessionNumber, filingUrl }); if (filings.length >= limit) { break; } } return filings; } private pickLatestFact(payload: CompanyFactsPayload, tag: string): number | null { const unitCollections = payload.facts?.['us-gaap']?.[tag]?.units; if (!unitCollections) { return null; } const preferredUnits = ['USD', 'USD/shares']; for (const unit of preferredUnits) { const series = unitCollections[unit]; if (!series?.length) { continue; } const best = [...series] .filter((item) => typeof item.val === 'number') .sort((a, b) => { const aDate = Date.parse(a.filed ?? a.end ?? '1970-01-01'); const bDate = Date.parse(b.filed ?? b.end ?? '1970-01-01'); return bDate - aDate; })[0]; if (best?.val !== undefined) { return best.val; } } return null; } async fetchMetrics(cik: string): Promise { const normalized = cik.padStart(10, '0'); const cached = this.factsCache.get(normalized); if (cached && Date.now() - cached.loadedAt < FACTS_CACHE_TTL_MS) { return cached.metrics; } const payload = await this.fetchJson(`https://data.sec.gov/api/xbrl/companyfacts/CIK${normalized}.json`); const metrics: FilingMetrics = { revenue: this.pickLatestFact(payload, 'Revenues'), netIncome: this.pickLatestFact(payload, 'NetIncomeLoss'), totalAssets: this.pickLatestFact(payload, 'Assets'), cash: this.pickLatestFact(payload, 'CashAndCashEquivalentsAtCarryingValue'), debt: this.pickLatestFact(payload, 'LongTermDebt') }; this.factsCache.set(normalized, { loadedAt: Date.now(), metrics }); return metrics; } }