import type { StoredCompany } from './research.ts' // The companies whose SEC filings are in the store, as the view lists them: what the store holds of // each, and how far the job reading it is. Pure, so it is tested. /** How far back a company is read unless the user picks its years: this year and the ten before. */ export const DEFAULT_YEARS = 10 /** The first year EDGAR holds filings for. */ export const FIRST_YEAR = 1994 const TICKER = /^[A-Z][A-Z0-9.-]{0,9}$/ /** How far a company's job got: filings listed, read (now or before), and failed. */ export interface Progress { total: number done: number failed: number running: boolean /** Why the ingest itself stopped, when it did not finish. */ error: string | null } /** A company as the view lists it. */ export interface CompanyStatus { ticker: string name: string /** Filings in the store. */ filings: number chunks: number /** The kinds of its filings ever asked for and their years, by filing date. */ reads: Reads /** Chunks still waiting for an embedding: searchable by their words meanwhile. */ unembedded: number progress: Progress | null } export function tickerOf(raw: string): string { return companyOf(raw).ticker } /** * A company as typed: a ticker, or a ticker and its SEC CIK (DFS:1393612) for one SEC no longer * lists by ticker, like a company bought since. The ticker names its filings; the CIK finds them. */ export function companyOf(raw: string): { ticker: string; cik: number | null } { const [name = '', cik] = raw .trim() .toUpperCase() .split(/\s*:\s*/) if (!TICKER.test(name) || (cik !== undefined && !/^\d{1,10}$/.test(cik))) { throw new Error( `${JSON.stringify(raw)} is not a ticker, like FLWS or BRK-B, or a ticker and CIK, like DFS:1393612.`, ) } return { ticker: name, cik: cik === undefined ? null : Number(cik) } } /** Every company the store was asked about, by ticker: its job, if one is open or just ended, as its progress. */ export function companyStatuses(stored: StoredCompany[]): CompanyStatus[] { return stored .map((c) => ({ ticker: c.ticker, name: c.company ?? c.ticker, filings: c.filings, chunks: c.chunks, reads: c.reads, unembedded: c.unembedded, progress: c.job ? { total: c.job.total, done: c.job.done, failed: c.job.failed, running: c.job.state === 'queued' || c.job.state === 'listing' || c.job.state === 'reading', error: c.job.error, } : null, })) .sort((a, b) => a.ticker.localeCompare(b.ticker)) } /** Every year from `from` to `to`, refused when they are not years EDGAR has, in order. */ export function yearRange(from: number, to: number, now: number): number[] { if (!Number.isInteger(from) || !Number.isInteger(to)) throw new Error('Years are whole numbers, like 2016.') if (from > to) throw new Error(`From ${from} is after to ${to}.`) if (from < FIRST_YEAR || to > now) throw new Error(`Years run from ${FIRST_YEAR} to ${now}.`) return Array.from({ length: to - from + 1 }, (_, i) => from + i) } /** The years read unless the user picks: this year and the ten before. */ export function defaultYears(now: number): number[] { return yearRange(now - DEFAULT_YEARS, now, now) } /** A company's years with more added: each once, ascending. */ export function addYears(covered: number[], added: number[]): number[] { return [...new Set([...covered, ...added])].sort((a, b) => a - b) } /** Years as the row says them: runs joined, like 2016–2019, 2022–2026. */ export function yearsText(years: number[]): string { const runs: [number, number][] = [] for (const year of years) { const run = runs[runs.length - 1] if (run && year === run[1] + 1) run[1] = year else runs.push([year, year]) } return runs.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ') } /** * The kinds of filing a company is read in by, each its forms with their amendments, grouped as * edgartools groups them (PERIODIC_FORMS, CURRENT_REPORT_FORMS, PROXY_FORMS, REGISTRATION_FORMS) * and by what a question reads them for. An answer reads in only the kinds it needs: of FLWS's 791 * filings in 2016-2026, 40 are reports and 542 insiders' forms. Left out of every kind: the prospectus * supplements and free writing prospectuses a bank files by the thousand (GS filed 7,741 424B2s in * 2017-2018), comment letters, and the rest. */ export const CATEGORIES = { reports: { holds: 'annual and quarterly reports: the business, risk factors, MD&A, financial statements', forms: ['10-K', '10-KT', '10-Q', '10-QT', '20-F', '40-F'], }, events: { holds: 'current reports: results releases, deals, leadership changes, other material events', forms: ['8-K', '6-K'], }, proxies: { holds: 'proxy statements: executive pay, the board, shareholder votes, merger proxies', forms: [ 'DEF 14A', 'DEFA14A', 'DEFC14A', 'DEFM14A', 'DEFN14A', 'DEFR14A', 'DFAN14A', 'DFRN14A', 'PRE 14A', 'PREC14A', 'PREM14A', 'PREN14A', ], }, insiders: { holds: "officers' and directors' holdings and trades, and planned sales", forms: ['3', '4', '5', '144'], }, holders: { holds: 'large holders: 5% owners and activists, and an investment manager’s own holdings', forms: ['SC 13D', 'SC 13G', 'SCHEDULE 13D', 'SCHEDULE 13G', '13F-HR'], }, offerings: { holds: 'registrations, prospectuses, and merger communications', forms: [ 'S-1', 'S-3', 'S-3ASR', 'S-4', 'S-8', 'F-1', 'F-3', 'F-4', 'DRS', '424B1', '424B3', '424B4', '424B5', '425', ], }, } as const satisfies Record export type Category = keyof typeof CATEGORIES export const CATEGORY_NAMES = Object.keys(CATEGORIES) as Category[] /** The years each kind of a company's filings is read for, by filing date. */ export type Reads = Partial> /** The kind a filing of this form is, its amendments with it; null for a form no kind reads. */ export function categoryOf(form: string): Category | null { const base = form.trim().toUpperCase().replace(/\/A$/, '') return CATEGORY_NAMES.find((c) => (CATEGORIES[c].forms as readonly string[]).includes(base)) ?? null } /** Whether reading these kinds in these years reads a filing of this form filed on this date. */ export function readsFiling(reads: Reads, form: string, filed: string): boolean { const category = categoryOf(form) return category !== null && (reads[category]?.includes(Number(filed.slice(0, 4))) ?? false) } /** Every year any kind is read for, ascending. */ export function readYears(reads: Reads): number[] { return addYears([], Object.values(reads).flat()) } /** What a company reads, kinds with the same years together: reports, events 2016–2026; insiders 2026. */ export function readsText(reads: Reads): string { const byYears = new Map() for (const c of CATEGORY_NAMES) { const years = reads[c] if (!years?.length) continue const text = yearsText(years) byYears.set(text, [...(byYears.get(text) ?? []), c]) } return [...byYears].map(([years, kinds]) => `${kinds.join(', ')} ${years}`).join('; ') } /** How long a company's filings count as the latest after SEC listed them. */ export const CURRENT_FOR_MS = 15 * 60 * 1000 /** Whether SEC listed the company's filings within CURRENT_FOR_MS; one never listed is not current. */ export function isCurrent(recentlyAsOf: string | null, now: Date): boolean { if (!recentlyAsOf) return false const at = Date.parse(recentlyAsOf) return Number.isFinite(at) && now.getTime() - at <= CURRENT_FOR_MS }