Jaspers Hub

jaspers / screener-mcp

pluginOfficialReviewed★ 0↓ 33

Jaspers Screener: Screens US SEC registrants by fundamentals, holders and insiders, then reads filings against written criteria with cited quotes.

43 KB
1// The screener's pure half: what the panel's state means, what goes to the server and what its
2// answers hold, how a value reads and how a typed one is read back, and the text a model is given.
3// The server (screener-mcp) owns the fields: their names, kinds, units and ops come from its guide,
4// so there is no list of them here. No React and no SDK: it is imported by the view and read by the
5// tests, which run under Node on their own.
6
7export type Kind = 'number' | 'text' | 'date' | 'list'
8/** How a number field reads: raw USD, a fraction shown as a percentage, a year, or a plain number. */
9export type Unit = 'usd' | 'fraction' | 'year' | 'number'
10
11export const OPS = ['gt', 'gte', 'lt', 'lte', 'eq', 'ne', 'between', 'in', 'contains', 'is_null', 'not_null'] as const
12export type Op = (typeof OPS)[number]
13
14export const VERDICTS = ['pass', 'fail', 'unclear', 'unverified'] as const
15export type Verdict = (typeof VERDICTS)[number]
16
17/** One field a filter, a sort or a column may name, as the server's guide describes it. */
18export interface Field {
19 field: string
20 group: string
21 kind: Kind
22 unit: Unit | null
23 ops: Op[]
24 sortable: boolean
25 description: string
26}
27
28/** The server's filter language: raw units, joined by AND. */
29export interface Filter {
30 field: string
31 op: Op
32 value?: number | string | (number | string)[]
33}
34
35export interface Sort {
36 field: string
37 dir: 'asc' | 'desc'
38}
39
40export interface Criterion {
41 id: string
42 question: string
43}
44
45/** The qualitative run of this panel. Present with no id, it is a request to start one. */
46export interface Run {
47 id?: string
48 status?: 'running' | 'done' | 'error'
49 error?: string
50}
51
52/** What the panel holds, and so what the orchestrator sets. */
53export interface State {
54 filters: Filter[]
55 /** The user's own list; empty for every company. */
56 tickers: string[]
57 includeUnlisted: boolean
58 sort: Sort
59 page: number
60 columns: string[]
61 /** The server's session, written by the view from its first answer. */
62 sessionId: string | null
63 criteria: Criterion[]
64 run: Run | null
65 /** Which verdicts the table shows once a run has results; empty for all. */
66 verdicts: Verdict[]
67 /** The ticker whose evidence is open; empty for none. */
68 open: string
69}
70
71export const DEFAULT_SORT: Sort = { field: 'revenue', dir: 'desc' }
72
73/** The columns a new screener shows. The fields filtered and sorted on are shown whatever this says. */
74export const DEFAULT_COLUMNS = [
75 'revenue',
76 'revenue_growth_yoy',
77 'gross_margin',
78 'operating_margin',
79 'net_margin',
80 'net_income',
81 'free_cash_flow',
82 'roe',
83 'debt_to_equity',
84 'insider_net_buy_value_6m',
85 'inst_holders',
86 'fiscal_year',
87]
88
89/** What the server allows in one run, for the editor to stop at. */
90export const MAX_CRITERIA = 8
91/** The most columns the panel's state takes. */
92export const MAX_COLUMNS = 40
93
94// The guide's field rows.
95
96const KINDS: Kind[] = ['number', 'text', 'date', 'list']
97const UNITS: Unit[] = ['usd', 'fraction', 'year', 'number']
98
99/** The rows of get_guide("fields"), read leniently: a row that names no field is dropped, a number field with no unit is a plain number. */
100export function readFields(rows: Record<string, unknown>[] | undefined): Field[] {
101 const fields: Field[] = []
102 for (const row of rows ?? []) {
103 if (typeof row.field !== 'string' || !row.field) continue
104 const kind = KINDS.includes(row.kind as Kind) ? (row.kind as Kind) : 'number'
105 const unit = UNITS.includes(row.unit as Unit) ? (row.unit as Unit) : kind === 'number' ? 'number' : null
106 fields.push({
107 field: row.field,
108 group: typeof row.group === 'string' ? row.group : '',
109 kind,
110 unit,
111 ops: Array.isArray(row.ops) ? row.ops.filter((op): op is Op => OPS.includes(op as Op)) : [],
112 sortable: typeof row.sortable === 'boolean' ? row.sortable : kind !== 'list',
113 description: typeof row.description === 'string' ? row.description : '',
114 })
115 }
116 return fields
117}
118
119/** The fields by group, in the guide's order. */
120export function groupFields(fields: Field[]): [string, Field[]][] {
121 const groups = new Map<string, Field[]>()
122 for (const field of fields) groups.set(field.group, [...(groups.get(field.group) ?? []), field])
123 return [...groups.entries()]
124}
125
126/** Parts of a field's name that do not read as words. */
127const WORDS: Record<string, string> = {
128 yoy: 'YoY',
129 cagr: 'CAGR',
130 roe: 'ROE',
131 roa: 'ROA',
132 eps: 'EPS',
133 fcf: 'FCF',
134 ocf: 'OCF',
135 sga: 'SG&A',
136 rnd: 'R&D',
137 ebitda: 'EBITDA',
138 sic: 'SIC',
139 cik: 'CIK',
140 hq: 'HQ',
141 lt: 'LT',
142 st: 'ST',
143 wa: 'avg',
144 inst: 'Inst.',
145 pct: '%',
146 '3y': '3Y',
147}
148
149/** A field's name as a column header and a chip say it: revenue_growth_yoy is "Revenue growth YoY". */
150export function fieldLabel(field: string): string {
151 const words = field.split('_').map((word) => WORDS[word] ?? word)
152 const first = words[0] ?? ''
153 // A word that was replaced is already written the way it reads; any other opens with a capital.
154 if (first === field.split('_')[0]) words[0] = first.charAt(0).toUpperCase() + first.slice(1)
155 return words.join(' ')
156}
157
158/** What every row carries whatever is asked for. Naming one as a column says nothing, so none is drawn twice and none is sent. */
159const IDENTITY = new Set(['cik', 'name', 'ticker', 'tickers', 'sic_description'])
160
161/** The columns the table draws after the company: the fields filtered on, the sort, then the ones asked for — the server's own rule. */
162export function shownColumns(filters: Filter[], sort: Sort, columns: string[]): string[] {
163 return [...new Set([...filters.map((f) => f.field), sort.field, ...columns])].filter((field) => !IDENTITY.has(field))
164}
165
166// Formatting. A cell says a number the way a screener does (B, M, K); a chip and the dialog say a
167// threshold in the unit the user types it in, so what is typed and what is shown agree.
168
169/** A number with K, M, B or T behind it when that says it exactly, so what is shown reads back as the same value. */
170function scaled(value: number, thousands: boolean): string {
171 const a = Math.abs(value)
172 const sign = value < 0 ? '-' : ''
173 for (const [suffix, scale] of SCALES) {
174 if (a < scale || (suffix === 'K' && !thousands)) continue
175 const short = String(clean(a / scale))
176 if (short.length <= 7) return `${sign}${short}${suffix}`
177 break
178 }
179 return `${sign}${clean(a)}`
180}
181
182const SCALES: [string, number][] = [
183 ['T', 1e12],
184 ['B', 1e9],
185 ['M', 1e6],
186 ['K', 1e3],
187]
188
189function money(value: number): string {
190 const a = Math.abs(value)
191 const sign = value < 0 ? '-' : ''
192 if (a >= 1e12) return `${sign}$${(a / 1e12).toFixed(2)}T`
193 if (a >= 1e9) return `${sign}$${(a / 1e9).toFixed(2)}B`
194 if (a >= 1e6) return `${sign}$${(a / 1e6).toFixed(1)}M`
195 if (a >= 1e3) return `${sign}$${(a / 1e3).toFixed(0)}K`
196 return `${sign}$${a.toFixed(2)}`
197}
198
199function plain(value: number): string {
200 const a = Math.abs(value)
201 if (a >= 1e9) return `${(value / 1e9).toFixed(2)}B`
202 if (a >= 1e6) return `${(value / 1e6).toFixed(1)}M`
203 if (Number.isInteger(value)) return value.toLocaleString('en-US')
204 return value.toFixed(2)
205}
206
207/** What one cell says. A value the company never reported is a dash, not a zero. */
208export function cellText(value: unknown, field?: Pick<Field, 'kind' | 'unit'>): string {
209 if (value === null || value === undefined) return '—'
210 if (Array.isArray(value)) return value.length > 0 ? value.join(' ') : '—'
211 if (typeof value === 'number') {
212 if (!Number.isFinite(value)) return '—'
213 if (field?.unit === 'usd') return money(value)
214 if (field?.unit === 'fraction') return `${(value * 100).toFixed(1)}%`
215 if (field?.unit === 'year') return String(value)
216 return plain(value)
217 }
218 return String(value)
219}
220
221/** Growth and returns read faster with a sign on them; nothing else is coloured. */
222export function signOf(field: string, value: unknown): 'up' | 'down' | null {
223 if (!/growth|cagr|change|^ro[ae]$/.test(field)) return null
224 if (typeof value !== 'number' || !Number.isFinite(value) || value === 0) return null
225 return value > 0 ? 'up' : 'down'
226}
227
228/** What the dialog writes beside a value, so the user knows what the number they type means. */
229export function unitHint(field: Pick<Field, 'kind' | 'unit'>): string {
230 if (field.kind === 'date') return 'YYYY-MM-DD'
231 if (field.kind !== 'number') return ''
232 if (field.unit === 'usd') return '$, with K M B'
233 if (field.unit === 'fraction') return '%'
234 if (field.unit === 'year') return 'year'
235 return 'K M B allowed'
236}
237
238const SCALE: Record<string, number> = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }
239
240/**
241 * A value as the user types it, in the server's raw units, or null when it cannot be read: "500M" and
242 * "$2B" are dollars, "20" on a fraction is 0.2, a date is ISO. Text is taken as it is.
243 */
244export function parseValue(text: string, field: Pick<Field, 'kind' | 'unit'>): number | string | null {
245 const typed = text.trim()
246 if (!typed) return null
247 if (field.kind === 'date') return /^\d{4}-\d{2}-\d{2}$/.test(typed) ? typed : null
248 if (field.kind !== 'number') return typed
249 if (field.unit === 'year') return /^\d{4}$/.test(typed) ? Number(typed) : null
250 const match = /^(-)?\s*\$?\s*(-)?\s*(\d[\d,]*\.?\d*|\.\d+)\s*([kmbt])?\s*(%)?$/i.exec(typed)
251 if (!match) return null
252 const number = Number(match[3]!.replace(/,/g, ''))
253 if (!Number.isFinite(number)) return null
254 const fraction = field.unit === 'fraction'
255 // A percent sign belongs on a fraction and a scale does not; anywhere else it is the other way round.
256 if (fraction ? match[4] : match[5]) return null
257 const signed = match[1] || match[2] ? -number : number
258 return clean(fraction ? signed / 100 : signed * (SCALE[match[4]?.toLowerCase() ?? ''] ?? 1))
259}
260
261/** A raw value as the user would type it, so a filter reads in the dialog the way it was entered. */
262export function valueText(raw: number | string, field: Pick<Field, 'kind' | 'unit'> | undefined): string {
263 if (typeof raw !== 'number') return raw
264 if (field?.unit === 'fraction') return `${clean(raw * 100)}%`
265 if (field?.unit === 'year') return String(raw)
266 if (field?.unit === 'usd') return raw < 0 ? `-$${scaled(-raw, true)}` : `$${scaled(raw, true)}`
267 return scaled(raw, false)
268}
269
270const OP_TEXT: Record<Op, string> = {
271 gt: '>',
272 gte: '≥',
273 lt: '<',
274 lte: '≤',
275 eq: '=',
276 ne: '≠',
277 between: 'between',
278 in: 'in',
279 contains: 'contains',
280 is_null: 'is empty',
281 not_null: 'has a value',
282}
283
284/** An op as the dialog's list and a chip say it. */
285export function opText(op: Op): string {
286 return OP_TEXT[op]
287}
288
289/** One filter in words: "Revenue ≥ $500M", "Gross margin 20% to 40%", "HQ state in TX, FL". */
290export function filterText(filter: Filter, field: Pick<Field, 'kind' | 'unit'> | undefined): string {
291 const label = fieldLabel(filter.field)
292 const say = (value: number | string): string =>
293 typeof value === 'string' && field?.kind !== 'date' ? `"${value}"` : valueText(value, field)
294 const { op, value } = filter
295 if (op === 'is_null' || op === 'not_null') return `${label} ${OP_TEXT[op]}`
296 if (op === 'between' && Array.isArray(value) && value.length === 2) return `${label} ${say(value[0]!)} to ${say(value[1]!)}`
297 if (Array.isArray(value)) return `${label} ${OP_TEXT[op]} ${value.map((v) => valueText(v, field)).join(', ')}`
298 return `${label} ${OP_TEXT[op]} ${value === undefined ? '?' : say(value)}`
299}
300
301/** A filter as the dialog edits it: what was typed, not yet read. */
302export interface Draft {
303 field: string
304 op: Op
305 a: string
306 b: string
307}
308
309export function toDraft(filter: Filter, field: Pick<Field, 'kind' | 'unit'> | undefined): Draft {
310 const { value } = filter
311 const text = (v: number | string | undefined): string => (v === undefined ? '' : valueText(v, field))
312 if (filter.op === 'between' && Array.isArray(value)) return { field: filter.field, op: filter.op, a: text(value[0]), b: text(value[1]) }
313 if (Array.isArray(value)) return { field: filter.field, op: filter.op, a: value.map(text).join(', '), b: '' }
314 return { field: filter.field, op: filter.op, a: text(value), b: '' }
315}
316
317/** The filter a draft says, or a sentence about what is wrong with it. */
318export function fromDraft(draft: Draft, field: Field | undefined): { filter: Filter } | { error: string } {
319 if (!field) return { error: 'Pick a field.' }
320 if (!field.ops.includes(draft.op)) return { error: `${fieldLabel(field.field)} takes ${field.ops.map(opText).join(', ')}.` }
321 const base = { field: field.field, op: draft.op }
322 if (draft.op === 'is_null' || draft.op === 'not_null') return { filter: base }
323 const hint = unitHint(field)
324 const wrong = (text: string): { error: string } => ({ error: `Cannot read "${text}"${hint ? ` (${hint})` : ''}.` })
325 if (draft.op === 'between') {
326 const low = parseValue(draft.a, field)
327 const high = parseValue(draft.b, field)
328 if (low === null) return wrong(draft.a)
329 if (high === null) return wrong(draft.b)
330 return { filter: { ...base, value: [low, high] } }
331 }
332 // A list field is asked "any of", whatever the op's name: the server takes a list there.
333 if (draft.op === 'in') {
334 const parts = draft.a.split(',').map((part) => part.trim()).filter(Boolean)
335 if (parts.length === 0) return { error: 'Give at least one value, separated by commas.' }
336 const values: (number | string)[] = []
337 for (const part of parts) {
338 const value = parseValue(part, field)
339 if (value === null) return wrong(part)
340 values.push(value)
341 }
342 return { filter: { ...base, value: values } }
343 }
344 if (draft.op === 'contains') return draft.a.trim() ? { filter: { ...base, value: draft.a.trim() } } : { error: 'Give the text to look for.' }
345 const value = parseValue(draft.a, field)
346 return value === null ? wrong(draft.a) : { filter: { ...base, value } }
347}
348
349/** Tickers as typed, as the SEC writes them: upper case, once each, split on commas, spaces and lines. */
350export function readTickers(text: string): string[] {
351 return [...new Set(text.split(/[\s,;]+/).map((t) => t.trim().toUpperCase()).filter(Boolean))]
352}
353
354// The screen: what one call sends, and what its answer holds.
355
356/** What the server's session holds, as its last answer said it. */
357export interface Echo {
358 sessionId: string
359 filters: Filter[]
360 tickers: string[]
361 includeUnlisted: boolean
362}
363
364/** A company a filter removed, as the server names it: its ticker and name, and the value of the filter's field that did not pass. */
365export interface Removed {
366 ticker: string | null
367 name: string | null
368 value: unknown
369}
370
371/** A filter as the server applied it, with what the answer says of it. */
372export interface Applied extends Filter {
373 /** Companies of the universe that pass it on its own. */
374 matches: number
375 /** Companies left after it and every filter before it; null from a server that does not say. */
376 left: number | null
377 /** The first companies, in the table's order, that it removed from those left before it; null from a server that does not name them. */
378 removed: Removed[] | null
379}
380
381/** A screen_companies answer without its rows: the dataset's meta, read leniently. */
382export interface Screen {
383 sessionId: string | null
384 universe: number
385 count: number
386 countBefore: number | null
387 applied: Applied[]
388 tickers: string[]
389 unknownTickers: string[]
390 includeUnlisted: boolean
391 ready: boolean
392 max: number
393}
394
395export function readScreen(meta: Record<string, unknown>): Screen | null {
396 if (typeof meta.count !== 'number') return null
397 const applied = Array.isArray(meta.applied_filters) ? meta.applied_filters.filter(isRecord) : []
398 return {
399 sessionId: typeof meta.session_id === 'string' ? meta.session_id : null,
400 universe: number(meta.universe),
401 count: meta.count,
402 countBefore: typeof meta.count_before === 'number' ? meta.count_before : null,
403 applied: applied
404 .filter((f) => typeof f.field === 'string' && OPS.includes(f.op as Op))
405 .map((f) => ({
406 field: f.field as string,
407 op: f.op as Op,
408 ...(f.value === undefined || f.value === null ? {} : { value: f.value as Filter['value'] }),
409 matches: number(f.matches),
410 left: typeof f.left === 'number' ? f.left : null,
411 removed: Array.isArray(f.removed) ? f.removed.filter(isRecord).map(readRemoved) : null,
412 })),
413 tickers: strings(meta.tickers),
414 unknownTickers: strings(meta.unknown_tickers),
415 includeUnlisted: meta.include_unlisted === true,
416 ready: meta.qualitative_ready === true,
417 max: number(meta.qualitative_max),
418 }
419}
420
421function readRemoved(company: Record<string, unknown>): Removed {
422 return {
423 ticker: typeof company.ticker === 'string' ? company.ticker : null,
424 name: typeof company.name === 'string' ? company.name : null,
425 value: company.value ?? null,
426 }
427}
428
429export function echoOf(screen: Screen): Echo | null {
430 if (!screen.sessionId) return null
431 return {
432 sessionId: screen.sessionId,
433 // The filter, and nothing the answer said about it.
434 filters: screen.applied.map(({ field, op, value }) => (value === undefined ? { field, op } : { field, op, value })),
435 tickers: screen.tickers,
436 includeUnlisted: screen.includeUnlisted,
437 }
438}
439
440type Listed = Pick<State, 'filters' | 'tickers' | 'includeUnlisted'>
441
442/** Whether the server's session holds the list this state asks for. A run freezes the session's list, so it waits for this. */
443export function inSync(state: Listed & { sessionId: string | null }, echo: Echo | null): boolean {
444 return (
445 echo !== null &&
446 echo.sessionId === state.sessionId &&
447 same(echo.filters.map(parts), state.filters.map(parts)) &&
448 same(echo.tickers, readTickers(state.tickers.join(' '))) &&
449 echo.includeUnlisted === state.includeUnlisted
450 )
451}
452
453/** How many of the companies each filter removes the server is asked to name: what one row of the funnel has room for. */
454export const REMOVED_NAMES = 8
455
456/**
457 * What one screen_companies call sends. The list (filters, include_unlisted) goes whole when the
458 * session does not hold it yet, and is left out when only the sort, the page or the columns moved:
459 * to the server a call that names the list is a change of it, and one that does not is a read.
460 * No tickers leave the view unless the user has a list of their own: they go as the server's
461 * tickers parameter, an empty one only to clear a list the session still holds, and never as a column.
462 * Every call asks whom each filter removed, since the funnel is drawn from whichever answer is on screen.
463 */
464export function screenArgs(
465 state: Listed & Pick<State, 'sort' | 'page' | 'columns' | 'sessionId'>,
466 echo: Echo | null,
467 pageSize: number,
468): Record<string, unknown> {
469 const columns = state.columns.filter((field) => !IDENTITY.has(field))
470 const read = { sort: state.sort, columns, limit: pageSize, offset: state.page * pageSize, removed_names: REMOVED_NAMES }
471 if (inSync(state, echo)) return { session_id: state.sessionId, ...read }
472 // Left out, the session's list stays, and a new session has none: [] is only how a list is taken back.
473 const held = (echo?.tickers.length ?? 0) > 0
474 return {
475 ...(state.sessionId ? { session_id: state.sessionId } : {}),
476 filters: state.filters,
477 ...(state.tickers.length > 0 ? { tickers: state.tickers } : held ? { tickers: [] } : {}),
478 include_unlisted: state.includeUnlisted,
479 ...read,
480 }
481}
482
483// The funnel: the universe, then what each filter did to the list in its turn.
484
485/** What the answer on screen says of one row of the funnel. */
486export interface FunnelAnswer {
487 /** Companies left after the row; null where the server does not say. */
488 left: number | null
489 /** How many of those the row before it left the row removed. */
490 out: number | null
491 /** Companies of the universe that pass the row's filter on its own; null for the universe. */
492 matches: number | null
493 /** The first of the companies removed, in the table's order; null where the server does not name them. */
494 removed: Removed[] | null
495}
496
497export interface FunnelRow {
498 /** What the row is of: the universe and the filters up to it. Two rows with one key say the same, whichever answer they are read from. */
499 key: string
500 /** Null for the universe. */
501 filter: Filter | null
502 /** Null while the answer on screen is of another list. */
503 answer: FunnelAnswer | null
504}
505
506/**
507 * The funnel's rows, which are the state's: the universe, then one for each filter, so they are there
508 * before any answer is. A row has an answer when the one on screen is of the same universe and the
509 * same filters up to it; what a filter left depends on nothing after it, so a filter added at the end
510 * leaves every row above it as it was.
511 */
512export function funnelRows(asked: Listed, screen: Screen | null): FunnelRow[] {
513 const keys = funnelKeys(asked.tickers, asked.includeUnlisted, asked.filters)
514 const held = screen ? funnelKeys(screen.tickers, screen.includeUnlisted, screen.applied) : []
515 let before: number | null = null
516 return keys.map((key, at) => {
517 const filter = asked.filters[at - 1] ?? null
518 if (!screen || held[at] !== key) return { key, filter, answer: null }
519 if (at === 0) {
520 before = screen.universe
521 return { key, filter, answer: { left: screen.universe, out: null, matches: null, removed: null } }
522 }
523 const applied = screen.applied[at - 1]!
524 // A server that does not say what a filter left still says it of two: the first leaves what it matches on its own, the last what passes them all.
525 const left = applied.left ?? (at === 1 ? applied.matches : at === screen.applied.length ? screen.count : null)
526 const out = before !== null && left !== null ? before - left : null
527 before = left
528 return { key, filter, answer: { left, out, matches: applied.matches, removed: applied.removed } }
529 })
530}
531
532/** One key for the universe and one for each filter, each of everything up to it. */
533function funnelKeys(tickers: string[], includeUnlisted: boolean, filters: Filter[]): string[] {
534 const universe = [readTickers(tickers.join(' ')), includeUnlisted]
535 return [null, ...filters].map((_, at) => JSON.stringify([universe, filters.slice(0, at).map(parts)]))
536}
537
538export type RowState = 'done' | 'working' | 'pending'
539
540/**
541 * How each row of the funnel is drawn. `answered` rows have an answer and `shown` of them have been
542 * shown, since the rows of an answer appear one after another. One row is at work at a time: the next
543 * to be shown, or the first one a call is out for (`busy`); when nothing is on its way, none is.
544 */
545export function funnelStates(rows: number, answered: number, shown: number, busy: boolean): RowState[] {
546 const done = Math.min(answered, shown)
547 const moving = busy || shown < answered
548 return Array.from({ length: rows }, (_, at) => (at < done ? 'done' : at === done && moving ? 'working' : 'pending'))
549}
550
551/** How far two lists agree from the start. */
552export function commonPrefix(a: readonly string[], b: readonly string[]): number {
553 let n = 0
554 while (n < a.length && n < b.length && a[n] === b[n]) n++
555 return n
556}
557
558/**
559 * A company a filter removed, as a row of the funnel names it: its ticker, and the value that did not
560 * pass where that is a figure. A dash is a company with no value, which no comparison matches. A
561 * sentence of text or a list says too much for a line of names.
562 */
563export function removedText(company: Removed, field: Pick<Field, 'kind' | 'unit'> | undefined): string {
564 const who = company.ticker ?? company.name ?? '—'
565 return field?.kind === 'number' || field?.kind === 'date' ? `${who} ${cellText(company.value, field)}` : who
566}
567
568/** How far over the qualitative stage's limit the list is, in the words the view and the orchestrator are told. */
569export function overLimit(screen: Pick<Screen, 'count' | 'max'>): string {
570 if (screen.count === 0) return 'The list is empty: there is nothing to check.'
571 const over = screen.count - screen.max
572 const n = (value: number): string => value.toLocaleString('en-US')
573 return `The list has ${n(screen.count)} companies, ${n(over)} over the ${screen.max} the qualitative stage takes. Narrow it first.`
574}
575
576// The qualitative stage.
577
578/** A criterion's id from its question: the words that say something, as the server's pattern wants them (^[a-z0-9_]{1,40}$), unlike any taken. */
579export function slug(question: string, taken: string[] = []): string {
580 const words = question.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().split(' ').filter(Boolean)
581 // A letter on its own is what an apostrophe left behind.
582 const telling = words.filter((word) => !SMALL_WORDS.has(word) && (word.length > 1 || /\d/.test(word)))
583 // Whole words, up to five, while they fit with room left for a number behind them.
584 let base = ''
585 for (const word of (telling.length > 0 ? telling : words).slice(0, 5)) {
586 if (base && base.length + 1 + word.length > 36) break
587 base = base ? `${base}_${word}` : word.slice(0, 36)
588 }
589 base ||= 'criterion'
590 let id = base
591 for (let n = 2; taken.includes(id); n++) id = `${base}_${n}`
592 return id
593}
594
595const SMALL_WORDS = new Set(
596 'a an and any are as at be been by do does for from has have in is it its of on or that the their this to was were which with company'.split(' '),
597)
598
599/**
600 * Whether a run still has to be started. A `run` with no id is the request, from the Run button or
601 * from the orchestrator; what a model fills in beside it (a status, an empty id) does not count as
602 * started. Only what the view itself leaves does: the server's run id, or a failed start with its reason.
603 */
604export function needsStart(criteria: Criterion[] | undefined, run: Run | null | undefined): boolean {
605 return Boolean(criteria?.length) && run !== null && run !== undefined && !run.id && !(run.status === 'error' && run.error)
606}
607
608/**
609 * Where the qualitative stage stands, in one word, for the bar and the orchestrator alike. A run asked
610 * for with no criteria never starts, and must not read as if it had: that is how a reply came to promise
611 * results of a run that did not exist. What a model fills in beside the request counts no more here than in needsStart.
612 */
613export type Stage = 'none' | 'no_criteria' | 'starting' | 'running' | 'done' | 'error'
614
615export function stageOf(criteria: Criterion[] | undefined, run: Run | null | undefined): Stage {
616 if (!run) return 'none'
617 if (run.status === 'error' && run.error) return 'error'
618 if (!run.id) return criteria?.length ? 'starting' : 'no_criteria'
619 return run.status === 'done' ? 'done' : 'running'
620}
621
622/** What a started run answers with. */
623export function readStart(text: string): { runId: string; total: number } {
624 const value = parseJson(text)
625 if (typeof value.run_id !== 'string' || !value.run_id) throw new Error('the run did not start: no run id came back')
626 return { runId: value.run_id, total: number(value.total) }
627}
628
629export interface Progress {
630 total: number
631 queued: number
632 running: number
633 error: number
634 pass: number
635 fail: number
636 unclear: number
637 unverified: number
638}
639
640/** One quote of a filing that an answer rests on, already checked by the server. */
641export interface Citation {
642 id: string
643 title: string
644 url: string | null
645 quote: string
646}
647
648export interface CriterionResult {
649 id: string
650 verdict: string
651 rationale: string
652 verification: string
653 confidence: number | null
654 note: string
655 /** Citation ids. */
656 evidence: string[]
657 gathered: { searched: number; judged: number; paragraphs: number; relevant: number } | null
658}
659
660export interface CompanyResult {
661 cik: number
662 ticker: string | null
663 name: string | null
664 status: string
665 /** While it is being read: the step it is in, as the server names it. */
666 step: string | null
667 verdict: Verdict | null
668 error: string | null
669 criteria: CriterionResult[]
670}
671
672/** A run's first phase: its companies' earnings calls are fetched, and no company is read before they are in. Off: the run reads the filings alone. */
673export interface Phase {
674 status: (typeof PHASES)[number]
675 /** Companies looked at, of how many. */
676 done: number
677 total: number
678}
679
680const PHASES = ['fetching', 'done', 'off'] as const
681
682export interface RunSnapshot {
683 status: 'queued' | 'running' | 'done'
684 /** When the run started and ended, by the server's clock, as ISO text. */
685 createdAt: string | null
686 finishedAt: string | null
687 progress: Progress
688 /** Null before the phase starts, and from a server that does not say. */
689 phase: Phase | null
690 criteria: Criterion[]
691 results: CompanyResult[]
692 citations: Record<string, Citation>
693}
694
695/**
696 * One get_qualitative_screen answer: its results are the dataset's rows, the rest its meta. Read
697 * leniently. A criterion's evidence is a list of citation ids; the citations themselves sit once at the top.
698 */
699export function readRun(meta: Record<string, unknown>, rows: Record<string, unknown>[]): RunSnapshot {
700 const citations: Record<string, Citation> = {}
701 for (const entry of Array.isArray(meta.citations) ? meta.citations : []) {
702 const citation = readCitation(entry)
703 if (citation) citations[citation.id] = citation
704 }
705 const results = rows.map((row): CompanyResult => ({
706 cik: number(row.cik),
707 ticker: typeof row.ticker === 'string' ? row.ticker : null,
708 name: typeof row.name === 'string' ? row.name : null,
709 status: typeof row.status === 'string' ? row.status : 'queued',
710 step: typeof row.step === 'string' ? row.step : null,
711 verdict: VERDICTS.includes(row.verdict as Verdict) ? (row.verdict as Verdict) : null,
712 error: typeof row.error === 'string' ? row.error : null,
713 criteria: (Array.isArray(row.criteria) ? row.criteria.filter(isRecord) : []).map((c) => readCriterion(c, citations)),
714 }))
715 const progress = isRecord(meta.progress) ? meta.progress : {}
716 return {
717 status: meta.status === 'done' ? 'done' : meta.status === 'queued' ? 'queued' : 'running',
718 createdAt: typeof meta.created_at === 'string' ? meta.created_at : null,
719 finishedAt: typeof meta.finished_at === 'string' ? meta.finished_at : null,
720 phase: readPhase(meta.earnings_calls),
721 progress: {
722 total: number(progress.total),
723 queued: number(progress.queued),
724 running: number(progress.running),
725 error: number(progress.error),
726 pass: number(progress.pass),
727 fail: number(progress.fail),
728 unclear: number(progress.unclear),
729 unverified: number(progress.unverified),
730 },
731 criteria: (Array.isArray(meta.criteria) ? meta.criteria.filter(isRecord) : [])
732 .filter((c) => typeof c.id === 'string' && typeof c.question === 'string')
733 .map((c) => ({ id: c.id as string, question: c.question as string })),
734 results,
735 citations,
736 }
737}
738
739/** The same answer when it came back as text rather than rows. */
740export function readRunText(text: string): RunSnapshot {
741 const { results, ...meta } = parseJson(text)
742 return readRun(meta, Array.isArray(results) ? results.filter(isRecord) : [])
743}
744
745function readPhase(value: unknown): Phase | null {
746 if (!isRecord(value)) return null
747 const status = PHASES.find((one) => one === value.status)
748 return status ? { status, done: number(value.done), total: number(value.total) } : null
749}
750
751function readCitation(entry: unknown): Citation | null {
752 if (!isRecord(entry) || typeof entry.id !== 'string' || !entry.id) return null
753 const url = typeof entry.url === 'string' && entry.url.startsWith('https://') ? entry.url : null
754 return {
755 id: entry.id,
756 title: typeof entry.title === 'string' ? entry.title : '',
757 url,
758 quote: typeof entry.quote === 'string' ? entry.quote : '',
759 }
760}
761
762function readCriterion(c: Record<string, unknown>, citations: Record<string, Citation>): CriterionResult {
763 // An id with no citation behind it has nothing to show, so it is not kept.
764 const evidence = (Array.isArray(c.evidence) ? c.evidence : []).filter((id): id is string => typeof id === 'string' && id in citations)
765 const gathered = isRecord(c.gathered) ? c.gathered : null
766 return {
767 id: typeof c.id === 'string' ? c.id : '',
768 verdict: typeof c.verdict === 'string' ? c.verdict : '',
769 rationale: typeof c.rationale === 'string' ? c.rationale : '',
770 verification: typeof c.verification === 'string' ? c.verification : '',
771 confidence: typeof c.confidence === 'number' ? c.confidence : null,
772 note: typeof c.note === 'string' ? c.note : '',
773 evidence,
774 gathered: gathered && {
775 searched: Array.isArray(gathered.searched) ? gathered.searched.length : 0,
776 judged: number(gathered.judged),
777 paragraphs: number(gathered.paragraphs),
778 relevant: Array.isArray(gathered.good) ? gathered.good.length : 0,
779 },
780 }
781}
782
783/** The companies with one verdict, by ticker. */
784export function verdictTickers(run: Pick<RunSnapshot, 'results'> | null, verdict: Verdict): string[] {
785 return (run?.results ?? []).flatMap((result) => (result.verdict === verdict && result.ticker ? [result.ticker] : []))
786}
787
788/** The companies that passed, by ticker. The table's rows are the list that was read; only these are what the user asked for. */
789export function passedTickers(run: Pick<RunSnapshot, 'results'> | null): string[] {
790 return verdictTickers(run, 'pass')
791}
792
793/** A company's part of a run is over, as done or as an error. */
794function over(result: Pick<CompanyResult, 'status'>): boolean {
795 return result.status === 'done' || result.status === 'error'
796}
797
798/**
799 * What one read of a run asks for. The first is the whole run, at once, so a panel that comes back to
800 * one shows where it is. Each one after waits on the server a few seconds and asks only for the
801 * companies not yet over: a company's answers come once, when it finishes, and the app, which keeps
802 * every answer it is given, is not handed the whole run again every few seconds. While the earnings
803 * calls are fetched no company is read, so none is asked for and the answer is the counts alone.
804 */
805export function followArgs(runId: string, seen: RunSnapshot | null, waitSeconds: number): Record<string, unknown> {
806 if (!seen) return { run_id: runId }
807 const reading = seen.phase?.status === 'fetching' ? [] : seen.results.flatMap((result) => (!over(result) && result.ticker ? [result.ticker] : []))
808 return { run_id: runId, wait_seconds: waitSeconds, tickers: reading }
809}
810
811/** A run as it was last seen, brought up to date by an answer that holds only some of its companies. */
812export function mergeRun(seen: RunSnapshot, next: RunSnapshot): RunSnapshot {
813 const fresh = new Map(next.results.map((result) => [result.cik, result]))
814 return { ...next, results: seen.results.map((result) => fresh.get(result.cik) ?? result) }
815}
816
817/** How many of a run's companies are over, as done or as an error. */
818export function finished(progress: Progress): number {
819 return progress.total - progress.queued - progress.running
820}
821
822/** What a run is doing, in a few words. Its companies' earnings calls come first: no company is read before they are in. */
823export function doingText(run: Pick<RunSnapshot, 'status' | 'progress' | 'phase'>): string {
824 const { progress, phase } = run
825 if (run.status === 'done') return `${progress.total} read`
826 if (phase?.status === 'fetching') return `fetching earnings calls: ${phase.done} of ${phase.total}`
827 return `reading: ${finished(progress)} of ${progress.total}`
828}
829
830/** Where a run is, in one line: what it is doing, then the counts by verdict. */
831export function progressText(run: Pick<RunSnapshot, 'status' | 'progress' | 'phase'>): string {
832 const counts = VERDICTS.map((verdict) => `${run.progress[verdict]} ${verdict}`)
833 if (run.progress.error > 0) counts.push(`${run.progress.error} error`)
834 return `${doingText(run)} · ${counts.join(' · ')}`
835}
836
837/**
838 * How long a run has gone, or took, as a clock: 1:42, and 1:02:03 past the hour. It counts from when
839 * the server says the run started, so a panel that comes back to a run shows its time and not its
840 * own; a clock here that is behind the server's never counts backwards. Null for a run that does not say.
841 */
842export function elapsedText(run: Pick<RunSnapshot, 'createdAt' | 'finishedAt'>, now: number): string | null {
843 const from = run.createdAt ? Date.parse(run.createdAt) : NaN
844 const to = run.finishedAt ? Date.parse(run.finishedAt) : now
845 if (!Number.isFinite(from) || !Number.isFinite(to)) return null
846 const seconds = Math.max(0, Math.floor((to - from) / 1000))
847 const two = (value: number): string => String(value).padStart(2, '0')
848 const minutes = Math.floor((seconds % 3600) / 60)
849 return seconds >= 3600 ? `${Math.floor(seconds / 3600)}:${two(minutes)}:${two(seconds % 60)}` : `${minutes}:${two(seconds % 60)}`
850}
851
852/** The steps the server takes a company through while it reads it, in order, as it names them, and the word for each. */
853const READING: [step: string, word: string][] = [
854 ['search', 'search'],
855 ['jev', 'relevance'],
856 ['llm', 'answer'],
857 ['verify', 'verify'],
858]
859
860/** Where a company being read is: how many steps are behind it, of how many, and the word for the one it is in. Null on none yet, and on one this view has not heard of. */
861export function readingAt(step: string | null): { at: number; of: number; word: string } | null {
862 const at = READING.findIndex(([name]) => name === step)
863 return at < 0 ? null : { at, of: READING.length, word: READING[at]![1] }
864}
865
866/** A verification that did not hold, in words; nothing for one that did, or for a claim of absence, which cites nothing. */
867export function verificationMark(verification: string): string | null {
868 if (!verification || verification === 'supports' || verification === 'not_checked') return null
869 if (verification === 'contradicts') return 'The cited evidence contradicts this verdict.'
870 if (verification === 'says_nothing') return 'The cited evidence does not show this.'
871 if (verification === 'uncertain') return 'The verifier was not confident the cited evidence carries this verdict.'
872 return `Verification: ${verification}.`
873}
874
875/** What was searched and read for one criterion, in one line. */
876export function gatheredText(gathered: NonNullable<CriterionResult['gathered']>): string {
877 const n = (count: number, one: string, many: string): string => `${count.toLocaleString('en-US')} ${count === 1 ? one : many}`
878 return [
879 `${n(gathered.searched, 'search', 'searches')} made`,
880 `${n(gathered.judged, 'passage', 'passages')} judged`,
881 `${n(gathered.paragraphs, 'paragraph', 'paragraphs')} read`,
882 `${gathered.relevant.toLocaleString('en-US')} relevant`,
883 ].join(', ')
884}
885
886/**
887 * The open company's evidence as text a model can quote. The first line names what it is, since a
888 * citation of the panel takes that line as its title; each quote is followed by its marker, [^id],
889 * which the chat resolves against the citations of the same tool result.
890 */
891export function evidenceText(company: CompanyResult, questions: Criterion[], citations: Record<string, Citation>, runId: string): string {
892 const name = company.name ? ` (${company.name})` : ''
893 const lines = [`Screener evidence for ${company.ticker ?? `CIK ${company.cik}`}${name}, qualitative run ${runId}: ${company.verdict ?? company.status}`]
894 if (company.error) lines.push('', `Error: ${company.error}`)
895 for (const c of company.criteria) {
896 const question = questions.find((q) => q.id === c.id)?.question
897 const confidence = c.confidence === null ? '' : `, confidence ${c.confidence.toFixed(2)}`
898 lines.push('', `## ${c.id}${question ? `: ${question}` : ''}`, `Verdict: ${c.verdict} (verification: ${c.verification || 'none'}${confidence})`)
899 if (c.rationale) lines.push(`Rationale: ${c.rationale}`)
900 if (c.note) lines.push(`Note: ${c.note}`)
901 for (const id of c.evidence) {
902 const citation = citations[id]
903 if (citation) lines.push(`- "${citation.quote}" [^${citation.id}]${citation.title ? ` (${citation.title})` : ''}`)
904 }
905 if (c.gathered) lines.push(`Retrieval: ${gatheredText(c.gathered)}.`)
906 }
907 return lines.join('\n')
908}
909
910// What the orchestrator reads.
911
912/** What the view publishes: the screen behind the rows on screen, and where the run is. */
913export interface Output {
914 session_id: string | null
915 universe: number
916 count: number
917 /** matches: what the filter passes on its own. left: what remains after it and every one before it, which is what the funnel shows. */
918 applied_filters: (Filter & { matches: number; left?: number })[]
919 /** The tickers on this page, in order. */
920 tickers: string[]
921 unknown_tickers: string[]
922 sort: Sort
923 qualitative_ready: boolean
924 qualitative_max: number
925 /** status is a Stage. earnings_calls: the run's first phase, once it has started. passed: the tickers with a pass, once there are results; the rows above are only the list that was read. */
926 qualitative?: { run_id: string | null; status: string; progress: Progress | null; earnings_calls?: Phase; passed?: string[]; error?: string }
927 open: string
928}
929
930/** The filters as the output carries them: what each left, as the funnel shows it, and not whom it removed, which is a company's worth of names for every filter. */
931export function publishedFilters(screen: Pick<Screen, 'applied'> | null): Output['applied_filters'] {
932 return (screen?.applied ?? []).map(({ removed: _removed, left, ...filter }) => (left === null ? filter : { ...filter, left }))
933}
934
935/** The app takes 4,096 bytes of output: the tickers at the end of the page give way first. */
936export const OUTPUT_BYTES = 3800
937
938export function fitOutput(output: Output): Output {
939 let fitted = output
940 while (fitted.tickers.length > 0 && byteLength(JSON.stringify(fitted)) > OUTPUT_BYTES) {
941 fitted = { ...fitted, tickers: fitted.tickers.slice(0, Math.floor(fitted.tickers.length * 0.8)) }
942 }
943 return fitted
944}
945
946/** The one line the model's map carries: how many pass, of how many, behind which screen, and where the run is. */
947export function summarize(state: Partial<State>, output: Partial<Output>): string {
948 const n = (value: number | undefined): string => (value ?? 0).toLocaleString('en-US')
949 const filters = state.filters ?? []
950 const parts = [`Screener: ${n(output.count)} of ${n(output.universe)} pass`]
951 if (filters.length > 0) parts[0] += ` ${filters.length} filter${filters.length === 1 ? '' : 's'}`
952 if (state.tickers?.length) parts.push(`own list of ${state.tickers.length}`)
953 const sort = state.sort ?? DEFAULT_SORT
954 parts.push(`sort ${sort.field} ${sort.dir}`)
955 const q = output.qualitative
956 if (q) {
957 const progress = q.progress
958 parts.push(
959 q.status === 'error'
960 ? `qualitative run failed: ${q.error ?? 'unknown error'}`
961 : progress
962 ? `qualitative run ${q.status}: ${progressText({ status: q.status === 'done' ? 'done' : 'running', progress, phase: q.earnings_calls ?? null })}`
963 : q.status === 'no_criteria'
964 ? 'a qualitative run was asked for but NOT started: state.criteria is empty'
965 : `qualitative run ${q.status}`,
966 )
967 } else if (output.qualitative_ready) parts.push('ready for the qualitative stage')
968 if (output.open) parts.push(`evidence open for ${output.open}`)
969 return parts.join(', ')
970}
971
972// Small things.
973
974function parseJson(text: string): Record<string, unknown> {
975 try {
976 const value: unknown = JSON.parse(text)
977 return isRecord(value) ? value : {}
978 } catch {
979 return {}
980 }
981}
982
983function strings(value: unknown): string[] {
984 return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
985}
986
987function number(value: unknown): number {
988 return typeof value === 'number' && Number.isFinite(value) ? value : 0
989}
990
991function isRecord(value: unknown): value is Record<string, unknown> {
992 return typeof value === 'object' && value !== null && !Array.isArray(value)
993}
994
995/** A filter by what it says, whatever order its keys were written in. */
996function parts(filter: Filter): unknown[] {
997 return [filter.field, filter.op, filter.value ?? null]
998}
999
1000function same(a: unknown, b: unknown): boolean {
1001 return JSON.stringify(a) === JSON.stringify(b)
1002}
1003
1004/** A product or a quotient without the digits floating point adds at the end: 15.3 / 100 is 0.153. */
1005function clean(value: number): number {
1006 return Number(value.toPrecision(12))
1007}
1008
1009function byteLength(text: string): number {
1010 return new TextEncoder().encode(text).length
1011}