yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.
| 1 | import { plain as f } from '../format.ts' |
| 2 | import { num, record, records, text } from '../values.ts' |
| 3 | import { capped, parts, toolResult, type ToolResult } from './result.ts' |
| 4 | import { 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 | |
| 10 | export 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 | |
| 49 | export 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 | |
| 59 | export type Story = { |
| 60 | title: string |
| 61 | publisher: string | null |
| 62 | /** ISO. */ |
| 63 | published: string | null |
| 64 | url: string |
| 65 | tickers: string[] |
| 66 | } |
| 67 | |
| 68 | export 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). */ |
| 76 | export 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 | |
| 88 | export type MoverList = keyof typeof MOVER_LISTS |
| 89 | |
| 90 | export const TRENDING_TITLE = 'Trending tickers' |
| 91 | export const TRENDING_DESCRIPTION = 'The tickers people are looking up most right now.' |
| 92 | |
| 93 | const 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 | |
| 102 | function 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. */ |
| 107 | export 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 | |
| 147 | function present<T>(items: (T | null)[]): T[] { |
| 148 | return items.filter((item): item is T => item !== null) |
| 149 | } |
| 150 | |
| 151 | export function readQuotes(json: unknown): Quote[] { |
| 152 | return present(records(record(record(json)?.['quoteResponse'])?.['result']).map((q) => readQuote(q))) |
| 153 | } |
| 154 | |
| 155 | export function readMarketSummary(json: unknown): Quote[] { |
| 156 | return present(records(record(record(json)?.['marketSummaryResponse'])?.['result']).map((q) => readQuote(q, 'short'))) |
| 157 | } |
| 158 | |
| 159 | export 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 | |
| 170 | export 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 | |
| 175 | export 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 | |
| 193 | export 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 | |
| 213 | function when(isoTime: string | null): string | null { |
| 214 | return isoTime ? `${isoTime.slice(0, 16).replace('T', ' ')} UTC` : null |
| 215 | } |
| 216 | |
| 217 | function 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 | |
| 221 | function 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 | |
| 227 | function 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. */ |
| 232 | function sentence(body: string | null | undefined | false): string | null { |
| 233 | return body ? `${body.charAt(0).toUpperCase()}${body.slice(1)}.` : null |
| 234 | } |
| 235 | |
| 236 | export 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. */ |
| 269 | export 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 | |
| 274 | export 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 | |
| 285 | export 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 | |
| 295 | export 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 | |
| 304 | export 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 | |
| 309 | export 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 | } |