381 lines
11 KiB
TypeScript
381 lines
11 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;
|
|
};
|
|
|
|
export 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
|
|
};
|