flatten app to repo root and update docker deployment for single-stack runtime

This commit is contained in:
2026-02-24 00:25:03 -05:00
parent 2987ac06fa
commit 168c05cb71
59 changed files with 64 additions and 12 deletions

248
lib/server/sec.ts Normal file
View File

@@ -0,0 +1,248 @@
import type { Filing } from '@/lib/types';
type FilingType = Filing['filing_type'];
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 }>> }>;
};
};
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 * 12;
let tickerCache = new Map<string, TickerDirectoryRecord>();
let tickerCacheLoadedAt = 0;
function envUserAgent() {
return process.env.SEC_USER_AGENT || 'Fiscal Clone <support@fiscal.local>';
}
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
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
});
}
return filings;
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(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;
}
async function ensureTickerCache() {
const isFresh = Date.now() - tickerCacheLoadedAt < TICKER_CACHE_TTL_MS;
if (isFresh && tickerCache.size > 0) {
return;
}
const payload = await fetchJson<Record<string, TickerDirectoryRecord>>('https://www.sec.gov/files/company_tickers.json');
const next = new Map<string, TickerDirectoryRecord>();
for (const record of Object.values(payload)) {
next.set(record.ticker.toUpperCase(), record);
}
tickerCache = next;
tickerCacheLoadedAt = Date.now();
}
async function resolveTicker(ticker: string) {
await ensureTickerCache();
const normalized = ticker.trim().toUpperCase();
const record = tickerCache.get(normalized);
if (!record) {
throw new Error(`Ticker ${normalized} not found in SEC directory`);
}
return {
ticker: normalized,
cik: String(record.cik_str),
companyName: record.title
};
}
function 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;
}
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;
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
});
if (filings.length >= safeLimit) {
break;
}
}
return filings.length > 0 ? filings : fallbackFilings(company.ticker, safeLimit);
} catch {
return fallbackFilings(ticker, safeLimit);
}
}
export async function fetchFilingMetrics(cik: string, ticker: string) {
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')
};
} 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))
};
}
}