Rebuild company overview analysis page
This commit is contained in:
380
lib/server/sec-company-profile.ts
Normal file
380
lib/server/sec-company-profile.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user