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.

8.5 KB
1import { plain as f } from '../format.ts'
2import { list, num, percentOf, record, records, text, type Row } from '../values.ts'
3import { toolResult, type ToolResult } from './result.ts'
4import { 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
10export 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
22export type OptionRow = {
23 strike: number
24 call: Contract | null
25 put: Contract | null
26}
27
28export 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
53export const SIDES = ['both', 'calls', 'puts'] as const
54export type Side = (typeof SIDES)[number]
55
56/** Yahoo's name for an expiration day. */
57export 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. */
63export 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
68function round(value: number | null, places = 4): number | null {
69 return value === null ? null : Math.round(value * 10 ** places) / 10 ** places
70}
71
72function 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
86function 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
92function sum(contracts: Contract[], key: 'volume' | 'openInterest'): number {
93 return contracts.reduce((total, c) => total + (c[key] ?? 0), 0)
94}
95
96export 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. */
103export 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
110export 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
179function 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
192export 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}