Jaspers Hub

jaspers / earningscall

pluginOfficialReviewed★ 0↓ 6

EarningsCall Transcripts & Calendar: Shows a company's earnings call transcripts and the daily earnings calendar from EarningsCall.biz in a single panel view.

11 KB
1// The EarningsCall server's pure half: the requests EarningsCall reads, its answers read the
2// forgiving way, refusals put in words, and a plain transcript cut into paragraphs. No network and no
3// MCP here, so it runs under the tests; server.ts does the fetching and the tools.
4//
5// A plain transcript (the only kind a Starter key gets) is one string: machine transcribed, no
6// speaker names, no line breaks. What marks a change of speaker is what the operator says, so the
7// paragraphs break there: after someone addresses the operator, before the operator introduces a
8// question, and after the operator gives the line away. Everywhere else a paragraph runs to about six
9// hundred characters. It is a reading aid, not a speaker map, and it can be wrong.
10
11export const API = 'https://v2.api.earningscall.biz'
12
13/** symbols-v2.txt names an exchange by its index in this list; a ticker on two of them resolves to the first. */
14export const EXCHANGES = ['NYSE', 'NASDAQ', 'AMEX', 'TSX', 'TSXV', 'OTC', 'LSE', 'CBOE', 'STO']
15
16export interface Company {
17 exchange: string
18 symbol: string
19 name: string
20}
21
22export interface Call {
23 /** The company's fiscal year and quarter, as EarningsCall labels the call. */
24 year: number
25 quarter: number
26 /** When the call was held, ISO with the offset EarningsCall gave. */
27 date: string | null
28 published: boolean
29}
30
31export interface CalendarRow {
32 exchange: string
33 symbol: string
34 company: string
35 year: number
36 quarter: number
37 date: string | null
38 ready: boolean
39}
40
41export interface ToolResult {
42 [key: string]: unknown
43 content: { type: 'text'; text: string }[]
44 structuredContent: Record<string, unknown>
45}
46
47// Symbols.
48
49/** Tab-separated lines: exchange index, ticker, name, sector index, industry index. */
50export function parseSymbols(text: string): Company[] {
51 const companies: Company[] = []
52 for (const line of text.split('\n')) {
53 const [index, symbol, name] = line.replace(/\r$/, '').split('\t')
54 const exchange = EXCHANGES[Number(index)]
55 if (!exchange || !symbol || index === '') continue
56 companies.push({ exchange, symbol, name: name ?? symbol })
57 }
58 return companies
59}
60
61export function findCompany(companies: Company[], symbol: string, exchange?: string): Company | null {
62 const ticker = symbol.trim().toUpperCase()
63 const on = exchange?.trim().toUpperCase()
64 const listings = companies.filter((c) => c.symbol === ticker && (!on || c.exchange === on))
65 listings.sort((a, b) => EXCHANGES.indexOf(a.exchange) - EXCHANGES.indexOf(b.exchange))
66 return listings[0] ?? null
67}
68
69// Requests.
70
71export function symbolsUrl(apikey: string): string {
72 return url('symbols-v2.txt', apikey, {})
73}
74
75export function eventsUrl(apikey: string, company: { exchange: string; symbol: string }): string {
76 return url('events', apikey, { exchange: company.exchange, symbol: company.symbol })
77}
78
79/** Level 1, the plain transcript: every plan has it, and the enhanced levels are refused on Starter. */
80export function transcriptUrl(apikey: string, company: { exchange: string; symbol: string }, year: number, quarter: number): string {
81 return url('transcript', apikey, { exchange: company.exchange, symbol: company.symbol, year, quarter, level: 1 })
82}
83
84/** The calendar takes the day in three parts; a `date` parameter is refused. */
85export function calendarUrl(apikey: string, date: string): string {
86 const [year, month, day] = date.split('-').map(Number)
87 return url('calendar', apikey, { year: year!, month: month!, day: day! })
88}
89
90function url(path: string, apikey: string, params: Record<string, string | number>): string {
91 const u = new URL(`${API}/${path}`)
92 u.searchParams.set('apikey', apikey)
93 for (const [key, value] of Object.entries(params)) u.searchParams.set(key, String(value))
94 return u.href
95}
96
97/** A day as YYYY-MM-DD in the zone the server runs in, which is the user's. */
98export function localDay(at: Date): string {
99 const pad = (n: number): string => String(n).padStart(2, '0')
100 return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`
101}
102
103// Refusals.
104
105/** What EarningsCall's status means, in words. `what` names the thing asked for. */
106export function failure(status: number, what: string, headers: { get(name: string): string | null }): string {
107 const plan = headers.get('x-plan-name')
108 if (status === 401) return `EarningsCall refused the API key asking for ${what}. Check the key in Connections.`
109 if (status === 403) return `EarningsCall refused ${what}: the ${plan ?? 'current'} plan does not include it.`
110 if (status === 404) return `EarningsCall does not have ${what}.`
111 if (status === 429) {
112 const wait = headers.get('retry-after')
113 return `EarningsCall's rate limit${plan ? ` on the ${plan} plan` : ''} was reached asking for ${what}; try again${wait ? ` in ${wait} s` : ' in a minute'}.`
114 }
115 return `EarningsCall answered ${status} asking for ${what}.`
116}
117
118/** The text with every occurrence of the key taken out. */
119export function scrub(text: string, secret: string): string {
120 return secret ? text.split(secret).join('…') : text
121}
122
123// Answers.
124
125export function readEvents(body: unknown): { company: string | null; calls: Call[] } {
126 const value = record(body)
127 const events = Array.isArray(value?.['events']) ? value['events'] : []
128 const calls: Call[] = []
129 for (const entry of events) {
130 const event = record(entry)
131 const year = event?.['year']
132 const quarter = event?.['quarter']
133 if (!isInt(year) || !isInt(quarter)) continue
134 calls.push({ year, quarter, date: text(event?.['conference_date']), published: event?.['is_published'] !== false })
135 }
136 calls.sort((a, b) => b.year - a.year || b.quarter - a.quarter)
137 return { company: text(value?.['company_name']), calls }
138}
139
140/** The newest call with a transcript out; one listed ahead of its date is not. */
141export function latestCall(calls: Call[]): Call | null {
142 return calls.find((call) => call.published) ?? null
143}
144
145export function readCalendar(body: unknown): CalendarRow[] {
146 const rows: CalendarRow[] = []
147 for (const entry of Array.isArray(body) ? body : []) {
148 const event = record(entry)
149 const exchange = text(event?.['exchange'])
150 const symbol = text(event?.['symbol'])
151 const year = event?.['year']
152 const quarter = event?.['quarter']
153 if (!exchange || !symbol || !isInt(year) || !isInt(quarter)) continue
154 rows.push({
155 exchange,
156 symbol,
157 company: text(event?.['company_name']) ?? symbol,
158 year,
159 quarter,
160 date: text(event?.['conference_date']),
161 ready: event?.['transcript_ready'] === true,
162 })
163 }
164 return rows.sort((a, b) => instant(a.date) - instant(b.date) || (a.symbol < b.symbol ? -1 : a.symbol > b.symbol ? 1 : 0))
165}
166
167// Paragraphs.
168
169const TARGET = 600
170/** A question introduced after less than this stays with the operator's lead-in ("Certainly."). */
171const LEAD_IN = 80
172/** How far before the first question a handover ("let's open the call to questions") still starts the Q&A. */
173const HANDOVER_REACH = 1500
174
175const QUESTION = /\b(?:first|next|final|last|following) question\b[^.?!]{0,30}?\bfrom\b/i
176const HANDOVER =
177 /\bopen (?:up )?the (?:call|line|lines|floor) (?:up )?(?:to|for) (?:questions|q ?& ?a)\b|\b(?:go|move|turn|proceed) (?:over )?to (?:the )?q ?& ?a\b|\bquestion[- ]and[- ]answer session\b/i
178const TO_OPERATOR = /^Operator,/
179const GIVES_LINE = /(?:please (?:proceed|go ahead)|your line is (?:now )?open)[.!]?$/i
180
181export function sentences(text: string): string[] {
182 const flat = text.replace(/\s+/g, ' ').trim()
183 if (!flat) return []
184 const out: string[] = []
185 let start = 0
186 for (const stop of flat.matchAll(/[.?!]+["”’)]*(?= ["“(]?[A-Z0-9$])/g)) {
187 const end = stop.index + stop[0].length
188 out.push(flat.slice(start, end))
189 start = end + 1
190 }
191 out.push(flat.slice(start))
192 return out
193}
194
195export function paragraphs(text: string): { paragraphs: string[]; qaStart: number | null } {
196 const all = sentences(text)
197 const qa = qaSentence(all)
198 const out: string[] = []
199 let qaStart: number | null = null
200 let current = ''
201 let turnEnded = false
202 all.forEach((sentence, i) => {
203 const breakHere =
204 current !== '' && (i === qa || turnEnded || current.length >= TARGET || (QUESTION.test(sentence) && current.length > LEAD_IN))
205 if (breakHere) {
206 out.push(current)
207 current = ''
208 }
209 if (i === qa) qaStart = out.length
210 current = current ? `${current} ${sentence}` : sentence
211 turnEnded = TO_OPERATOR.test(sentence) || GIVES_LINE.test(sentence)
212 })
213 if (current) out.push(current)
214 return { paragraphs: out, qaStart }
215}
216
217/** The sentence the Q&A opens on: after the handover just before the first question, or the first question itself. */
218function qaSentence(all: string[]): number | null {
219 const offsets: number[] = []
220 let at = 0
221 for (const sentence of all) {
222 offsets.push(at)
223 at += sentence.length + 1
224 }
225 const first = all.findIndex((sentence) => QUESTION.test(sentence))
226 if (first === -1) return null
227 for (let i = 0; i < first; i++) {
228 if (offsets[first]! - offsets[i]! <= HANDOVER_REACH && HANDOVER.test(all[i]!)) return i + 1
229 }
230 return first
231}
232
233// Tool results: text for the model, rows for the views.
234
235export function callsResult(company: Company, calls: Call[]): ToolResult {
236 const lines = calls.map((call) => `${label(call)} ${day(call.date)}${call.published ? '' : ' (not published yet)'}`)
237 const head = `${company.name} (${ticker(company)}): ${calls.length} earnings call${calls.length === 1 ? '' : 's'} on EarningsCall, newest first. Quarters are the company's fiscal ones.`
238 return {
239 content: [{ type: 'text', text: [head, ...lines].join('\n') }],
240 structuredContent: { exchange: company.exchange, symbol: company.symbol, company: company.name, rows: calls },
241 }
242}
243
244export function transcriptResult(company: Company, call: { year: number; quarter: number; date: string | null }, transcript: string): ToolResult {
245 const { paragraphs: parts, qaStart } = paragraphs(transcript)
246 const head = `${company.name} (${ticker(company)}), ${label(call)} earnings call, ${day(call.date)}. Machine-transcribed plain transcript from EarningsCall: no speaker names, and the paragraph breaks are inferred.`
247 const body = parts.flatMap((part, i) => (i === qaStart ? ['— Questions and answers —', part] : [part]))
248 return {
249 content: [{ type: 'text', text: [head, ...body].join('\n\n') }],
250 structuredContent: {
251 exchange: company.exchange,
252 symbol: company.symbol,
253 company: company.name,
254 year: call.year,
255 quarter: call.quarter,
256 date: call.date,
257 qaStart,
258 rows: parts.map((part, i) => ({ n: i + 1, text: part })),
259 },
260 }
261}
262
263export function calendarResult(date: string, rows: CalendarRow[]): ToolResult {
264 const lines = rows.map(
265 (row) => `${row.date ?? 'time unknown'} ${ticker(row)} ${row.company} ${label(row)} ${row.ready ? 'transcript ready' : 'no transcript yet'}`,
266 )
267 const head = rows.length ? `Earnings calls on ${date}: ${rows.length}.` : `No earnings calls on ${date}.`
268 return {
269 content: [{ type: 'text', text: [head, ...lines].join('\n') }],
270 structuredContent: { date, rows },
271 }
272}
273
274function label(call: { year: number; quarter: number }): string {
275 return `Q${call.quarter} ${call.year}`
276}
277
278function ticker(company: { exchange: string; symbol: string }): string {
279 return `${company.exchange}:${company.symbol}`
280}
281
282function day(date: string | null): string {
283 return date ? date.slice(0, 10) : 'date unknown'
284}
285
286// Small readers.
287
288function record(value: unknown): Record<string, unknown> | null {
289 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null
290}
291
292function text(value: unknown): string | null {
293 return typeof value === 'string' && value !== '' ? value : null
294}
295
296function isInt(value: unknown): value is number {
297 return typeof value === 'number' && Number.isInteger(value)
298}
299
300function instant(date: string | null): number {
301 const time = date ? Date.parse(date) : NaN
302 return Number.isNaN(time) ? Number.MAX_SAFE_INTEGER : time
303}