Jaspers Hub

biseroff / option-chain

pluginReviewed★ 0↓ 0

Options Chain: Shows a multi-vendor options chain straddle table with IV, delta, OI, volume, and max pain for a ticker.

5.9 KB
1import type { BackendContext } from '@jaspers-ai/sdk'
2import { computeSummary, toOcc, type ChainMeta, type ChainRow } from './math.ts'
3import { filterStrikesNearSpot } from './vendors.ts'
4
5function 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
13function 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
32export 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
89function 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}