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 view's pure half: its state read the forgiving way (main stores state as written, so a key can
2// be missing or the wrong shape), which call it shows, find in a transcript, and what it publishes
3// and says in the model's map. No React and no bridge here, so it runs under the tests.
4
5export type Row = Record<string, unknown>
6
7export interface CallsState {
8 /** The ticker. Empty shows the calendar. */
9 symbol: string
10 exchange: string | null
11 /** The call's fiscal year and quarter, both or neither; neither is the latest call. */
12 year: number | null
13 quarter: number | null
14 /** The calendar's day, YYYY-MM-DD; none is today. */
15 date: string | null
16 find: string
17}
18
19export interface Call {
20 year: number
21 quarter: number
22 date: string | null
23 published: boolean
24}
25
26export interface CalendarRow {
27 exchange: string
28 symbol: string
29 company: string
30 year: number
31 quarter: number
32 date: string | null
33 ready: boolean
34}
35
36/** One find match: the paragraph's index and the match's place in its text. */
37export interface Match {
38 paragraph: number
39 start: number
40 end: number
41}
42
43export interface Segment {
44 text: string
45 /** The match's number across the whole transcript, or null for the text between matches. */
46 match: number | null
47}
48
49// State.
50
51export function readState(raw: unknown): CallsState {
52 const state = record(raw) ?? {}
53 const year = int(state['year'])
54 const quarter = int(state['quarter'])
55 const both = year !== null && quarter !== null && quarter >= 1 && quarter <= 4
56 const exchange = typeof state['exchange'] === 'string' ? state['exchange'].trim().toUpperCase() : ''
57 const date = state['date']
58 return {
59 symbol: typeof state['symbol'] === 'string' ? state['symbol'].trim().toUpperCase() : '',
60 exchange: exchange || null,
61 year: both ? year : null,
62 quarter: both ? quarter : null,
63 date: typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null,
64 find: typeof state['find'] === 'string' ? state['find'].trim() : '',
65 }
66}
67
68/** The state as the view writes it back, whole: only what is set, so a cleared key is gone. */
69export function stored(state: CallsState): Row {
70 const out: Row = { symbol: state.symbol }
71 if (state.exchange) out['exchange'] = state.exchange
72 if (state.year !== null && state.quarter !== null) {
73 out['year'] = state.year
74 out['quarter'] = state.quarter
75 }
76 if (state.date) out['date'] = state.date
77 if (state.find) out['find'] = state.find
78 return out
79}
80
81/** The quarter asked for, else the newest call with a transcript out. */
82export function chosenQuarter(calls: Call[], year: number | null, quarter: number | null): { year: number; quarter: number } | null {
83 if (year !== null && quarter !== null) return { year, quarter }
84 const latest = calls.find((call) => call.published)
85 return latest ? { year: latest.year, quarter: latest.quarter } : null
86}
87
88// Rows from a run.
89
90/**
91 * Whether a run's meta answers this ask. A run keeps its last answer while the next one is out, so
92 * a new ticker would otherwise be read against the last company's calls, and a new day against the
93 * last day's calendar.
94 */
95export function answers(meta: Row, ask: Row): boolean {
96 return Object.entries(ask).every(([key, value]) => meta[key] === value)
97}
98
99export function readCalls(rows: Row[] | undefined): Call[] {
100 const calls: Call[] = []
101 for (const row of rows ?? []) {
102 const year = int(row['year'])
103 const quarter = int(row['quarter'])
104 if (year === null || quarter === null) continue
105 calls.push({ year, quarter, date: text(row['date']), published: row['published'] !== false })
106 }
107 return calls
108}
109
110/** One string per row, in order; a row without text stays as an empty paragraph so the Q&A index still points right. */
111export function readParagraphs(rows: Row[] | undefined): string[] {
112 return (rows ?? []).map((row) => (typeof row['text'] === 'string' ? row['text'] : ''))
113}
114
115export function readCalendarRows(rows: Row[] | undefined): CalendarRow[] {
116 const out: CalendarRow[] = []
117 for (const row of rows ?? []) {
118 const exchange = text(row['exchange'])
119 const symbol = text(row['symbol'])
120 const year = int(row['year'])
121 const quarter = int(row['quarter'])
122 if (!exchange || !symbol || year === null || quarter === null) continue
123 out.push({ exchange, symbol, company: text(row['company']) ?? symbol, year, quarter, date: text(row['date']), ready: row['ready'] === true })
124 }
125 return out
126}
127
128// Find.
129
130/** Every occurrence of the query, as typed text rather than a pattern, whatever the case. */
131export function findMatches(paragraphs: string[], query: string): Match[] {
132 const q = query.trim()
133 if (!q) return []
134 const pattern = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')
135 const matches: Match[] = []
136 paragraphs.forEach((paragraph, index) => {
137 for (const found of paragraph.matchAll(pattern)) matches.push({ paragraph: index, start: found.index, end: found.index + found[0].length })
138 })
139 return matches
140}
141
142/** A paragraph cut into the text between matches and the matches themselves, in order. */
143export function segments(paragraph: string, ranges: { start: number; end: number; index: number }[]): Segment[] {
144 const out: Segment[] = []
145 let at = 0
146 for (const range of ranges) {
147 if (range.start > at) out.push({ text: paragraph.slice(at, range.start), match: null })
148 out.push({ text: paragraph.slice(range.start, range.end), match: range.index })
149 at = range.end
150 }
151 if (at < paragraph.length || out.length === 0) out.push({ text: paragraph.slice(at), match: null })
152 return out
153}
154
155/** The match with up to `width` characters either side, cut back to whole words. */
156export function snippet(paragraph: string, start: number, end: number, width = 80): string {
157 const from = Math.max(0, start - width)
158 const to = Math.min(paragraph.length, end + width)
159 let head = paragraph.slice(from, start)
160 let tail = paragraph.slice(end, to)
161 if (from > 0) head = head.includes(' ') ? head.slice(head.indexOf(' ') + 1) : ''
162 if (to < paragraph.length) tail = tail.includes(' ') ? tail.slice(0, tail.lastIndexOf(' ')) : ''
163 return `${from > 0 ? '…' : ''}${head}${paragraph.slice(start, end)}${tail}${to < paragraph.length ? '…' : ''}`
164}
165
166// Output, for the orchestrator to read. Main refuses a publish over 4096 bytes of JSON.
167
168const PUBLISH_MAX = 4096
169const RECENT_CALLS = 8
170const SNIPPETS = 5
171const CALENDAR_EVENTS = 20
172
173export interface CallOutput {
174 mode: 'call'
175 symbol: string
176 exchange: string | null
177 company: string | null
178 year: number | null
179 quarter: number | null
180 date: string | null
181 calls: Call[]
182 paragraphs: number
183 /** The paragraph number the questions start at, as the view numbers them. */
184 qaParagraph: number | null
185 find: string
186 matches: number
187 snippets: { paragraph: number; text: string }[]
188 error: string | null
189}
190
191export function callOutput(input: {
192 state: CallsState
193 exchange: string | null
194 company: string | null
195 calls: Call[]
196 chosen: { year: number; quarter: number } | null
197 date: string | null
198 paragraphs: string[]
199 qaStart: number | null
200 matches: Match[]
201 error: string | null
202}): CallOutput {
203 const { paragraphs, matches } = input
204 return {
205 mode: 'call',
206 symbol: input.state.symbol,
207 exchange: input.exchange,
208 company: cap(input.company, 120),
209 year: input.chosen?.year ?? null,
210 quarter: input.chosen?.quarter ?? null,
211 date: input.date,
212 calls: input.calls.slice(0, RECENT_CALLS),
213 paragraphs: paragraphs.length,
214 qaParagraph: input.qaStart === null ? null : input.qaStart + 1,
215 find: cap(input.state.find, 100) ?? '',
216 matches: matches.length,
217 snippets: matches.slice(0, SNIPPETS).map((m) => ({ paragraph: m.paragraph + 1, text: snippet(paragraphs[m.paragraph] ?? '', m.start, m.end, 80) })),
218 error: cap(input.error, 300),
219 }
220}
221
222/**
223 * The transcript as the panel's text, which a model reads by path where output only counts it: a
224 * line naming the call, which a citation of it takes as its title, what kind of transcript it is,
225 * then the paragraphs under the numbers the view shows, the questions marked where they start. An
226 * empty paragraph keeps its number and is left out.
227 */
228export function transcriptText(input: {
229 symbol: string
230 company: string | null
231 year: number
232 quarter: number
233 date: string | null
234 paragraphs: string[]
235 qaStart: number | null
236}): string {
237 const title = [`${input.symbol} Q${input.quarter} ${input.year} earnings call`, input.company, input.date?.slice(0, 10)].filter(Boolean).join(' · ')
238 const blocks = [
239 `${title}\nMachine-transcribed plain transcript: no speaker names, and the paragraph breaks are inferred. ¶ numbers are the paragraphs as the view numbers them.`,
240 ]
241 input.paragraphs.forEach((paragraph, i) => {
242 if (i === input.qaStart) blocks.push('Questions and answers')
243 if (paragraph.trim()) blocks.push(`¶${i + 1} ${paragraph.trim()}`)
244 })
245 return blocks.join('\n\n')
246}
247
248export interface CalendarOutput {
249 mode: 'calendar'
250 date: string
251 count: number
252 events: CalendarRow[]
253 error: string | null
254}
255
256export function calendarOutput(day: string, rows: CalendarRow[], error: string | null): CalendarOutput {
257 const output: CalendarOutput = {
258 mode: 'calendar',
259 date: day,
260 count: rows.length,
261 events: rows.slice(0, CALENDAR_EVENTS).map((row) => ({ ...row, company: cap(row.company, 60) ?? row.symbol })),
262 error: cap(error, 300),
263 }
264 while (output.events.length > 0 && byteLength(JSON.stringify(output)) > PUBLISH_MAX) output.events.pop()
265 return output
266}
267
268/** The model's one line for this panel. An output still about another company says nothing about this one. */
269export function summarize(state: unknown, output: unknown): string {
270 const read = readState(state)
271 const out = record(output)
272 const error = text(out?.['error'])
273 if (!read.symbol) {
274 const date = text(out?.['date'])
275 if (out?.['mode'] !== 'calendar' || !date || (read.date && read.date !== date)) return read.date ? `Earnings calendar ${read.date}` : 'Earnings calendar, today'
276 return error ? `Earnings calendar ${date}: ${error}` : `Earnings calendar ${date}: ${int(out['count']) ?? 0} calls`
277 }
278 if (out?.['mode'] !== 'call' || out['symbol'] !== read.symbol) return `${read.symbol} earnings call`
279 if (error) return `${read.symbol} earnings call: ${error}`
280 const year = int(out['year'])
281 const quarter = int(out['quarter'])
282 if (year === null || quarter === null) return `${read.symbol} earnings call`
283 const date = text(out['date'])
284 const paragraphs = int(out['paragraphs'])
285 const find = text(out['find'])
286 const matches = int(out['matches']) ?? 0
287 return [
288 `${read.symbol} Q${quarter} ${year} earnings call`,
289 date ? `, ${date.slice(0, 10)}` : '',
290 paragraphs !== null ? `, ${paragraphs} paragraphs` : '',
291 find ? `; "${find}" ${matches} match${matches === 1 ? '' : 'es'}` : '',
292 ].join('')
293}
294
295// Days.
296
297/** Today as YYYY-MM-DD in the viewer's own zone. */
298export function today(now: Date): string {
299 const pad = (n: number): string => String(n).padStart(2, '0')
300 return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
301}
302
303export function shiftDay(day: string, by: number): string {
304 const [year, month, date] = day.split('-').map(Number)
305 return new Date(Date.UTC(year!, month! - 1, date! + by)).toISOString().slice(0, 10)
306}
307
308// Small readers.
309
310function record(value: unknown): Row | null {
311 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Row) : null
312}
313
314function text(value: unknown): string | null {
315 return typeof value === 'string' && value !== '' ? value : null
316}
317
318function int(value: unknown): number | null {
319 return typeof value === 'number' && Number.isInteger(value) ? value : null
320}
321
322function cap(value: string | null, max: number): string | null {
323 if (value === null) return null
324 return value.length > max ? `${value.slice(0, max - 1)}…` : value
325}
326
327function byteLength(value: string): number {
328 return new TextEncoder().encode(value).length
329}