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.

12 KB
1import { plain as f } from '../format.ts'
2import { num, record, records, text } from '../values.ts'
3import { capped, parts, toolResult, type ToolResult } from './result.ts'
4import { YahooError } from './yahoo.ts'
5
6// Quotes and the lists made of them: v7 quote, the market summary, the saved screens, and trending,
7// which all describe a listing the same way; and search, which finds symbols and news. Percentages
8// here are in percent, as Yahoo writes them in these answers: changePercent 0.32 is 0.32%.
9
10export type Quote = {
11 symbol: string
12 name: string | null
13 /** EQUITY, ETF, MUTUALFUND, INDEX, FUTURE, CURRENCY, CRYPTOCURRENCY. */
14 type: string | null
15 exchange: string | null
16 currency: string | null
17 /** The decimals Yahoo shows the price with. */
18 priceHint: number | null
19 /** PRE, REGULAR, POST, POSTPOST, PREPRE, CLOSED. */
20 marketState: string | null
21 price: number | null
22 change: number | null
23 changePercent: number | null
24 previousClose: number | null
25 open: number | null
26 dayLow: number | null
27 dayHigh: number | null
28 volume: number | null
29 /** Three-month average daily volume. */
30 averageVolume: number | null
31 marketCap: number | null
32 pe: number | null
33 forwardPe: number | null
34 eps: number | null
35 /** In percent. */
36 dividendYield: number | null
37 fiftyTwoWeekLow: number | null
38 fiftyTwoWeekHigh: number | null
39 preMarketPrice: number | null
40 preMarketChangePercent: number | null
41 postMarketPrice: number | null
42 postMarketChangePercent: number | null
43 /** Yahoo's average analyst rating: "2.2 - Buy". */
44 rating: string | null
45 /** When the price was set, ISO. */
46 time: string | null
47}
48
49export type Match = {
50 symbol: string
51 name: string | null
52 type: string | null
53 typeLabel: string | null
54 exchange: string | null
55 sector: string | null
56 industry: string | null
57}
58
59export type Story = {
60 title: string
61 publisher: string | null
62 /** ISO. */
63 published: string | null
64 url: string
65 tickers: string[]
66}
67
68export type Screen = {
69 title: string
70 description: string | null
71 total: number | null
72 rows: Quote[]
73}
74
75/** The lists the movers tool offers: the name it takes, and Yahoo's saved screen (trending has none). */
76export const MOVER_LISTS = {
77 gainers: 'day_gainers',
78 losers: 'day_losers',
79 most_active: 'most_actives',
80 trending: null,
81 undervalued_growth: 'undervalued_growth_stocks',
82 growth_technology: 'growth_technology_stocks',
83 aggressive_small_caps: 'aggressive_small_caps',
84 small_cap_gainers: 'small_cap_gainers',
85 undervalued_large_caps: 'undervalued_large_caps',
86} as const
87
88export type MoverList = keyof typeof MOVER_LISTS
89
90export const TRENDING_TITLE = 'Trending tickers'
91export const TRENDING_DESCRIPTION = 'The tickers people are looking up most right now.'
92
93const STATES: Record<string, string> = {
94 PRE: 'pre-market',
95 REGULAR: 'market open',
96 POST: 'after hours',
97 POSTPOST: 'market closed',
98 PREPRE: 'market closed',
99 CLOSED: 'market closed',
100}
101
102function iso(seconds: number | null): string | null {
103 return seconds === null ? null : new Date(seconds * 1000).toISOString()
104}
105
106/** One listing, as any of the quote answers describe it. Names are the long one first, or the short one. */
107export function readQuote(raw: unknown, names: 'long' | 'short' = 'long'): Quote | null {
108 const q = record(raw)
109 const symbol = text(q?.['symbol'])
110 if (!q || !symbol) return null
111 const long = text(q['longName'])
112 const short = text(q['shortName'])
113 const trailingYield = num(q['trailingAnnualDividendYield'])
114 return {
115 symbol,
116 name: (names === 'long' ? long ?? short : short ?? long) ?? text(q['displayName']),
117 type: text(q['quoteType']),
118 exchange: text(q['fullExchangeName']) ?? text(q['exchange']),
119 currency: text(q['currency']),
120 priceHint: num(q['priceHint']),
121 marketState: text(q['marketState']),
122 price: num(q['regularMarketPrice']),
123 change: num(q['regularMarketChange']),
124 changePercent: num(q['regularMarketChangePercent']),
125 previousClose: num(q['regularMarketPreviousClose']),
126 open: num(q['regularMarketOpen']),
127 dayLow: num(q['regularMarketDayLow']),
128 dayHigh: num(q['regularMarketDayHigh']),
129 volume: num(q['regularMarketVolume']),
130 averageVolume: num(q['averageDailyVolume3Month']),
131 marketCap: num(q['marketCap']),
132 pe: num(q['trailingPE']),
133 forwardPe: num(q['forwardPE']),
134 eps: num(q['epsTrailingTwelveMonths']),
135 dividendYield: num(q['dividendYield']) ?? (trailingYield === null ? null : trailingYield * 100),
136 fiftyTwoWeekLow: num(q['fiftyTwoWeekLow']),
137 fiftyTwoWeekHigh: num(q['fiftyTwoWeekHigh']),
138 preMarketPrice: num(q['preMarketPrice']),
139 preMarketChangePercent: num(q['preMarketChangePercent']),
140 postMarketPrice: num(q['postMarketPrice']),
141 postMarketChangePercent: num(q['postMarketChangePercent']),
142 rating: text(q['averageAnalystRating']),
143 time: iso(num(q['regularMarketTime'])),
144 }
145}
146
147function present<T>(items: (T | null)[]): T[] {
148 return items.filter((item): item is T => item !== null)
149}
150
151export function readQuotes(json: unknown): Quote[] {
152 return present(records(record(record(json)?.['quoteResponse'])?.['result']).map((q) => readQuote(q)))
153}
154
155export function readMarketSummary(json: unknown): Quote[] {
156 return present(records(record(record(json)?.['marketSummaryResponse'])?.['result']).map((q) => readQuote(q, 'short')))
157}
158
159export function readScreen(json: unknown): Screen {
160 const screen = records(record(record(json)?.['finance'])?.['result'])[0]
161 if (!screen) throw new YahooError('The market data service answered with no screen.')
162 return {
163 title: text(screen['title']) ?? 'Screen',
164 description: text(screen['description']),
165 total: num(screen['total']),
166 rows: present(records(screen['quotes']).map((q) => readQuote(q))),
167 }
168}
169
170export function readTrending(json: unknown): string[] {
171 const result = records(record(record(json)?.['finance'])?.['result'])[0]
172 return present(records(result?.['quotes']).map((q) => text(q['symbol'])))
173}
174
175export function readSearch(json: unknown): Match[] {
176 return present(
177 records(record(json)?.['quotes']).map((q) => {
178 const symbol = text(q['symbol'])
179 if (!symbol) return null
180 return {
181 symbol,
182 name: text(q['longname']) ?? text(q['shortname']),
183 type: text(q['quoteType']),
184 typeLabel: text(q['typeDisp']),
185 exchange: text(q['exchDisp']) ?? text(q['exchange']),
186 sector: text(q['sectorDisp']) ?? text(q['sector']),
187 industry: text(q['industryDisp']) ?? text(q['industry']),
188 }
189 }),
190 )
191}
192
193export function readNews(json: unknown): Story[] {
194 const stories = present(
195 records(record(json)?.['news']).map((n) => {
196 const title = text(n['title'])
197 const url = text(n['link'])
198 if (!title || !url?.startsWith('https://')) return null
199 return {
200 title,
201 publisher: text(n['publisher']),
202 published: iso(num(n['providerPublishTime'])),
203 url,
204 tickers: present((Array.isArray(n['relatedTickers']) ? n['relatedTickers'] : []).map(text)),
205 }
206 }),
207 )
208 return stories.sort((a, b) => (b.published ?? '').localeCompare(a.published ?? ''))
209}
210
211// Text.
212
213function when(isoTime: string | null): string | null {
214 return isoTime ? `${isoTime.slice(0, 16).replace('T', ' ')} UTC` : null
215}
216
217function range(low: number | null, high: number | null, hint: number | null): string | null {
218 return low !== null && high !== null ? `${f.price(low, hint)}-${f.price(high, hint)}` : null
219}
220
221function move(q: Quote): string | null {
222 if (q.change === null && q.changePercent === null) return null
223 if (q.change === null) return f.signedPercent(q.changePercent)
224 return `${f.signed(q.change, q.priceHint ?? 2)} (${f.signedPercent(q.changePercent)})`
225}
226
227function state(q: Quote): string | null {
228 return q.marketState ? (STATES[q.marketState] ?? q.marketState.toLowerCase()) : null
229}
230
231/** A sentence of what there is, capitalized, or nothing. */
232function sentence(body: string | null | undefined | false): string | null {
233 return body ? `${body.charAt(0).toUpperCase()}${body.slice(1)}.` : null
234}
235
236export function quoteLine(q: Quote): string {
237 const hint = q.priceHint
238 const about = parts(q.type, q.exchange, q.currency)
239 const head = `${q.symbol}${q.name ? ` ${q.name}` : ''}${about ? ` (${about})` : ''}`
240 const extended =
241 q.preMarketPrice !== null
242 ? `pre-market ${f.price(q.preMarketPrice, hint)} (${f.signedPercent(q.preMarketChangePercent)})`
243 : q.postMarketPrice !== null
244 ? `post-market ${f.price(q.postMarketPrice, hint)} (${f.signedPercent(q.postMarketChangePercent)})`
245 : null
246 const day = parts(
247 range(q.dayLow, q.dayHigh, hint) && `Day ${range(q.dayLow, q.dayHigh, hint)}`,
248 q.open !== null && `open ${f.price(q.open, hint)}`,
249 q.previousClose !== null && `previous close ${f.price(q.previousClose, hint)}`,
250 )
251 const volume = parts(q.volume !== null && `Volume ${f.big(q.volume)}`, q.averageVolume !== null && `3-month average ${f.big(q.averageVolume)}`)
252 const earnings = parts(q.pe !== null && `P/E ${f.fixed(q.pe)}`, q.forwardPe !== null && `forward P/E ${f.fixed(q.forwardPe)}`, q.eps !== null && `EPS ${f.fixed(q.eps)}`)
253 return [
254 `${head}: ${parts(f.price(q.price, hint), move(q), state(q), extended)}.`,
255 sentence(day),
256 sentence(volume),
257 sentence(q.marketCap !== null && `Market cap ${f.big(q.marketCap)}`),
258 sentence(earnings),
259 sentence(q.dividendYield !== null && `Dividend yield ${f.percent(q.dividendYield)}`),
260 sentence(range(q.fiftyTwoWeekLow, q.fiftyTwoWeekHigh, hint) && `52-week range ${range(q.fiftyTwoWeekLow, q.fiftyTwoWeekHigh, hint)}`),
261 sentence(q.rating && `Analysts: ${q.rating}`),
262 sentence(when(q.time) && `As of ${when(q.time)}`),
263 ]
264 .filter((s): s is string => s !== null)
265 .join(' ')
266}
267
268/** A listing in a list: price, move, volume, and size. */
269export function shortLine(q: Quote): string {
270 const rest = parts(q.volume !== null && `volume ${f.big(q.volume)}`, q.marketCap !== null && `market cap ${f.big(q.marketCap)}`, q.exchange)
271 return `${q.symbol}${q.name ? ` ${q.name}` : ''}: ${f.price(q.price, q.priceHint)} ${f.signedPercent(q.changePercent)}${rest ? `, ${rest}` : ''}`
272}
273
274export function quotesResult(quotes: Quote[], asked: string[]): ToolResult {
275 const found = new Set(quotes.map((q) => q.symbol.toUpperCase()))
276 const missing = asked.filter((symbol) => !found.has(symbol.toUpperCase()))
277 const lines = quotes.map(quoteLine)
278 const tail = missing.length ? [`No quote for ${missing.join(', ')}. Check the symbol with the search tool.`] : []
279 return toolResult(
280 capped(['Quotes (some exchanges are delayed):'], [...lines, ...tail], (n) => `(${n} more lines left out.)`),
281 { missing, rows: quotes },
282 )
283}
284
285export function searchResult(query: string, matches: Match[]): ToolResult {
286 if (!matches.length) return toolResult(`No symbols match "${query}".`, { query, rows: [] })
287 const lines = matches.map((m) => {
288 const field = m.sector && m.industry ? `${m.sector} / ${m.industry}` : (m.sector ?? m.industry)
289 const about = parts(m.typeLabel ?? m.type, m.exchange, field)
290 return `${m.symbol}: ${m.name ?? m.symbol}${about ? ` (${about})` : ''}`
291 })
292 return toolResult([`Symbols matching "${query}":`, ...lines].join('\n'), { query, rows: matches })
293}
294
295export function newsResult(query: string, stories: Story[]): ToolResult {
296 if (!stories.length) return toolResult(`No news for "${query}".`, { query, rows: [] })
297 const lines = stories.map((s) => {
298 const head = parts(when(s.published), s.publisher)
299 return `${head ? `${head}: ` : ''}${s.title} ${s.url}${s.tickers.length ? ` (${s.tickers.join(', ')})` : ''}`
300 })
301 return toolResult([`News for "${query}", newest first:`, ...lines].join('\n'), { query, rows: stories })
302}
303
304export function marketSummaryResult(region: string, rows: Quote[]): ToolResult {
305 const lines = rows.map((q) => `${q.name ?? q.symbol} (${parts(q.symbol, q.type)}): ${parts(f.price(q.price, q.priceHint), move(q), state(q))}`)
306 return toolResult([`Markets (region ${region}):`, ...lines].join('\n'), { region, rows })
307}
308
309export function moversResult(list: MoverList, screen: Screen): ToolResult {
310 const count = screen.total !== null ? `${screen.rows.length} of ${f.fixed(screen.total, 0)}` : `${screen.rows.length}`
311 const head = `${screen.title}: ${screen.description ? `${screen.description} ` : ''}${count}.`
312 return toolResult(capped([head], screen.rows.map(shortLine), (n) => `(${n} more left out.)`), {
313 list,
314 title: screen.title,
315 description: screen.description,
316 total: screen.total,
317 rows: screen.rows,
318 })
319}