Jaspers Hub

jaspers / yfinance

pluginOfficialReviewed★ 0↓ 11

yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.

17 KB
1import { display as d } from './format.ts'
2
3export { smallPercent } from './format.ts'
4import { FREQUENCIES, LINES, lineValue, STATEMENTS, type Frequency, type Statement } from './server/financials.ts'
5import { MOVER_LISTS, type MoverList } from './server/quotes.ts'
6import { clamp, int, list, num, record, records, text, type Row } from './values.ts'
7
8// The views' pure half: each view's state read the forgiving way (main stores state as written),
9// what each publishes (under the 4,096 bytes a publish may take) and says in the model's map, and
10// the small reckonings the drawing needs. A run arrives as the app hands it over: the tool's rows as
11// data, the rest of its structured answer as meta. No React and no bridge here.
12
13export const OUTPUT_MAX = 4096
14export const WATCHLIST_MAX = 30
15/** How often the quote views ask again. */
16export const REFRESH_MS = 60_000
17export const STRIKE_CHOICES = [5, 10, 15, 20, 30] as const
18
19const SYMBOL = /^[A-Z0-9.^=&-]{1,32}$/
20const DAY = /^\d{4}-\d{2}-\d{2}$/
21
22/** A run as a view has it. */
23export interface Run {
24 data: Record<string, unknown>[] | undefined
25 meta: Record<string, unknown>
26 error: string | null
27 loading: boolean
28}
29
30export function runOf(run: Run): { rows: Row[]; meta: Row; error: string | null; loading: boolean } {
31 return { rows: run.data ?? [], meta: run.meta, error: run.error, loading: run.loading }
32}
33
34/** The message as the server or main wrote it, without the wrapping the IPC adds. */
35export function cleanError(message: string): string {
36 return message.replace(/^Error invoking remote method '[^']*': (?:Error: )?/, '')
37}
38
39export function cleanSymbol(raw: unknown): string {
40 const symbol = typeof raw === 'string' ? raw.trim().toUpperCase() : ''
41 return SYMBOL.test(symbol) ? symbol : ''
42}
43
44export function readSymbol(state: unknown): string {
45 return cleanSymbol(record(state)?.['symbol'])
46}
47
48export function yahooPage(symbol: string): string {
49 return `https://finance.yahoo.com/quote/${encodeURIComponent(symbol)}/`
50}
51
52export function tone(value: number | null | undefined): 'up' | 'down' | '' {
53 return typeof value === 'number' && value > 0 ? 'up' : typeof value === 'number' && value < 0 ? 'down' : ''
54}
55
56export function signedText(value: number | null | undefined, suffix = ''): string {
57 return typeof value === 'number' && Number.isFinite(value) ? `${d.signed(value)}${suffix}` : '—'
58}
59
60/** Where a value sits between low and high, 0 to 1; null when the range says nothing. */
61export function position(low: number | null | undefined, high: number | null | undefined, value: number | null | undefined): number | null {
62 if (typeof low !== 'number' || typeof high !== 'number' || typeof value !== 'number' || !(high > low)) return null
63 return clamp((value - low) / (high - low), 0, 1)
64}
65
66const FUNDS = new Set(['ETF', 'MUTUALFUND'])
67
68/** Whether a quote type is a fund, whose holdings the overview shows. */
69export function isFund(type: string | null | undefined): boolean {
70 return typeof type === 'string' && FUNDS.has(type)
71}
72
73
74/** Yahoo's display with a true minus. */
75function shown(value: unknown): string {
76 return (text(value) ?? '—').replace(/^-/, '−')
77}
78
79function short(message: string | null): string | null {
80 if (!message) return null
81 const clean = cleanError(message)
82 return clean.length > 200 ? `${clean.slice(0, 199)}…` : clean
83}
84
85// Company overview.
86
87export const OVERVIEW_STATS: [string, string][] = [
88 ['marketCap', 'Market cap'],
89 ['enterpriseValue', 'Enterprise value'],
90 ['trailingPE', 'P/E'],
91 ['forwardPE', 'Forward P/E'],
92 ['pegRatio', 'PEG'],
93 ['priceToSales', 'Price/sales'],
94 ['priceToBook', 'Price/book'],
95 ['enterpriseToEbitda', 'EV/EBITDA'],
96 ['trailingEps', 'EPS'],
97 ['revenue', 'Revenue'],
98 ['profitMargin', 'Profit margin'],
99 ['operatingMargin', 'Operating margin'],
100 ['returnOnEquity', 'ROE'],
101 ['debtToEquity', 'Debt/equity'],
102 ['dividendYield', 'Dividend yield'],
103 ['payoutRatio', 'Payout ratio'],
104 ['beta', 'Beta'],
105 ['shortPercentOfFloat', 'Short % of float'],
106 ['averageVolume', 'Avg volume'],
107]
108
109const GRID = new Map(OVERVIEW_STATS)
110
111/** The statistics the overview's grid shows, in its order, from the statistics run's rows. */
112export function overviewStats(rows: Row[] | undefined): { key: string; label: string; value: string }[] {
113 const byKey = new Map((rows ?? []).map((row) => [text(row['key']) ?? '', row]))
114 return [...GRID].flatMap(([key, label]) => {
115 const row = byKey.get(key)
116 return row ? [{ key, label, value: shown(row['display']) }] : []
117 })
118}
119
120/** A rating change in words: "Upgraded to Buy from Hold". */
121export function ratingAction(action: string | null, from: string | null, to: string | null): string {
122 const was = from && from !== to ? ` from ${from}` : ''
123 switch (action) {
124 case 'upgrade':
125 return `Upgraded to ${to ?? '?'}${was}`
126 case 'downgrade':
127 return `Downgraded to ${to ?? '?'}${was}`
128 case 'maintain':
129 return `Maintained ${to ?? '?'}`
130 case 'reiterate':
131 return `Reiterated ${to ?? '?'}`
132 case 'initiate':
133 return `Initiated at ${to ?? '?'}`
134 default:
135 return [action, to].filter(Boolean).join(' ')
136 }
137}
138
139const RATINGS: [string, string][] = [
140 ['strongBuy', 'Strong buy'],
141 ['buy', 'Buy'],
142 ['hold', 'Hold'],
143 ['sell', 'Sell'],
144 ['strongSell', 'Strong sell'],
145]
146
147/** One month's ratings as shares of a bar; nothing when nobody rated. */
148export function ratingMix(month: Row | null | undefined): { key: string; label: string; count: number; share: number }[] {
149 const counts = RATINGS.map(([key, label]) => ({ key, label, count: num(month?.[key]) ?? 0 }))
150 const total = counts.reduce((sum, c) => sum + c.count, 0)
151 return total > 0 ? counts.map((c) => ({ ...c, share: c.count / total })) : []
152}
153
154export type OverviewRuns = { quote: Run; statistics: Run; analysts: Run; earnings: Run; profile: Run }
155
156export type OverviewOutput = {
157 symbol: string
158 name: string | null
159 type: string | null
160 currency: string | null
161 price: number | null
162 changePercent: number | null
163 marketState: string | null
164 marketCap: number | null
165 pe: number | null
166 forwardPe: number | null
167 dividendYieldPercent: number | null
168 fiftyTwoWeekLow: number | null
169 fiftyTwoWeekHigh: number | null
170 target: { low: number | null; mean: number | null; high: number | null; analysts: number | null } | null
171 recommendation: string | null
172 nextEarnings: string | null
173 sector: string | null
174 industry: string | null
175 errors: string[]
176}
177
178export function overviewOutput(symbol: string, runs: OverviewRuns): OverviewOutput {
179 const quote = runs.quote.data?.[0] ?? {}
180 const stats = record(runs.statistics.meta['stats']) ?? {}
181 const target = record(runs.analysts.meta['target'])
182 const recommendation = record(runs.analysts.meta['recommendation'])
183 const next = record(runs.earnings.meta['next'])
184 const profile = runs.profile.meta
185 const pick = (a: unknown, b: unknown): number | null => num(a) ?? num(b)
186 const errors = (Object.entries(runs) as [string, Run][]).flatMap(([name, run]) => (run.error ? [`${name}: ${short(run.error)}`] : []))
187 return {
188 symbol,
189 name: text(quote['name']) ?? text(runs.statistics.meta['name']) ?? text(profile['name']),
190 type: text(quote['type']) ?? text(profile['type']),
191 currency: text(quote['currency']) ?? text(runs.statistics.meta['currency']),
192 price: pick(quote['price'], stats['price']),
193 changePercent: num(quote['changePercent']),
194 marketState: text(quote['marketState']),
195 marketCap: pick(quote['marketCap'], stats['marketCap']),
196 pe: pick(quote['pe'], stats['trailingPE']),
197 forwardPe: pick(quote['forwardPe'], stats['forwardPE']),
198 dividendYieldPercent: pick(quote['dividendYield'], stats['dividendYield']),
199 fiftyTwoWeekLow: pick(quote['fiftyTwoWeekLow'], stats['fiftyTwoWeekLow']),
200 fiftyTwoWeekHigh: pick(quote['fiftyTwoWeekHigh'], stats['fiftyTwoWeekHigh']),
201 target:
202 target && num(target['mean']) !== null
203 ? { low: num(target['low']), mean: num(target['mean']), high: num(target['high']), analysts: num(target['analysts']) }
204 : null,
205 recommendation: text(recommendation?.['key']),
206 nextEarnings: text(next?.['date']),
207 sector: text(profile['sector']),
208 industry: text(profile['industry']),
209 errors,
210 }
211}
212
213export function overviewSummary(state: unknown, output: unknown): string {
214 const symbol = readSymbol(state)
215 if (!symbol) return 'Company: no symbol'
216 const out = record(output)
217 const price = num(out?.['price'])
218 if (!out || price === null) return `${symbol} company overview`
219 const target = record(out['target'])
220 const parts = [
221 `${d.price(price)} ${d.signedPercent(num(out['changePercent']))}`,
222 num(out['marketCap']) !== null && `market cap ${d.big(num(out['marketCap']))}`,
223 num(out['pe']) !== null && `P/E ${d.fixed(num(out['pe']), 1)}`,
224 num(target?.['mean']) !== null && `target ${d.price(num(target?.['mean']))}${text(out['recommendation']) ? ` (${text(out['recommendation'])!.replace(/_/g, ' ')})` : ''}`,
225 text(out['nextEarnings']) && `earnings ${text(out['nextEarnings'])}`,
226 ].filter(Boolean)
227 const name = text(out['name'])
228 return `${symbol}${name ? ` ${name}` : ''}: ${parts.join(', ')}`
229}
230
231// Watchlist.
232
233export function readWatchlist(state: unknown): string[] {
234 const symbols: string[] = []
235 for (const raw of list(record(state)?.['symbols'])) {
236 const symbol = cleanSymbol(raw)
237 if (symbol && !symbols.includes(symbol)) symbols.push(symbol)
238 if (symbols.length === WATCHLIST_MAX) break
239 }
240 return symbols
241}
242
243export function addSymbol(symbols: string[], raw: string): string[] {
244 const symbol = cleanSymbol(raw)
245 if (!symbol || symbols.includes(symbol) || symbols.length >= WATCHLIST_MAX) return symbols
246 return [...symbols, symbol]
247}
248
249export function removeSymbol(symbols: string[], symbol: string): string[] {
250 return symbols.filter((s) => s !== symbol)
251}
252
253export type WatchlistOutput = {
254 count: number
255 missing: string[]
256 rows: { symbol: string; price: number | null; changePercent: number | null }[]
257}
258
259export function watchlistOutput(symbols: string[], quotes: Row[]): WatchlistOutput {
260 const bySymbol = new Map(quotes.map((q) => [text(q['symbol']) ?? '', q]))
261 const rows: WatchlistOutput['rows'] = []
262 const missing: string[] = []
263 for (const symbol of symbols) {
264 const q = bySymbol.get(symbol)
265 if (q) rows.push({ symbol, price: num(q['price']), changePercent: num(q['changePercent']) })
266 else missing.push(symbol)
267 }
268 return { count: symbols.length, missing, rows }
269}
270
271export function watchlistSummary(_state: unknown, output: unknown): string {
272 const out = record(output)
273 const rows = records(out?.['rows'])
274 const count = num(out?.['count']) ?? rows.length
275 if (!count) return 'Watchlist: empty'
276 const shown = rows.slice(0, 5).map((r) => `${text(r['symbol'])} ${d.price(num(r['price']))} ${d.signedPercent(num(r['changePercent']))}`)
277 return `Watchlist (${count}): ${shown.join(', ')}${rows.length > 5 ? ', …' : ''}`
278}
279
280// Markets.
281
282export const MARKET_TABS: { list: MoverList; label: string }[] = [
283 { list: 'gainers', label: 'Gainers' },
284 { list: 'losers', label: 'Losers' },
285 { list: 'most_active', label: 'Most active' },
286 { list: 'trending', label: 'Trending' },
287]
288
289export const MARKET_SCREENS: { list: MoverList; label: string }[] = [
290 { list: 'undervalued_growth', label: 'Undervalued growth' },
291 { list: 'growth_technology', label: 'Growth technology' },
292 { list: 'aggressive_small_caps', label: 'Aggressive small caps' },
293 { list: 'small_cap_gainers', label: 'Small cap gainers' },
294 { list: 'undervalued_large_caps', label: 'Undervalued large caps' },
295]
296
297export function readMarketList(state: unknown): MoverList {
298 const listed = record(state)?.['list']
299 return typeof listed === 'string' && listed in MOVER_LISTS ? (listed as MoverList) : 'gainers'
300}
301
302type Listing = { symbol: string; name: string | null; price: number | null; changePercent: number | null }
303
304function listing(q: Row): Listing {
305 return { symbol: text(q['symbol']) ?? '', name: text(q['name']), price: num(q['price']), changePercent: num(q['changePercent']) }
306}
307
308export type MarketsOutput = {
309 list: MoverList
310 title: string | null
311 total: number | null
312 tiles: Listing[]
313 top: Listing[]
314}
315
316export function marketsOutput(list: MoverList, tiles: Row[], screen: { title: unknown; total: unknown; rows: Row[] }): MarketsOutput {
317 return {
318 list,
319 title: text(screen.title),
320 total: num(screen.total),
321 tiles: tiles.slice(0, 16).map(listing),
322 top: screen.rows.slice(0, 10).map(listing),
323 }
324}
325
326export function marketsSummary(state: unknown, output: unknown): string {
327 const out = record(output)
328 const tiles = records(out?.['tiles'])
329 const top = records(out?.['top'])
330 if (!tiles.length && !top.length) return `Markets: ${readMarketList(state).replace(/_/g, ' ')}`
331 const move = (r: Row, name: unknown): string => `${text(name) ?? text(r['symbol'])} ${d.signedPercent(num(r['changePercent']))}`
332 const head = tiles.slice(0, 3).map((r) => move(r, r['name']))
333 const list = top.slice(0, 3).map((r) => move(r, r['symbol']))
334 const title = text(out?.['title']) ?? readMarketList(state)
335 return `Markets: ${[head.join(', '), list.length ? `${title}: ${list.join(', ')}` : ''].filter(Boolean).join('; ')}`
336}
337
338// Financials.
339
340export type FinancialsState = { symbol: string; statement: Statement; frequency: Frequency }
341
342export function readFinancialsState(state: unknown): FinancialsState {
343 const raw = record(state) ?? {}
344 const statement = STATEMENTS.find((s) => s === raw['statement']) ?? 'income'
345 let frequency = FREQUENCIES.find((f) => f === raw['frequency']) ?? 'annual'
346 // A balance sheet has no trailing twelve months.
347 if (statement === 'balance' && frequency === 'trailing') frequency = 'annual'
348 return { symbol: readSymbol(raw), statement, frequency }
349}
350
351export type FinancialsOutput = FinancialsState & {
352 currency: string | null
353 periods: string[]
354 lines: number
355 highlights: Record<string, (number | null)[]>
356 error: string | null
357}
358
359export function financialsOutput(state: FinancialsState, run: Run): FinancialsOutput {
360 const periods = list(run.meta['periods']).map(text).filter((p): p is string => p !== null)
361 const rows = run.data ?? []
362 const highlights: Record<string, (number | null)[]> = {}
363 for (const row of rows) {
364 if (row['highlight'] !== true) continue
365 const values = record(row['values']) ?? {}
366 highlights[text(row['label']) ?? ''] = periods.slice(0, 8).map((p) => num(values[p]))
367 }
368 return {
369 ...state,
370 currency: text(run.meta['currency']),
371 periods: periods.slice(0, 8),
372 lines: rows.length,
373 highlights,
374 error: short(run.error),
375 }
376}
377
378export function financialsSummary(state: unknown, output: unknown): string {
379 const read = readFinancialsState(state)
380 if (!read.symbol) return 'Financials: no symbol'
381 const out = record(output)
382 const head = `${read.symbol} ${read.statement}, ${read.frequency}`
383 const error = text(out?.['error'])
384 if (error) return `${head}: ${error}`
385 const periods = list(out?.['periods']).map(text)
386 const highlights = record(out?.['highlights'])
387 if (!periods[0] || !highlights) return `${head} statement`
388 const kinds = new Map(LINES[read.statement].map((l) => [l.label, l.kind]))
389 const values = Object.entries(highlights).map(
390 ([label, series]) => `${label} ${lineValue(kinds.get(label) ?? 'money', num(list(series)[0]), d)}`,
391 )
392 const currency = text(out?.['currency'])
393 return `${head}${currency ? ` (${currency})` : ''}, ${periods[0]}: ${values.join(', ')}`
394}
395
396// Options.
397
398export type OptionsState = { symbol: string; expiration: string | null; strikes: number }
399
400export function readOptionsState(state: unknown): OptionsState {
401 const raw = record(state) ?? {}
402 const expiration = text(raw['expiration'])
403 const strikes = int(raw['strikes'])
404 return {
405 symbol: readSymbol(raw),
406 expiration: expiration && DAY.test(expiration) ? expiration : null,
407 strikes: strikes === undefined ? 10 : clamp(strikes, 1, 50),
408 }
409}
410
411export type OptionsOutput = {
412 symbol: string
413 expiration: string | null
414 expirations: string[]
415 underlying: number | null
416 strikes: number
417 atm: { strike: number | null; callMid: number | null; putMid: number | null; callIvPercent: number | null; putIvPercent: number | null } | null
418 putCallVolumeRatio: number | null
419 putCallOpenInterestRatio: number | null
420 error: string | null
421}
422
423export function optionsOutput(state: OptionsState, run: Run): OptionsOutput {
424 const meta = run.meta
425 const atm = record(meta['atm'])
426 const totals = record(meta['totals'])
427 return {
428 symbol: text(meta['symbol']) ?? state.symbol,
429 expiration: text(meta['expiration']) ?? state.expiration,
430 expirations: list(meta['expirations'])
431 .map(text)
432 .filter((e): e is string => e !== null)
433 .slice(0, 60),
434 underlying: num(record(meta['underlying'])?.['price']),
435 strikes: (run.data ?? []).length,
436 atm: atm
437 ? {
438 strike: num(atm['strike']),
439 callMid: num(atm['callMid']),
440 putMid: num(atm['putMid']),
441 callIvPercent: num(atm['callIvPercent']),
442 putIvPercent: num(atm['putIvPercent']),
443 }
444 : null,
445 putCallVolumeRatio: num(totals?.['putCallVolumeRatio']),
446 putCallOpenInterestRatio: num(totals?.['putCallOpenInterestRatio']),
447 error: short(run.error),
448 }
449}
450
451export function optionsSummary(state: unknown, output: unknown): string {
452 const read = readOptionsState(state)
453 if (!read.symbol) return 'Options: no symbol'
454 const out = record(output)
455 const expiration = text(out?.['expiration'])
456 const error = text(out?.['error'])
457 if (error) return `${read.symbol} options: ${error}`
458 const atm = record(out?.['atm'])
459 if (!expiration || !atm) return `${read.symbol} options`
460 const iv = num(atm['callIvPercent']) ?? num(atm['putIvPercent'])
461 const ratio = num(out?.['putCallOpenInterestRatio'])
462 return [
463 `${read.symbol} options ${expiration}: at ${d.price(num(atm['strike']))} call ${d.price(num(atm['callMid']))} / put ${d.price(num(atm['putMid']))}`,
464 iv !== null && `IV ${d.percent(iv, 1)}`,
465 ratio !== null && `put/call OI ${d.fixed(ratio)}`,
466 ]
467 .filter(Boolean)
468 .join(', ')
469}