Options Chain: Shows a multi-vendor options chain straddle table with IV, delta, OI, volume, and max pain for a ticker.
| 1 | import type { BackendContext } from '@jaspers-ai/sdk' |
| 2 | import { computeSummary, toOcc, type ChainMeta, type ChainRow } from './math.ts' |
| 3 | import { filterStrikesNearSpot } from './vendors.ts' |
| 4 | |
| 5 | function parseNum(s: string | undefined): number | null { |
| 6 | if (!s) return null |
| 7 | const clean = s.replace(/[,%$]/g, '').trim() |
| 8 | if (!clean || clean === '—' || clean === '-') return null |
| 9 | const n = Number(clean) |
| 10 | return Number.isFinite(n) ? n : null |
| 11 | } |
| 12 | |
| 13 | function parseSide(cell: string) { |
| 14 | const trimmed = cell.trim() |
| 15 | if (!trimmed || trimmed === '—' || trimmed === '-') { |
| 16 | return { last: null, bid: null, ask: null, vol: null, oi: null, iv: null, itm: false } |
| 17 | } |
| 18 | const itm = /\bITM\b/.test(trimmed) |
| 19 | const clean = trimmed.replace(/\bITM\b/g, '').trim() |
| 20 | const tokens = clean.split(/,\s+/) |
| 21 | return { |
| 22 | last: parseNum(tokens[0]), |
| 23 | bid: parseNum(tokens[1]), |
| 24 | ask: parseNum(tokens[2]), |
| 25 | vol: parseNum(tokens[3]), |
| 26 | oi: parseNum(tokens[4]), |
| 27 | iv: parseNum(tokens[5]), |
| 28 | itm, |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | export async function fetchYahooChain( |
| 33 | ctx: BackendContext, |
| 34 | symbol: string, |
| 35 | expiration: string | undefined, |
| 36 | strikes: number, |
| 37 | ): Promise<{ rows: ChainRow[]; meta: ChainMeta }> { |
| 38 | const sym = symbol.toUpperCase().trim() |
| 39 | // yfinance/server/get_options_chain takes `strikes` on each side of spot (up to 50 per side = 100 total). |
| 40 | const eachSide = strikes <= 0 ? 50 : Math.min(Math.max(Math.ceil(strikes / 2) + 2, 5), 50) |
| 41 | const optArgs: Record<string, unknown> = { symbol: sym, strikes: eachSide } |
| 42 | if (expiration) optArgs.expiration = expiration |
| 43 | |
| 44 | const [optRes, quoteRes] = await Promise.all([ |
| 45 | ctx.tools.call('yfinance/server/get_options_chain', optArgs), |
| 46 | ctx.tools.call('yfinance/server/get_quote', { symbols: [sym] }), |
| 47 | ]) |
| 48 | |
| 49 | let spot: number | null = null |
| 50 | let change: number | null = null |
| 51 | let changePct: number | null = null |
| 52 | let dayRange: string | null = null |
| 53 | let week52Range: string | null = null |
| 54 | let volume: number | null = null |
| 55 | |
| 56 | if (!quoteRes.isError && quoteRes.text) { |
| 57 | const qText = quoteRes.text |
| 58 | const mPrice = qText.match(/:\s*([0-9.,]+)(?:,\s*([+-]?[0-9.,]+)\s*\(([+-]?[0-9.,]+)%\))?/) |
| 59 | if (mPrice) { |
| 60 | spot = parseNum(mPrice[1]) |
| 61 | change = parseNum(mPrice[2]) |
| 62 | changePct = parseNum(mPrice[3]) |
| 63 | } |
| 64 | const mDay = qText.match(/Day\s+([0-9.,]+-[0-9.,]+)/) |
| 65 | if (mDay) dayRange = mDay[1] ?? null |
| 66 | const m52 = qText.match(/52-week range\s+([0-9.,]+-[0-9.,]+)/) |
| 67 | if (m52) week52Range = m52[1] ?? null |
| 68 | const mVol = qText.match(/Volume\s+([0-9.,]+)([KMB])?/) |
| 69 | if (mVol) { |
| 70 | const base = parseNum(mVol[1]) ?? 0 |
| 71 | const mult = mVol[2] === 'B' ? 1e9 : mVol[2] === 'M' ? 1e6 : mVol[2] === 'K' ? 1e3 : 1 |
| 72 | volume = Math.round(base * mult) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | if (optRes.isError && expiration) { |
| 77 | // Fallback to nearest expiration if requested expiration was not in Yahoo's list |
| 78 | const retry = await ctx.tools.call('yfinance/server/get_options_chain', { symbol: sym, strikes: eachSide }) |
| 79 | if (!retry.isError) return parseYahooText(sym, retry.text, spot, change, changePct, dayRange, week52Range, volume, strikes) |
| 80 | } |
| 81 | |
| 82 | if (optRes.isError) { |
| 83 | throw new Error(optRes.text || `Yahoo options lookup failed for ${sym}`) |
| 84 | } |
| 85 | |
| 86 | return parseYahooText(sym, optRes.text, spot, change, changePct, dayRange, week52Range, volume, strikes) |
| 87 | } |
| 88 | |
| 89 | function parseYahooText( |
| 90 | sym: string, |
| 91 | text: string, |
| 92 | spotIn: number | null, |
| 93 | changeIn: number | null, |
| 94 | changePctIn: number | null, |
| 95 | dayRange: string | null, |
| 96 | week52Range: string | null, |
| 97 | volume: number | null, |
| 98 | maxStrikes: number, |
| 99 | ): { rows: ChainRow[]; meta: ChainMeta } { |
| 100 | const lines = text.split('\n').map((l) => l.trim()).filter(Boolean) |
| 101 | let selectedExpiration: string | null = null |
| 102 | let spot = spotIn |
| 103 | let change = changeIn |
| 104 | let changePct = changePctIn |
| 105 | let expirations: string[] = [] |
| 106 | |
| 107 | const headerLine = lines[0] ?? '' |
| 108 | const mExp = headerLine.match(/expiring\s+(\d{4}-\d{2}-\d{2})/) |
| 109 | if (mExp) selectedExpiration = mExp[1] ?? null |
| 110 | const mUnd = headerLine.match(/Underlying\s+([0-9.,]+)(?:,\s*([+-]?[0-9.,]+)\s*\(([+-]?[0-9.,]+)%\))?/) |
| 111 | if (mUnd) { |
| 112 | if (spot == null) spot = parseNum(mUnd[1]) |
| 113 | if (change == null) change = parseNum(mUnd[2]) |
| 114 | if (changePct == null) changePct = parseNum(mUnd[3]) |
| 115 | } |
| 116 | |
| 117 | for (const line of lines) { |
| 118 | if (line.startsWith('Expirations')) { |
| 119 | const idx = line.indexOf(':') |
| 120 | if (idx !== -1) { |
| 121 | expirations = line |
| 122 | .slice(idx + 1) |
| 123 | .split(',') |
| 124 | .map((s) => s.trim()) |
| 125 | .filter((s) => /^\d{4}-\d{2}-\d{2}$/.test(s)) |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | const exp = selectedExpiration ?? expirations[0] ?? null |
| 131 | const expStr = exp ?? '' |
| 132 | const rows: ChainRow[] = [] |
| 133 | |
| 134 | for (const line of lines) { |
| 135 | if (!line.includes('|') || line.startsWith('strike |')) continue |
| 136 | const parts = line.split('|').map((p) => p.trim()) |
| 137 | if (parts.length < 3) continue |
| 138 | const strike = parseNum(parts[0]) |
| 139 | if (strike == null) continue |
| 140 | const c = parseSide(parts[1] ?? '') |
| 141 | const p = parseSide(parts[2] ?? '') |
| 142 | const callContract = toOcc(sym, expStr, 'C', strike) |
| 143 | const putContract = toOcc(sym, expStr, 'P', strike) |
| 144 | rows.push({ |
| 145 | contract: `${callContract} / ${putContract}`, |
| 146 | callContract, |
| 147 | putContract, |
| 148 | expiration: expStr, |
| 149 | strike, |
| 150 | callBid: c.bid, |
| 151 | callAsk: c.ask, |
| 152 | callLast: c.last, |
| 153 | callChg: null, |
| 154 | callVol: c.vol, |
| 155 | callOi: c.oi, |
| 156 | callIv: c.iv, |
| 157 | callDelta: null, |
| 158 | callItm: c.itm || (spot != null ? strike < spot : false), |
| 159 | putBid: p.bid, |
| 160 | putAsk: p.ask, |
| 161 | putLast: p.last, |
| 162 | putChg: null, |
| 163 | putVol: p.vol, |
| 164 | putOi: p.oi, |
| 165 | putIv: p.iv, |
| 166 | putDelta: null, |
| 167 | putItm: p.itm || (spot != null ? strike > spot : false), |
| 168 | }) |
| 169 | } |
| 170 | |
| 171 | const filtered = filterStrikesNearSpot(rows, spot, maxStrikes) |
| 172 | return computeSummary(filtered, { |
| 173 | symbol: sym, |
| 174 | provider: 'yahoo', |
| 175 | feedLabel: 'Yahoo Finance (Delayed)', |
| 176 | spot, |
| 177 | change, |
| 178 | changePct, |
| 179 | dayRange, |
| 180 | week52Range, |
| 181 | volume, |
| 182 | expirations, |
| 183 | selectedExpiration: exp, |
| 184 | }) |
| 185 | } |