Jaspers Hub

jaspers / sec

pluginOfficialReviewed★ 0↓ 9

SEC EDGAR Filings, Facts, and Insider Forms: Gets one company's SEC filings, XBRL reported figures, insider forms 3/4/5, and filer profile from SEC EDGAR by ticker or CIK. No key needed.

7.7 KB
1import { defineSource } from '@jaspers-ai/sdk'
2import { z } from 'zod'
3
4
5// SEC EDGAR, straight from the source. No key and no account: the SEC asks only that a caller says
6// who it is in its User-Agent and keeps under ten requests a second, and both are done here.
7//
8// This is the free, per-company half of SEC data: what one filer filed, what it reported, and who
9// bought and sold. Screening the whole universe on those numbers is the Jaspers screener's job.
10
11const HOSTS = ['data.sec.gov', 'www.sec.gov']
12const AGENT = 'Jaspers Terminal (https://jsprai.com)'
13/** The SEC's published ceiling is ten a second; this stays well under it. */
14const MIN_GAP_MS = 120
15
16let last = 0
17/** Every call goes through here, so a view and the assistant asking at once still queue politely. */
18async function get(ctx: { fetch: typeof fetch }, url: string): Promise<unknown> {
19 const wait = Math.max(0, last + MIN_GAP_MS - Date.now())
20 last = Date.now() + wait
21 if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait))
22 const response = await ctx.fetch(url, { headers: { 'User-Agent': AGENT, Accept: 'application/json' } })
23 if (!response.ok) {
24 if (response.status === 404) throw new Error(`The SEC has nothing at that address. Check the ticker or the tag.`)
25 throw new Error(`The SEC answered ${response.status}. It rate limits by User-Agent; try again in a moment.`)
26 }
27 return response.json()
28}
29
30/** ticker to CIK, from the SEC's own list, read once per host and kept. */
31let tickers: Map<string, { cik: string; name: string }> | null = null
32
33async function lookup(ctx: { fetch: typeof fetch }, symbol: string): Promise<{ cik: string; name: string }> {
34 const wanted = symbol.trim().toUpperCase()
35 if (/^\d{1,10}$/.test(wanted)) return { cik: wanted.padStart(10, '0'), name: wanted }
36 if (!tickers) {
37 const listed = (await get(ctx, 'https://www.sec.gov/files/company_tickers.json')) as Record<string, { cik_str: number; ticker: string; title: string }>
38 tickers = new Map(Object.values(listed).map((one) => [one.ticker.toUpperCase(), { cik: String(one.cik_str).padStart(10, '0'), name: one.title }]))
39 }
40 const found = tickers.get(wanted)
41 if (!found) throw new Error(`No SEC filer trades as ${wanted}. Give its ticker as EDGAR lists it, or its CIK.`)
42 return found
43}
44
45const ticker = z.string().describe('A ticker like AAPL, or a CIK.')
46
47interface Submissions {
48 cik: string
49 name: string
50 sic: string
51 sicDescription: string
52 tickers: string[]
53 exchanges: string[]
54 category: string
55 stateOfIncorporation: string
56 fiscalYearEnd: string
57 filings: { recent: Record<string, (string | number)[]> }
58}
59
60/** The filings table as rows, newest first, since EDGAR answers with parallel arrays. */
61function rowsOf(recent: Record<string, (string | number)[]>, cik: string): Record<string, unknown>[] {
62 const keys = Object.keys(recent)
63 const count = recent['accessionNumber']?.length ?? 0
64 const out: Record<string, unknown>[] = []
65 for (let i = 0; i < count; i++) {
66 const row: Record<string, unknown> = {}
67 for (const key of keys) row[key] = recent[key]?.[i] ?? null
68 const accession = String(row['accessionNumber'] ?? '').replace(/-/g, '')
69 row['url'] = `https://www.sec.gov/Archives/edgar/data/${Number(cik)}/${accession}/${String(row['primaryDocument'] ?? '')}`
70 out.push(row)
71 }
72 return out
73}
74
75export const filings = defineSource({
76 description:
77 'Filings one company has sent to the SEC, newest first: form, date, and a link to the document. form narrows it, like 10-K, 10-Q, 8-K, or 4. This is the free per-company feed; screening every filer is the screener plugin.',
78 hosts: HOSTS,
79 input: z.object({
80 ticker,
81 form: z.string().optional().describe('One form type, like 10-K or 8-K. Left out, every form comes back.'),
82 limit: z.number().int().min(1).max(1000).default(40),
83 }),
84 async run({ ticker: symbol, form, limit }, ctx) {
85 const { cik, name } = await lookup(ctx, symbol)
86 const data = (await get(ctx, `https://data.sec.gov/submissions/CIK${cik}.json`)) as Submissions
87 let rows = rowsOf(data.filings.recent, cik)
88 if (form) {
89 const wanted = form.trim().toUpperCase()
90 rows = rows.filter((row) => String(row['form'] ?? '').toUpperCase() === wanted)
91 }
92 return { company: data.name || name, cik, rows: rows.slice(0, limit) }
93 },
94})
95
96export const facts = defineSource({
97 description:
98 'What one company reported for one XBRL tag, every period it filed, with the filing each number came from. The tag is a US GAAP name like Revenues, NetIncomeLoss, Assets, or StockholdersEquity.',
99 hosts: HOSTS,
100 input: z.object({
101 ticker,
102 tag: z.string().default('Revenues').describe('A US GAAP tag, like NetIncomeLoss.'),
103 taxonomy: z.string().default('us-gaap'),
104 annual: z.boolean().default(false).describe('Only figures from 10-K filings.'),
105 }),
106 async run({ ticker: symbol, tag, taxonomy, annual }, ctx) {
107 const { cik, name } = await lookup(ctx, symbol)
108 const data = (await get(ctx, `https://data.sec.gov/api/xbrl/companyconcept/CIK${cik}/${taxonomy}/${tag}.json`)) as {
109 label?: string
110 units: Record<string, { start?: string; end: string; val: number; fy: number; fp: string; form: string; filed: string; frame?: string }[]>
111 }
112 const unit = Object.keys(data.units)[0]
113 // One period is reported in several filings: the year's own 10-K, and again as the comparative
114 // in the next one. The latest filing is the one that stands, since a restatement supersedes.
115 const latest = new Map<string, { end: string; filed: string } & Record<string, unknown>>()
116 for (const one of unit ? (data.units[unit] ?? []) : []) {
117 if (annual && one.form !== '10-K') continue
118 const key = `${one.start ?? ''}..${one.end}`
119 const row = {
120 ticker: symbol.toUpperCase(),
121 end: one.end,
122 start: one.start ?? null,
123 value: one.val,
124 unit,
125 fy: one.fy,
126 fp: one.fp,
127 form: one.form,
128 filed: one.filed,
129 }
130 const held = latest.get(key)
131 if (!held || held.filed < one.filed) latest.set(key, row)
132 }
133 const rows = [...latest.values()].sort((a, b) => a.end.localeCompare(b.end))
134 return { company: name, tag, label: data.label ?? tag, unit, rows }
135 },
136})
137
138export const insiders = defineSource({
139 description:
140 'Insider forms one company filed: Form 4 as they are reported, newest first, with a link to each. Who bought or sold and how much is inside the linked form.',
141 hosts: HOSTS,
142 input: z.object({ ticker, limit: z.number().int().min(1).max(500).default(40) }),
143 async run({ ticker: symbol, limit }, ctx) {
144 const { cik, name } = await lookup(ctx, symbol)
145 const data = (await get(ctx, `https://data.sec.gov/submissions/CIK${cik}.json`)) as Submissions
146 const rows = rowsOf(data.filings.recent, cik)
147 .filter((row) => ['3', '4', '5'].includes(String(row['form'] ?? '')))
148 .map((row) => ({ form: row['form'], filed: row['filingDate'], reported: row['reportDate'], url: row['url'] }))
149 .slice(0, limit)
150 return { company: data.name || name, cik, rows }
151 },
152})
153
154export const company = defineSource({
155 description: 'What the SEC knows about one filer: its name, CIK, tickers and exchanges, industry, state, and fiscal year end.',
156 hosts: HOSTS,
157 input: z.object({ ticker }),
158 async run({ ticker: symbol }, ctx) {
159 const { cik } = await lookup(ctx, symbol)
160 const data = (await get(ctx, `https://data.sec.gov/submissions/CIK${cik}.json`)) as Submissions
161 return {
162 rows: [
163 {
164 name: data.name,
165 cik: data.cik,
166 tickers: (data.tickers ?? []).join(', '),
167 exchanges: (data.exchanges ?? []).join(', '),
168 industry: data.sicDescription,
169 sic: data.sic,
170 category: data.category,
171 incorporated: data.stateOfIncorporation,
172 fiscalYearEnd: data.fiscalYearEnd,
173 },
174 ],
175 }
176 },
177})