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, percentOf, record, records, text, type Row } from '../values.ts' |
| 3 | import { toolResult, type ToolResult } from './result.ts' |
| 4 | import { YahooError } from './yahoo.ts' |
| 5 | |
| 6 | // An option chain from v7 options: the expirations, and for one of them the calls and puts side by |
| 7 | // side by strike, cut to the strikes nearest the underlying's price. Yahoo names an expiration by its |
| 8 | // day at 00:00 UTC in seconds. Implied volatility is in percent here (30.91 is 30.91%). |
| 9 | |
| 10 | export type Contract = { |
| 11 | contract: string | null |
| 12 | last: number | null |
| 13 | change: number | null |
| 14 | bid: number | null |
| 15 | ask: number | null |
| 16 | volume: number | null |
| 17 | openInterest: number | null |
| 18 | ivPercent: number | null |
| 19 | inTheMoney: boolean |
| 20 | } |
| 21 | |
| 22 | export type OptionRow = { |
| 23 | strike: number |
| 24 | call: Contract | null |
| 25 | put: Contract | null |
| 26 | } |
| 27 | |
| 28 | export type Options = { |
| 29 | symbol: string |
| 30 | currency: string | null |
| 31 | /** The decimals prices show with. */ |
| 32 | priceHint: number |
| 33 | underlying: { price: number | null; change: number | null; changePercent: number | null } |
| 34 | /** YYYY-MM-DD. */ |
| 35 | expiration: string |
| 36 | expirations: string[] |
| 37 | totals: { |
| 38 | calls: number |
| 39 | puts: number |
| 40 | callVolume: number |
| 41 | putVolume: number |
| 42 | callOpenInterest: number |
| 43 | putOpenInterest: number |
| 44 | putCallVolumeRatio: number | null |
| 45 | putCallOpenInterestRatio: number | null |
| 46 | } |
| 47 | /** The strike nearest the price, with each side's mid (bid and ask, else last) and IV. */ |
| 48 | atm: { strike: number; callMid: number | null; putMid: number | null; callIvPercent: number | null; putIvPercent: number | null } | null |
| 49 | /** Strikes nearest the price, ascending. */ |
| 50 | rows: OptionRow[] |
| 51 | } |
| 52 | |
| 53 | export const SIDES = ['both', 'calls', 'puts'] as const |
| 54 | export type Side = (typeof SIDES)[number] |
| 55 | |
| 56 | /** Yahoo's name for an expiration day. */ |
| 57 | export function expirationSeconds(day: string): number { |
| 58 | const [y, m, d] = day.split('-').map(Number) |
| 59 | return Date.UTC(y!, m! - 1, d!) / 1000 |
| 60 | } |
| 61 | |
| 62 | /** The expirations closest to a day, nearest first. */ |
| 63 | export function nearestExpirations(expirations: string[], day: string, count = 5): string[] { |
| 64 | const at = expirationSeconds(day) |
| 65 | return [...expirations].sort((a, b) => Math.abs(expirationSeconds(a) - at) - Math.abs(expirationSeconds(b) - at)).slice(0, count) |
| 66 | } |
| 67 | |
| 68 | function round(value: number | null, places = 4): number | null { |
| 69 | return value === null ? null : Math.round(value * 10 ** places) / 10 ** places |
| 70 | } |
| 71 | |
| 72 | function readContract(raw: Row): Contract { |
| 73 | return { |
| 74 | contract: text(raw['contractSymbol']), |
| 75 | last: num(raw['lastPrice']), |
| 76 | change: round(num(raw['change'])), |
| 77 | bid: num(raw['bid']), |
| 78 | ask: num(raw['ask']), |
| 79 | volume: num(raw['volume']), |
| 80 | openInterest: num(raw['openInterest']), |
| 81 | ivPercent: round(percentOf(num(raw['impliedVolatility'])), 2), |
| 82 | inTheMoney: raw['inTheMoney'] === true, |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | function mid(contract: Contract | null): number | null { |
| 87 | if (!contract) return null |
| 88 | if (contract.bid !== null && contract.ask !== null && contract.bid > 0 && contract.ask > 0) return round((contract.bid + contract.ask) / 2) |
| 89 | return contract.last |
| 90 | } |
| 91 | |
| 92 | function sum(contracts: Contract[], key: 'volume' | 'openInterest'): number { |
| 93 | return contracts.reduce((total, c) => total + (c[key] ?? 0), 0) |
| 94 | } |
| 95 | |
| 96 | export function chainOf(json: unknown, symbol?: string): Row { |
| 97 | const chain = records(record(record(json)?.['optionChain'])?.['result'])[0] |
| 98 | if (!chain) throw new YahooError(`No option chain${symbol ? ` for ${symbol}` : ''}.`) |
| 99 | return chain |
| 100 | } |
| 101 | |
| 102 | /** The expirations a chain lists, as days. */ |
| 103 | export function readExpirations(json: unknown, symbol?: string): string[] { |
| 104 | return list(chainOf(json, symbol)['expirationDates']) |
| 105 | .map(num) |
| 106 | .filter((s): s is number => s !== null) |
| 107 | .map((s) => f.day(s)) |
| 108 | } |
| 109 | |
| 110 | export function readOptions(json: unknown, strikes: number, asked?: string): Options { |
| 111 | const chain = chainOf(json, asked) |
| 112 | const symbol = text(chain['underlyingSymbol']) ?? asked ?? '' |
| 113 | const quote = record(chain['quote']) ?? {} |
| 114 | const expirations = readExpirations(json, asked) |
| 115 | const set = records(chain['options'])[0] |
| 116 | if (!set) throw new YahooError(`No options are listed for ${symbol}.`) |
| 117 | const calls = records(set['calls']).map((c) => ({ strike: num(c['strike']), contract: readContract(c) })) |
| 118 | const puts = records(set['puts']).map((p) => ({ strike: num(p['strike']), contract: readContract(p) })) |
| 119 | const byStrike = new Map<number, OptionRow>() |
| 120 | for (const [side, entries] of [ |
| 121 | ['call', calls], |
| 122 | ['put', puts], |
| 123 | ] as const) { |
| 124 | for (const { strike, contract } of entries) { |
| 125 | if (strike === null) continue |
| 126 | const row = byStrike.get(strike) ?? { strike, call: null, put: null } |
| 127 | row[side] = contract |
| 128 | byStrike.set(strike, row) |
| 129 | } |
| 130 | } |
| 131 | const all = [...byStrike.values()].sort((a, b) => a.strike - b.strike) |
| 132 | const price = num(quote['regularMarketPrice']) |
| 133 | let rows = all |
| 134 | let atm: Options['atm'] = null |
| 135 | if (price !== null && all.length) { |
| 136 | const above = all.findIndex((row) => row.strike >= price) |
| 137 | const at = above === -1 ? all.length : above |
| 138 | rows = all.slice(Math.max(0, at - strikes), at + strikes) |
| 139 | const nearest = all.reduce((best, row) => (Math.abs(row.strike - price) < Math.abs(best.strike - price) ? row : best)) |
| 140 | atm = { |
| 141 | strike: nearest.strike, |
| 142 | callMid: mid(nearest.call), |
| 143 | putMid: mid(nearest.put), |
| 144 | callIvPercent: nearest.call?.ivPercent ?? null, |
| 145 | putIvPercent: nearest.put?.ivPercent ?? null, |
| 146 | } |
| 147 | } else { |
| 148 | rows = all.slice(0, strikes * 2) |
| 149 | } |
| 150 | const callContracts = calls.map((c) => c.contract) |
| 151 | const putContracts = puts.map((p) => p.contract) |
| 152 | const callVolume = sum(callContracts, 'volume') |
| 153 | const putVolume = sum(putContracts, 'volume') |
| 154 | const callOpenInterest = sum(callContracts, 'openInterest') |
| 155 | const putOpenInterest = sum(putContracts, 'openInterest') |
| 156 | const seconds = num(set['expirationDate']) |
| 157 | return { |
| 158 | symbol, |
| 159 | currency: text(quote['currency']), |
| 160 | priceHint: num(quote['priceHint']) ?? 2, |
| 161 | underlying: { price, change: num(quote['regularMarketChange']), changePercent: num(quote['regularMarketChangePercent']) }, |
| 162 | expiration: seconds === null ? (expirations[0] ?? '') : f.day(seconds), |
| 163 | expirations, |
| 164 | totals: { |
| 165 | calls: calls.length, |
| 166 | puts: puts.length, |
| 167 | callVolume, |
| 168 | putVolume, |
| 169 | callOpenInterest, |
| 170 | putOpenInterest, |
| 171 | putCallVolumeRatio: callVolume ? round(putVolume / callVolume) : null, |
| 172 | putCallOpenInterestRatio: callOpenInterest ? round(putOpenInterest / callOpenInterest) : null, |
| 173 | }, |
| 174 | atm, |
| 175 | rows, |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | function side(contract: Contract | null, hint: number): string { |
| 180 | if (!contract) return '—' |
| 181 | const cells = [ |
| 182 | f.price(contract.last, hint), |
| 183 | f.price(contract.bid, hint), |
| 184 | f.price(contract.ask, hint), |
| 185 | f.fixed(contract.volume, 0), |
| 186 | f.fixed(contract.openInterest, 0), |
| 187 | f.percent(contract.ivPercent), |
| 188 | ] |
| 189 | return `${cells.join(', ')}${contract.inTheMoney ? ' ITM' : ''}` |
| 190 | } |
| 191 | |
| 192 | export function optionsResult(options: Options, show: Side = 'both'): ToolResult { |
| 193 | const { symbol, underlying, totals, atm, priceHint: hint } = options |
| 194 | const move = underlying.change !== null ? `, ${f.signed(underlying.change, hint)} (${f.signedPercent(underlying.changePercent)})` : '' |
| 195 | const lines = [ |
| 196 | `${symbol} options expiring ${options.expiration}${options.currency ? `, in ${options.currency}` : ''}. Underlying ${f.price(underlying.price, hint)}${move}.`, |
| 197 | `Expirations (${options.expirations.length}): ${options.expirations.join(', ')}`, |
| 198 | `This expiration: ${totals.calls} calls and ${totals.puts} puts; call volume ${f.fixed(totals.callVolume, 0)} and open interest ${f.fixed( |
| 199 | totals.callOpenInterest, |
| 200 | 0, |
| 201 | )}; put volume ${f.fixed(totals.putVolume, 0)} and open interest ${f.fixed(totals.putOpenInterest, 0)}; put/call ${f.fixed( |
| 202 | totals.putCallVolumeRatio, |
| 203 | )} by volume, ${f.fixed(totals.putCallOpenInterestRatio)} by open interest.`, |
| 204 | ] |
| 205 | if (atm) { |
| 206 | lines.push( |
| 207 | `Nearest the price, ${f.price(atm.strike, hint)}: call mid ${f.price(atm.callMid, hint)}, IV ${f.percent(atm.callIvPercent)}; put mid ${f.price(atm.putMid, hint)}, IV ${f.percent(atm.putIvPercent)}.`, |
| 208 | ) |
| 209 | } |
| 210 | const columns = 'last, bid, ask, volume, open interest, IV' |
| 211 | const header = ['strike', show !== 'puts' && `call ${columns}`, show !== 'calls' && `put ${columns}`].filter(Boolean).join(' | ') |
| 212 | lines.push(`Strikes nearest the price (ITM: in the money):`, header) |
| 213 | for (const row of options.rows) { |
| 214 | const cells = [f.price(row.strike, hint), show !== 'puts' && side(row.call, hint), show !== 'calls' && side(row.put, hint)].filter(Boolean) |
| 215 | lines.push(cells.join(' | ')) |
| 216 | } |
| 217 | return toolResult(lines.join('\n'), options) |
| 218 | } |