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.

7.1 KB
1import { plain as f } from '../format.ts'
2import { list, num, record, records, text } from '../values.ts'
3import { capped, toolResult, type ToolResult } from './result.ts'
4import { 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
9export const RANGES = ['1d', '5d', '1mo', '3mo', '6mo', 'ytd', '1y', '2y', '5y', '10y', 'max'] as const
10export 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. */
13export const TEXT_BARS = 400
14
15export 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
27export 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
44const 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). */
55export function isIntraday(interval: string): boolean {
56 return /^\d+[mh]$/.test(interval)
57}
58
59function 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
64export 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
126function 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
136export 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}