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 { list, num, record, records, text } from '../values.ts' |
| 3 | import { capped, toolResult, type ToolResult } from './result.ts' |
| 4 | import { YahooError } from './yahoo.ts' |
| 5 | |
| 6 | // Price history from v8 chart: one bar per interval on the market's own clock (a daily bar is its |
| 7 | // day, an intraday bar its local time), with the dividends and splits Yahoo lists beside them. |
| 8 | |
| 9 | export const RANGES = ['1d', '5d', '1mo', '3mo', '6mo', 'ytd', '1y', '2y', '5y', '10y', 'max'] as const |
| 10 | export const INTERVALS = ['1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1wk', '1mo', '3mo'] as const |
| 11 | |
| 12 | /** Bars the text lists at most; the rows keep every one. */ |
| 13 | export const TEXT_BARS = 400 |
| 14 | |
| 15 | export type Bar = { |
| 16 | /** YYYY-MM-DD, or YYYY-MM-DD HH:MM for an intraday bar, where the market is. */ |
| 17 | date: string |
| 18 | open: number | null |
| 19 | high: number | null |
| 20 | low: number | null |
| 21 | close: number |
| 22 | /** Adjusted for splits and dividends; null for intraday bars. */ |
| 23 | adjClose: number | null |
| 24 | volume: number | null |
| 25 | } |
| 26 | |
| 27 | export type History = { |
| 28 | symbol: string |
| 29 | name: string | null |
| 30 | currency: string | null |
| 31 | exchange: string | null |
| 32 | timezone: string | null |
| 33 | interval: string |
| 34 | /** The range asked for, or null when dates were. */ |
| 35 | range: string | null |
| 36 | intraday: boolean |
| 37 | price: number | null |
| 38 | priceHint: number | null |
| 39 | rows: Bar[] |
| 40 | dividends: { date: string; amount: number }[] |
| 41 | splits: { date: string; ratio: string }[] |
| 42 | } |
| 43 | |
| 44 | const NAMES: Record<string, string> = { |
| 45 | '1d': 'daily', |
| 46 | '5d': '5-day', |
| 47 | '1wk': 'weekly', |
| 48 | '1mo': 'monthly', |
| 49 | '3mo': 'quarterly', |
| 50 | '1h': 'hourly', |
| 51 | '60m': 'hourly', |
| 52 | } |
| 53 | |
| 54 | /** Minutes or hours, as opposed to days and longer (1mo is a month). */ |
| 55 | export function isIntraday(interval: string): boolean { |
| 56 | return /^\d+[mh]$/.test(interval) |
| 57 | } |
| 58 | |
| 59 | function rounder(hint: number | null): (value: number | null) => number | null { |
| 60 | const scale = 10 ** (Math.max(hint ?? 2, 2) + 2) |
| 61 | return (value) => (value === null ? null : Math.round(value * scale) / scale) |
| 62 | } |
| 63 | |
| 64 | export function readHistory(json: unknown): History { |
| 65 | const chart = records(record(record(json)?.['chart'])?.['result'])[0] |
| 66 | if (!chart) throw new YahooError('The market data service answered with no price history.') |
| 67 | const meta = record(chart['meta']) ?? {} |
| 68 | const offset = num(meta['gmtoffset']) ?? 0 |
| 69 | const interval = text(meta['dataGranularity']) ?? '1d' |
| 70 | const intraday = isIntraday(interval) |
| 71 | const priceHint = num(meta['priceHint']) |
| 72 | const round = rounder(priceHint) |
| 73 | const indicators = record(chart['indicators']) |
| 74 | const quote = records(indicators?.['quote'])[0] ?? {} |
| 75 | const adjusted = list(records(indicators?.['adjclose'])[0]?.['adjclose']) |
| 76 | const series = (key: string): unknown[] => list(quote[key]) |
| 77 | const [opens, highs, lows, closes, volumes] = ['open', 'high', 'low', 'close', 'volume'].map(series) as unknown[][] |
| 78 | const day = (seconds: number): string => (intraday ? f.localTime(seconds, offset) : f.localDay(seconds, offset)) |
| 79 | const rows: Bar[] = [] |
| 80 | list(chart['timestamp']).forEach((stamp, i) => { |
| 81 | const seconds = num(stamp) |
| 82 | const close = round(num(closes![i])) |
| 83 | if (seconds === null || close === null) return |
| 84 | rows.push({ |
| 85 | date: day(seconds), |
| 86 | open: round(num(opens![i])), |
| 87 | high: round(num(highs![i])), |
| 88 | low: round(num(lows![i])), |
| 89 | close, |
| 90 | adjClose: round(num(adjusted[i])), |
| 91 | volume: num(volumes![i]), |
| 92 | }) |
| 93 | }) |
| 94 | const events = record(chart['events']) |
| 95 | const dividends = Object.values(record(events?.['dividends']) ?? {}) |
| 96 | .map((d) => ({ at: num(record(d)?.['date']), amount: num(record(d)?.['amount']) })) |
| 97 | .filter((d): d is { at: number; amount: number } => d.at !== null && d.amount !== null) |
| 98 | .sort((a, b) => a.at - b.at) |
| 99 | .map((d) => ({ date: f.localDay(d.at, offset), amount: d.amount })) |
| 100 | const splits = Object.values(record(events?.['splits']) ?? {}) |
| 101 | .map((s) => { |
| 102 | const split = record(s) |
| 103 | const ratio = text(split?.['splitRatio']) ?? (num(split?.['numerator']) !== null ? `${num(split?.['numerator'])}:${num(split?.['denominator'])}` : null) |
| 104 | return { at: num(split?.['date']), ratio } |
| 105 | }) |
| 106 | .filter((s): s is { at: number; ratio: string } => s.at !== null && s.ratio !== null) |
| 107 | .sort((a, b) => a.at - b.at) |
| 108 | .map((s) => ({ date: f.localDay(s.at, offset), ratio: s.ratio })) |
| 109 | return { |
| 110 | symbol: text(meta['symbol']) ?? '', |
| 111 | name: text(meta['longName']) ?? text(meta['shortName']), |
| 112 | currency: text(meta['currency']), |
| 113 | exchange: text(meta['fullExchangeName']) ?? text(meta['exchangeName']), |
| 114 | timezone: text(meta['exchangeTimezoneName']) ?? text(meta['timezone']), |
| 115 | interval, |
| 116 | range: text(meta['range']), |
| 117 | intraday, |
| 118 | price: num(meta['regularMarketPrice']), |
| 119 | priceHint, |
| 120 | rows, |
| 121 | dividends, |
| 122 | splits, |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | function extreme(rows: Bar[], key: 'high' | 'low'): Bar | null { |
| 127 | let best: Bar | null = null |
| 128 | for (const bar of rows) { |
| 129 | const value = bar[key] |
| 130 | if (value === null) continue |
| 131 | if (!best || (key === 'high' ? value > best.high! : value < best.low!)) best = bar |
| 132 | } |
| 133 | return best |
| 134 | } |
| 135 | |
| 136 | export function historyResult(history: History, maxBars = TEXT_BARS): ToolResult { |
| 137 | const { rows, symbol, interval } = history |
| 138 | const structured = { ...history } |
| 139 | if (!rows.length) { |
| 140 | const over = history.range ? `over ${history.range}` : 'between the dates asked' |
| 141 | return toolResult(`No ${interval} bars for ${symbol} ${over}.`, structured) |
| 142 | } |
| 143 | const hint = history.priceHint |
| 144 | const first = rows[0]! |
| 145 | const last = rows.at(-1)! |
| 146 | const where = [history.exchange, history.timezone].filter(Boolean).join(', ') |
| 147 | const head = `${symbol}${history.name ? ` ${history.name}` : ''} price history: ${rows.length} ${NAMES[interval] ?? `${interval}`} bar${ |
| 148 | rows.length === 1 ? '' : 's' |
| 149 | } from ${first.date} to ${last.date}${history.currency ? `, in ${history.currency}` : ''}${where ? ` (${where})` : ''}.` |
| 150 | const high = extreme(rows, 'high') |
| 151 | const low = extreme(rows, 'low') |
| 152 | const change = rows.length > 1 && first.close !== 0 ? ` (${f.signedPercent(((last.close - first.close) / first.close) * 100)})` : '' |
| 153 | const summary = [ |
| 154 | `Close ${f.price(first.close, hint)} to ${f.price(last.close, hint)}${change}.`, |
| 155 | high && low ? `High ${f.price(high.high, hint)} on ${high.date}, low ${f.price(low.low, hint)} on ${low.date}.` : '', |
| 156 | ] |
| 157 | .filter(Boolean) |
| 158 | .join(' ') |
| 159 | const shown = rows.slice(-maxBars) |
| 160 | const cell = (value: number | null): string => (value === null ? '' : f.price(value, hint).replaceAll(',', '')) |
| 161 | const lines = shown.map((bar) => [bar.date, cell(bar.open), cell(bar.high), cell(bar.low), cell(bar.close), cell(bar.adjClose), bar.volume ?? ''].join(',')) |
| 162 | const tail: string[] = [] |
| 163 | if (shown.length < rows.length) { |
| 164 | tail.push(`Only the latest ${shown.length} of ${rows.length} bars are listed; ask for a shorter range or a longer interval to see the rest.`) |
| 165 | } |
| 166 | if (history.dividends.length) tail.push(`Dividends per share: ${history.dividends.map((d) => `${d.date} ${d.amount}`).join(', ')}.`) |
| 167 | if (history.splits.length) tail.push(`Splits: ${history.splits.map((s) => `${s.date} ${s.ratio}`).join(', ')}.`) |
| 168 | const text = capped([head, summary, 'date,open,high,low,close,adj close,volume'], [...lines, ...tail], (n) => `(${n} more lines left out.)`) |
| 169 | return toolResult(text, structured) |
| 170 | } |