Prioritize SEC financials for 10-K/10-Q and keep other filings qualitative
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { Filing } from '@/lib/types';
|
||||
|
||||
type FilingType = Filing['filing_type'];
|
||||
type FilingMetrics = NonNullable<Filing['metrics']>;
|
||||
|
||||
type TickerDirectoryRecord = {
|
||||
cik_str: number;
|
||||
@@ -23,10 +24,21 @@ type RecentFilingsPayload = {
|
||||
|
||||
type CompanyFactsPayload = {
|
||||
facts?: {
|
||||
'us-gaap'?: Record<string, { units?: Record<string, Array<{ val?: number; end?: string; filed?: string }>> }>;
|
||||
'us-gaap'?: Record<string, { units?: Record<string, CompanyFactPoint[]> }>;
|
||||
};
|
||||
};
|
||||
|
||||
type CompanyFactPoint = {
|
||||
val?: number;
|
||||
end?: string;
|
||||
filed?: string;
|
||||
accn?: string;
|
||||
form?: string;
|
||||
fy?: number;
|
||||
fp?: string;
|
||||
frame?: string;
|
||||
};
|
||||
|
||||
type SecFiling = {
|
||||
ticker: string;
|
||||
cik: string;
|
||||
@@ -58,9 +70,35 @@ export type FilingDocumentText = {
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
type FilingMetricsLookupInput = {
|
||||
accessionNumber: string;
|
||||
filingDate: string;
|
||||
filingType: FilingType;
|
||||
};
|
||||
|
||||
const SUPPORTED_FORMS: FilingType[] = ['10-K', '10-Q', '8-K'];
|
||||
const TICKER_CACHE_TTL_MS = 1000 * 60 * 60 * 12;
|
||||
const FILING_TEXT_MAX_CHARS = 24_000;
|
||||
const METRIC_TAGS = {
|
||||
revenue: [
|
||||
'Revenues',
|
||||
'SalesRevenueNet',
|
||||
'RevenueFromContractWithCustomerExcludingAssessedTax',
|
||||
'TotalRevenuesAndOtherIncome'
|
||||
],
|
||||
netIncome: ['NetIncomeLoss', 'ProfitLoss'],
|
||||
totalAssets: ['Assets'],
|
||||
cash: [
|
||||
'CashAndCashEquivalentsAtCarryingValue',
|
||||
'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents'
|
||||
],
|
||||
debt: [
|
||||
'LongTermDebtAndCapitalLeaseObligations',
|
||||
'LongTermDebtNoncurrent',
|
||||
'LongTermDebt',
|
||||
'DebtAndFinanceLeaseLiabilities'
|
||||
]
|
||||
} as const;
|
||||
|
||||
let tickerCache = new Map<string, TickerDirectoryRecord>();
|
||||
let tickerCacheLoadedAt = 0;
|
||||
@@ -140,6 +178,30 @@ function compactAccessionNumber(value: string) {
|
||||
return value.replace(/-/g, '');
|
||||
}
|
||||
|
||||
function normalizeAccessionKey(value: string | undefined | null) {
|
||||
return (value ?? '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function normalizeForm(value: string | undefined | null) {
|
||||
const normalized = (value ?? '').trim().toUpperCase();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalized.endsWith('/A')
|
||||
? normalized.slice(0, -2)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined | null) {
|
||||
if (!value) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
return Date.parse(value);
|
||||
}
|
||||
|
||||
function normalizeCikForPath(value: string) {
|
||||
const digits = value.replace(/\D/g, '');
|
||||
if (!digits) {
|
||||
@@ -214,42 +276,6 @@ export async function fetchPrimaryFilingText(
|
||||
};
|
||||
}
|
||||
|
||||
function pseudoMetric(seed: string, min: number, max: number) {
|
||||
let hash = 0;
|
||||
for (const char of seed) {
|
||||
hash = (hash * 33 + char.charCodeAt(0)) % 100000;
|
||||
}
|
||||
|
||||
const fraction = (hash % 10000) / 10000;
|
||||
return min + (max - min) * fraction;
|
||||
}
|
||||
|
||||
function fallbackFilings(ticker: string, limit: number): SecFiling[] {
|
||||
const normalized = ticker.trim().toUpperCase();
|
||||
const companyName = `${normalized} Holdings Inc.`;
|
||||
const filings: SecFiling[] = [];
|
||||
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
const filingType = SUPPORTED_FORMS[i % SUPPORTED_FORMS.length];
|
||||
const date = new Date(Date.now() - i * 1000 * 60 * 60 * 24 * 35).toISOString().slice(0, 10);
|
||||
const accessionNumber = `${Date.now()}-${i}`;
|
||||
|
||||
filings.push({
|
||||
ticker: normalized,
|
||||
cik: String(100000 + i),
|
||||
companyName,
|
||||
filingType,
|
||||
filingDate: date,
|
||||
accessionNumber,
|
||||
filingUrl: null,
|
||||
submissionUrl: null,
|
||||
primaryDocument: null
|
||||
});
|
||||
}
|
||||
|
||||
return filings;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
@@ -301,116 +327,255 @@ async function resolveTicker(ticker: string) {
|
||||
}
|
||||
|
||||
function pickLatestFact(payload: CompanyFactsPayload, tag: string): number | null {
|
||||
const unitCollections = payload.facts?.['us-gaap']?.[tag]?.units;
|
||||
return pickFactForFiling(payload, tag, {
|
||||
accessionNumber: '',
|
||||
filingDate: '',
|
||||
filingType: '10-Q'
|
||||
});
|
||||
}
|
||||
|
||||
function collectFactSeries(payload: CompanyFactsPayload, tag: string): CompanyFactPoint[] {
|
||||
const unitCollections = payload.facts?.['us-gaap']?.[tag]?.units;
|
||||
if (!unitCollections) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
const preferredUnits = ['USD', 'USD/shares'];
|
||||
const usdSeries: CompanyFactPoint[] = [];
|
||||
const fallbackSeries: CompanyFactPoint[] = [];
|
||||
|
||||
for (const unit of preferredUnits) {
|
||||
const series = unitCollections[unit];
|
||||
if (!series?.length) {
|
||||
for (const [unit, series] of Object.entries(unitCollections)) {
|
||||
if (!Array.isArray(series) || series.length === 0) {
|
||||
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 (unit === 'USD' || /^USD(?!\/shares)/i.test(unit)) {
|
||||
usdSeries.push(...series);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (best?.val !== undefined) {
|
||||
return best.val;
|
||||
fallbackSeries.push(...series);
|
||||
}
|
||||
|
||||
const points = usdSeries.length > 0 ? usdSeries : fallbackSeries;
|
||||
|
||||
return points.filter((point) => typeof point.val === 'number' && Number.isFinite(point.val));
|
||||
}
|
||||
|
||||
function pickMostRecentFact(points: CompanyFactPoint[]) {
|
||||
return [...points].sort((a, b) => {
|
||||
const aDate = parseDate(a.filed ?? a.end);
|
||||
const bDate = parseDate(b.filed ?? b.end);
|
||||
|
||||
if (Number.isFinite(aDate) && Number.isFinite(bDate)) {
|
||||
return bDate - aDate;
|
||||
}
|
||||
|
||||
if (Number.isFinite(bDate)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (Number.isFinite(aDate)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
function pickClosestByDate(points: CompanyFactPoint[], targetDate: number) {
|
||||
if (points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(targetDate)) {
|
||||
return pickMostRecentFact(points);
|
||||
}
|
||||
|
||||
const dated = points
|
||||
.map((point) => ({ point, date: parseDate(point.filed ?? point.end) }))
|
||||
.filter((entry) => Number.isFinite(entry.date));
|
||||
|
||||
if (dated.length === 0) {
|
||||
return pickMostRecentFact(points);
|
||||
}
|
||||
|
||||
const beforeTarget = dated.filter((entry) => entry.date <= targetDate);
|
||||
if (beforeTarget.length > 0) {
|
||||
return beforeTarget.sort((a, b) => b.date - a.date)[0]?.point ?? null;
|
||||
}
|
||||
|
||||
return dated.sort((a, b) => {
|
||||
const distance = Math.abs(a.date - targetDate) - Math.abs(b.date - targetDate);
|
||||
if (distance !== 0) {
|
||||
return distance;
|
||||
}
|
||||
|
||||
return b.date - a.date;
|
||||
})[0]?.point ?? null;
|
||||
}
|
||||
|
||||
function pickFactForFiling(
|
||||
payload: CompanyFactsPayload,
|
||||
tag: string,
|
||||
filing: FilingMetricsLookupInput
|
||||
): number | null {
|
||||
const points = collectFactSeries(payload, tag);
|
||||
if (points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accessionKey = normalizeAccessionKey(filing.accessionNumber);
|
||||
if (accessionKey) {
|
||||
const byAccession = points.filter((point) => normalizeAccessionKey(point.accn) === accessionKey);
|
||||
if (byAccession.length > 0) {
|
||||
const matched = pickMostRecentFact(byAccession);
|
||||
if (typeof matched?.val === 'number' && Number.isFinite(matched.val)) {
|
||||
return matched.val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filingForm = normalizeForm(filing.filingType);
|
||||
const byForm = filingForm
|
||||
? points.filter((point) => normalizeForm(point.form) === filingForm)
|
||||
: points;
|
||||
|
||||
const targetDate = parseDate(filing.filingDate);
|
||||
const bestByForm = pickClosestByDate(byForm, targetDate);
|
||||
if (typeof bestByForm?.val === 'number' && Number.isFinite(bestByForm.val)) {
|
||||
return bestByForm.val;
|
||||
}
|
||||
|
||||
const bestAny = pickClosestByDate(points, targetDate);
|
||||
return typeof bestAny?.val === 'number' && Number.isFinite(bestAny.val)
|
||||
? bestAny.val
|
||||
: null;
|
||||
}
|
||||
|
||||
function pickFactByTags(
|
||||
payload: CompanyFactsPayload,
|
||||
tags: readonly string[],
|
||||
filing: FilingMetricsLookupInput
|
||||
) {
|
||||
for (const tag of tags) {
|
||||
const value = pickFactForFiling(payload, tag, filing);
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function emptyMetrics(): FilingMetrics {
|
||||
return {
|
||||
revenue: null,
|
||||
netIncome: null,
|
||||
totalAssets: null,
|
||||
cash: null,
|
||||
debt: null
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchRecentFilings(ticker: string, limit = 20): Promise<SecFiling[]> {
|
||||
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 50);
|
||||
|
||||
try {
|
||||
const company = await resolveTicker(ticker);
|
||||
const cikPadded = company.cik.padStart(10, '0');
|
||||
const payload = await fetchJson<RecentFilingsPayload>(`https://data.sec.gov/submissions/CIK${cikPadded}.json`);
|
||||
const recent = payload.filings?.recent;
|
||||
const submissionUrl = `https://data.sec.gov/submissions/CIK${cikPadded}.json`;
|
||||
const company = await resolveTicker(ticker);
|
||||
const cikPadded = company.cik.padStart(10, '0');
|
||||
const payload = await fetchJson<RecentFilingsPayload>(`https://data.sec.gov/submissions/CIK${cikPadded}.json`);
|
||||
const recent = payload.filings?.recent;
|
||||
const submissionUrl = `https://data.sec.gov/submissions/CIK${cikPadded}.json`;
|
||||
|
||||
if (!recent) {
|
||||
return fallbackFilings(company.ticker, safeLimit);
|
||||
}
|
||||
|
||||
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] ?? todayIso(),
|
||||
accessionNumber,
|
||||
filingUrl,
|
||||
submissionUrl,
|
||||
primaryDocument: documentName ?? null
|
||||
});
|
||||
|
||||
if (filings.length >= safeLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return filings.length > 0 ? filings : fallbackFilings(company.ticker, safeLimit);
|
||||
} catch {
|
||||
return fallbackFilings(ticker, safeLimit);
|
||||
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 normalizedForm = normalizeForm(forms[i]) as FilingType;
|
||||
if (!SUPPORTED_FORMS.includes(normalizedForm)) {
|
||||
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: normalizedForm,
|
||||
filingDate: filingDates[i] ?? todayIso(),
|
||||
accessionNumber,
|
||||
filingUrl,
|
||||
submissionUrl,
|
||||
primaryDocument: documentName ?? null
|
||||
});
|
||||
|
||||
if (filings.length >= safeLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return filings;
|
||||
}
|
||||
|
||||
export async function fetchFilingMetrics(cik: string, ticker: string) {
|
||||
export async function fetchLatestFilingMetrics(cik: string) {
|
||||
const normalized = cik.padStart(10, '0');
|
||||
const payload = await fetchJson<CompanyFactsPayload>(`https://data.sec.gov/api/xbrl/companyfacts/CIK${normalized}.json`);
|
||||
|
||||
return {
|
||||
revenue: pickLatestFact(payload, 'Revenues'),
|
||||
netIncome: pickLatestFact(payload, 'NetIncomeLoss'),
|
||||
totalAssets: pickLatestFact(payload, 'Assets'),
|
||||
cash: pickLatestFact(payload, 'CashAndCashEquivalentsAtCarryingValue'),
|
||||
debt: pickLatestFact(payload, 'LongTermDebt')
|
||||
} satisfies FilingMetrics;
|
||||
}
|
||||
|
||||
export async function fetchFilingMetricsForFilings(
|
||||
cik: string,
|
||||
_ticker: string,
|
||||
filings: FilingMetricsLookupInput[]
|
||||
) {
|
||||
const metricsByAccession = new Map<string, FilingMetrics>();
|
||||
if (filings.length === 0) {
|
||||
return metricsByAccession;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = cik.padStart(10, '0');
|
||||
const payload = await fetchJson<CompanyFactsPayload>(`https://data.sec.gov/api/xbrl/companyfacts/CIK${normalized}.json`);
|
||||
|
||||
return {
|
||||
revenue: pickLatestFact(payload, 'Revenues'),
|
||||
netIncome: pickLatestFact(payload, 'NetIncomeLoss'),
|
||||
totalAssets: pickLatestFact(payload, 'Assets'),
|
||||
cash: pickLatestFact(payload, 'CashAndCashEquivalentsAtCarryingValue'),
|
||||
debt: pickLatestFact(payload, 'LongTermDebt')
|
||||
};
|
||||
for (const filing of filings) {
|
||||
metricsByAccession.set(filing.accessionNumber, {
|
||||
revenue: pickFactByTags(payload, METRIC_TAGS.revenue, filing),
|
||||
netIncome: pickFactByTags(payload, METRIC_TAGS.netIncome, filing),
|
||||
totalAssets: pickFactByTags(payload, METRIC_TAGS.totalAssets, filing),
|
||||
cash: pickFactByTags(payload, METRIC_TAGS.cash, filing),
|
||||
debt: pickFactByTags(payload, METRIC_TAGS.debt, filing)
|
||||
});
|
||||
}
|
||||
|
||||
return metricsByAccession;
|
||||
} catch {
|
||||
return {
|
||||
revenue: Math.round(pseudoMetric(`${ticker}-revenue`, 2_000_000_000, 350_000_000_000)),
|
||||
netIncome: Math.round(pseudoMetric(`${ticker}-net`, 150_000_000, 40_000_000_000)),
|
||||
totalAssets: Math.round(pseudoMetric(`${ticker}-assets`, 4_000_000_000, 500_000_000_000)),
|
||||
cash: Math.round(pseudoMetric(`${ticker}-cash`, 200_000_000, 180_000_000_000)),
|
||||
debt: Math.round(pseudoMetric(`${ticker}-debt`, 300_000_000, 220_000_000_000))
|
||||
};
|
||||
for (const filing of filings) {
|
||||
metricsByAccession.set(filing.accessionNumber, emptyMetrics());
|
||||
}
|
||||
|
||||
return metricsByAccession;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user