import type { BackendContext } from '@jaspers-ai/sdk' import { computeSummary, toOcc, type ChainMeta, type ChainRow } from './math.ts' import { filterStrikesNearSpot } from './vendors.ts' function parseNum(s: string | undefined): number | null { if (!s) return null const clean = s.replace(/[,%$]/g, '').trim() if (!clean || clean === '—' || clean === '-') return null const n = Number(clean) return Number.isFinite(n) ? n : null } function parseSide(cell: string) { const trimmed = cell.trim() if (!trimmed || trimmed === '—' || trimmed === '-') { return { last: null, bid: null, ask: null, vol: null, oi: null, iv: null, itm: false } } const itm = /\bITM\b/.test(trimmed) const clean = trimmed.replace(/\bITM\b/g, '').trim() const tokens = clean.split(/,\s+/) return { last: parseNum(tokens[0]), bid: parseNum(tokens[1]), ask: parseNum(tokens[2]), vol: parseNum(tokens[3]), oi: parseNum(tokens[4]), iv: parseNum(tokens[5]), itm, } } export async function fetchYahooChain( ctx: BackendContext, symbol: string, expiration: string | undefined, strikes: number, ): Promise<{ rows: ChainRow[]; meta: ChainMeta }> { const sym = symbol.toUpperCase().trim() // yfinance/server/get_options_chain takes `strikes` on each side of spot (up to 50 per side = 100 total). const eachSide = strikes <= 0 ? 50 : Math.min(Math.max(Math.ceil(strikes / 2) + 2, 5), 50) const optArgs: Record = { symbol: sym, strikes: eachSide } if (expiration) optArgs.expiration = expiration const [optRes, quoteRes] = await Promise.all([ ctx.tools.call('yfinance/server/get_options_chain', optArgs), ctx.tools.call('yfinance/server/get_quote', { symbols: [sym] }), ]) let spot: number | null = null let change: number | null = null let changePct: number | null = null let dayRange: string | null = null let week52Range: string | null = null let volume: number | null = null if (!quoteRes.isError && quoteRes.text) { const qText = quoteRes.text const mPrice = qText.match(/:\s*([0-9.,]+)(?:,\s*([+-]?[0-9.,]+)\s*\(([+-]?[0-9.,]+)%\))?/) if (mPrice) { spot = parseNum(mPrice[1]) change = parseNum(mPrice[2]) changePct = parseNum(mPrice[3]) } const mDay = qText.match(/Day\s+([0-9.,]+-[0-9.,]+)/) if (mDay) dayRange = mDay[1] ?? null const m52 = qText.match(/52-week range\s+([0-9.,]+-[0-9.,]+)/) if (m52) week52Range = m52[1] ?? null const mVol = qText.match(/Volume\s+([0-9.,]+)([KMB])?/) if (mVol) { const base = parseNum(mVol[1]) ?? 0 const mult = mVol[2] === 'B' ? 1e9 : mVol[2] === 'M' ? 1e6 : mVol[2] === 'K' ? 1e3 : 1 volume = Math.round(base * mult) } } if (optRes.isError && expiration) { // Fallback to nearest expiration if requested expiration was not in Yahoo's list const retry = await ctx.tools.call('yfinance/server/get_options_chain', { symbol: sym, strikes: eachSide }) if (!retry.isError) return parseYahooText(sym, retry.text, spot, change, changePct, dayRange, week52Range, volume, strikes) } if (optRes.isError) { throw new Error(optRes.text || `Yahoo options lookup failed for ${sym}`) } return parseYahooText(sym, optRes.text, spot, change, changePct, dayRange, week52Range, volume, strikes) } function parseYahooText( sym: string, text: string, spotIn: number | null, changeIn: number | null, changePctIn: number | null, dayRange: string | null, week52Range: string | null, volume: number | null, maxStrikes: number, ): { rows: ChainRow[]; meta: ChainMeta } { const lines = text.split('\n').map((l) => l.trim()).filter(Boolean) let selectedExpiration: string | null = null let spot = spotIn let change = changeIn let changePct = changePctIn let expirations: string[] = [] const headerLine = lines[0] ?? '' const mExp = headerLine.match(/expiring\s+(\d{4}-\d{2}-\d{2})/) if (mExp) selectedExpiration = mExp[1] ?? null const mUnd = headerLine.match(/Underlying\s+([0-9.,]+)(?:,\s*([+-]?[0-9.,]+)\s*\(([+-]?[0-9.,]+)%\))?/) if (mUnd) { if (spot == null) spot = parseNum(mUnd[1]) if (change == null) change = parseNum(mUnd[2]) if (changePct == null) changePct = parseNum(mUnd[3]) } for (const line of lines) { if (line.startsWith('Expirations')) { const idx = line.indexOf(':') if (idx !== -1) { expirations = line .slice(idx + 1) .split(',') .map((s) => s.trim()) .filter((s) => /^\d{4}-\d{2}-\d{2}$/.test(s)) } } } const exp = selectedExpiration ?? expirations[0] ?? null const expStr = exp ?? '' const rows: ChainRow[] = [] for (const line of lines) { if (!line.includes('|') || line.startsWith('strike |')) continue const parts = line.split('|').map((p) => p.trim()) if (parts.length < 3) continue const strike = parseNum(parts[0]) if (strike == null) continue const c = parseSide(parts[1] ?? '') const p = parseSide(parts[2] ?? '') const callContract = toOcc(sym, expStr, 'C', strike) const putContract = toOcc(sym, expStr, 'P', strike) rows.push({ contract: `${callContract} / ${putContract}`, callContract, putContract, expiration: expStr, strike, callBid: c.bid, callAsk: c.ask, callLast: c.last, callChg: null, callVol: c.vol, callOi: c.oi, callIv: c.iv, callDelta: null, callItm: c.itm || (spot != null ? strike < spot : false), putBid: p.bid, putAsk: p.ask, putLast: p.last, putChg: null, putVol: p.vol, putOi: p.oi, putIv: p.iv, putDelta: null, putItm: p.itm || (spot != null ? strike > spot : false), }) } const filtered = filterStrikesNearSpot(rows, spot, maxStrikes) return computeSummary(filtered, { symbol: sym, provider: 'yahoo', feedLabel: 'Yahoo Finance (Delayed)', spot, change, changePct, dayRange, week52Range, volume, expirations, selectedExpiration: exp, }) }