209 lines
5.6 KiB
TypeScript
209 lines
5.6 KiB
TypeScript
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<string, { units?: Record<string, Array<{ val?: number; end?: string; filed?: string }>> }>;
|
|
};
|
|
};
|
|
|
|
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<string, TickerDirectoryRecord> = new Map();
|
|
private tickerCacheLoadedAt = 0;
|
|
private factsCache: Map<string, { loadedAt: number; metrics: FilingMetrics }> = new Map();
|
|
|
|
private async fetchJson<T>(url: string): Promise<T> {
|
|
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<Record<string, TickerDirectoryRecord>>('https://www.sec.gov/files/company_tickers.json');
|
|
const nextCache = new Map<string, TickerDirectoryRecord>();
|
|
|
|
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<SecFiling[]> {
|
|
const company = await this.resolveTicker(ticker);
|
|
const cikPadded = company.cik.padStart(10, '0');
|
|
|
|
const payload = await this.fetchJson<RecentFilingsPayload>(`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<FilingMetrics> {
|
|
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<CompanyFactsPayload>(`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;
|
|
}
|
|
}
|