Files
Neon-Desk/lib/server/sec-company-profile.ts

381 lines
10 KiB
TypeScript

import type { CompanyProfile, CompanyValuationSnapshot } from '@/lib/types';
type FetchImpl = typeof fetch;
type SubmissionPayload = {
cik?: string;
name?: string;
tickers?: string[];
exchanges?: string[];
sicDescription?: string;
fiscalYearEnd?: string;
website?: string;
addresses?: {
business?: {
country?: string | null;
countryCode?: string | null;
stateOrCountryDescription?: string | null;
};
};
};
type CompanyFactsPayload = {
facts?: Record<string, Record<string, { units?: Record<string, FactPoint[]> }>>;
};
type FactPoint = {
val?: number;
filed?: string;
end?: string;
};
type ExchangeDirectoryPayload = {
fields?: string[];
data?: Array<Array<string | number | null>>;
};
type ExchangeDirectoryRecord = {
cik: string;
name: string;
ticker: string;
exchange: string | null;
};
type SecCompanyProfileResult = {
ticker: string;
cik: string | null;
companyName: string | null;
exchange: string | null;
industry: string | null;
country: string | null;
website: string | null;
fiscalYearEnd: string | null;
employeeCount: number | null;
sharesOutstanding: number | null;
};
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const EXCHANGE_DIRECTORY_URL = 'https://www.sec.gov/files/company_tickers_exchange.json';
const SEC_SUBMISSIONS_BASE = 'https://data.sec.gov/submissions';
const SEC_COMPANY_FACTS_BASE = 'https://data.sec.gov/api/xbrl/companyfacts';
const EXCHANGE_CACHE_TTL_MS = 1000 * 60 * 30;
const SUBMISSIONS_CACHE_TTL_MS = 1000 * 60 * 30;
const COMPANY_FACTS_CACHE_TTL_MS = 1000 * 60 * 30;
let exchangeDirectoryCache: CacheEntry<Map<string, ExchangeDirectoryRecord>> | null = null;
const submissionsCache = new Map<string, CacheEntry<SubmissionPayload>>();
const companyFactsCache = new Map<string, CacheEntry<CompanyFactsPayload>>();
function envUserAgent() {
return process.env.SEC_USER_AGENT || 'Fiscal Clone <support@fiscal.local>';
}
async function fetchJson<T>(url: string, fetchImpl: FetchImpl = fetch): Promise<T> {
const response = await fetchImpl(url, {
headers: {
'User-Agent': envUserAgent(),
Accept: 'application/json'
},
cache: 'no-store'
});
if (!response.ok) {
throw new Error(`SEC request failed (${response.status})`);
}
return await response.json() as T;
}
function asNormalizedString(value: unknown) {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function normalizeCik(value: string | number | null | undefined) {
const digits = String(value ?? '').replace(/\D/g, '');
return digits.length > 0 ? digits : null;
}
function toPaddedCik(value: string | null) {
return value ? value.padStart(10, '0') : null;
}
function formatFiscalYearEnd(value: string | null | undefined) {
const normalized = asNormalizedString(value);
if (!normalized) {
return null;
}
const digits = normalized.replace(/\D/g, '');
if (digits.length !== 4) {
return normalized;
}
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
function pointDate(point: FactPoint) {
return Date.parse(point.filed ?? point.end ?? '');
}
function pickLatestNumericFact(payload: CompanyFactsPayload, namespaces: string[], tags: string[]) {
const points: FactPoint[] = [];
for (const namespace of namespaces) {
const facts = payload.facts?.[namespace] ?? {};
for (const tag of tags) {
const entry = facts[tag];
if (!entry?.units) {
continue;
}
for (const series of Object.values(entry.units)) {
if (!Array.isArray(series)) {
continue;
}
for (const point of series) {
if (typeof point.val === 'number' && Number.isFinite(point.val)) {
points.push(point);
}
}
}
}
}
if (points.length === 0) {
return null;
}
const sorted = [...points].sort((left, right) => {
const leftDate = pointDate(left);
const rightDate = pointDate(right);
if (Number.isFinite(leftDate) && Number.isFinite(rightDate)) {
return rightDate - leftDate;
}
if (Number.isFinite(rightDate)) {
return 1;
}
if (Number.isFinite(leftDate)) {
return -1;
}
return 0;
});
return sorted[0]?.val ?? null;
}
async function getExchangeDirectory(fetchImpl?: FetchImpl) {
if (exchangeDirectoryCache && exchangeDirectoryCache.expiresAt > Date.now()) {
return exchangeDirectoryCache.value;
}
const payload = await fetchJson<ExchangeDirectoryPayload>(EXCHANGE_DIRECTORY_URL, fetchImpl);
const fields = payload.fields ?? [];
const cikIndex = fields.indexOf('cik');
const nameIndex = fields.indexOf('name');
const tickerIndex = fields.indexOf('ticker');
const exchangeIndex = fields.indexOf('exchange');
const directory = new Map<string, ExchangeDirectoryRecord>();
for (const row of payload.data ?? []) {
const ticker = asNormalizedString(row[tickerIndex]);
const cik = normalizeCik(row[cikIndex]);
const name = asNormalizedString(row[nameIndex]);
const exchange = asNormalizedString(row[exchangeIndex]);
if (!ticker || !cik || !name) {
continue;
}
directory.set(ticker.toUpperCase(), {
cik,
name,
ticker: ticker.toUpperCase(),
exchange
});
}
exchangeDirectoryCache = {
value: directory,
expiresAt: Date.now() + EXCHANGE_CACHE_TTL_MS
};
return directory;
}
async function getSubmissionByCik(cik: string, fetchImpl?: FetchImpl) {
const padded = toPaddedCik(cik);
if (!padded) {
return null;
}
const cached = submissionsCache.get(padded);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const payload = await fetchJson<SubmissionPayload>(`${SEC_SUBMISSIONS_BASE}/CIK${padded}.json`, fetchImpl);
submissionsCache.set(padded, {
value: payload,
expiresAt: Date.now() + SUBMISSIONS_CACHE_TTL_MS
});
return payload;
}
async function getCompanyFactsByCik(cik: string, fetchImpl?: FetchImpl) {
const padded = toPaddedCik(cik);
if (!padded) {
return null;
}
const cached = companyFactsCache.get(padded);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const payload = await fetchJson<CompanyFactsPayload>(`${SEC_COMPANY_FACTS_BASE}/CIK${padded}.json`, fetchImpl);
companyFactsCache.set(padded, {
value: payload,
expiresAt: Date.now() + COMPANY_FACTS_CACHE_TTL_MS
});
return payload;
}
export async function getSecCompanyProfile(
ticker: string,
options?: { fetchImpl?: FetchImpl }
): Promise<SecCompanyProfileResult | null> {
const normalizedTicker = ticker.trim().toUpperCase();
if (!normalizedTicker) {
return null;
}
try {
const directory = await getExchangeDirectory(options?.fetchImpl);
const directoryRecord = directory.get(normalizedTicker) ?? null;
const cik = directoryRecord?.cik ?? null;
const [submission, companyFacts] = await Promise.all([
cik ? getSubmissionByCik(cik, options?.fetchImpl) : Promise.resolve(null),
cik ? getCompanyFactsByCik(cik, options?.fetchImpl) : Promise.resolve(null)
]);
const employeeCount = companyFacts
? pickLatestNumericFact(companyFacts, ['dei'], ['EntityNumberOfEmployees'])
: null;
const sharesOutstanding = companyFacts
? pickLatestNumericFact(companyFacts, ['dei'], ['EntityCommonStockSharesOutstanding', 'CommonStockSharesOutstanding'])
: null;
return {
ticker: normalizedTicker,
cik,
companyName: asNormalizedString(submission?.name) ?? directoryRecord?.name ?? null,
exchange: asNormalizedString(submission?.exchanges?.[0]) ?? directoryRecord?.exchange ?? null,
industry: asNormalizedString(submission?.sicDescription),
country: asNormalizedString(submission?.addresses?.business?.country)
?? asNormalizedString(submission?.addresses?.business?.stateOrCountryDescription),
website: asNormalizedString(submission?.website),
fiscalYearEnd: formatFiscalYearEnd(submission?.fiscalYearEnd ?? null),
employeeCount,
sharesOutstanding
};
} catch {
return null;
}
}
export function toCompanyProfile(input: SecCompanyProfileResult | null, description: string | null): CompanyProfile {
if (!input && !description) {
return {
description: null,
exchange: null,
industry: null,
country: null,
website: null,
fiscalYearEnd: null,
employeeCount: null,
source: 'unavailable'
};
}
return {
description,
exchange: input?.exchange ?? null,
industry: input?.industry ?? null,
country: input?.country ?? null,
website: input?.website ?? null,
fiscalYearEnd: input?.fiscalYearEnd ?? null,
employeeCount: input?.employeeCount ?? null,
source: 'sec_derived'
};
}
export function deriveValuationSnapshot(input: {
quote: number | null;
sharesOutstanding: number | null;
revenue: number | null;
cash: number | null;
debt: number | null;
netIncome: number | null;
}): CompanyValuationSnapshot {
const hasPrice = typeof input.quote === 'number' && Number.isFinite(input.quote) && input.quote > 0;
const hasShares = typeof input.sharesOutstanding === 'number' && Number.isFinite(input.sharesOutstanding) && input.sharesOutstanding > 0;
const marketCap = hasPrice && hasShares ? input.quote! * input.sharesOutstanding! : null;
const hasCash = typeof input.cash === 'number' && Number.isFinite(input.cash);
const hasDebt = typeof input.debt === 'number' && Number.isFinite(input.debt);
const enterpriseValue = marketCap !== null && hasCash && hasDebt
? marketCap + input.debt! - input.cash!
: null;
const hasRevenue = typeof input.revenue === 'number' && Number.isFinite(input.revenue) && input.revenue > 0;
const hasNetIncome = typeof input.netIncome === 'number' && Number.isFinite(input.netIncome) && input.netIncome > 0;
const trailingPe = marketCap !== null && hasNetIncome
? marketCap / input.netIncome!
: null;
const evToRevenue = enterpriseValue !== null && hasRevenue
? enterpriseValue / input.revenue!
: null;
const availableCount = [
input.sharesOutstanding,
marketCap,
enterpriseValue,
trailingPe,
evToRevenue
].filter((value) => typeof value === 'number' && Number.isFinite(value)).length;
return {
sharesOutstanding: input.sharesOutstanding,
marketCap,
enterpriseValue,
trailingPe,
evToRevenue,
evToEbitda: null,
source: availableCount === 0
? 'unavailable'
: availableCount >= 3
? 'derived'
: 'partial'
};
}
export const __secCompanyProfileInternals = {
formatFiscalYearEnd,
pickLatestNumericFact
};