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.

13 KB
1import { computeSummary, toOcc, type ChainMeta, type ChainRow } from './math.ts'
2import { filterStrikesNearSpot } from './vendors.ts'
3
4export function buildMassiveChain(
5 symbol: string,
6 contractsRows: Record<string, unknown>[],
7 chainRows: Record<string, unknown>[],
8 prevCloseRow: Record<string, unknown> | undefined,
9 requestedExp: string | null | undefined,
10 maxStrikes: number,
11 note: string | null,
12): { rows: ChainRow[]; meta: ChainMeta } {
13 const sym = symbol.toUpperCase()
14 let spot: number | null = typeof prevCloseRow?.close === 'number' ? prevCloseRow.close : null
15 for (const r of chainRows) {
16 const undPx =
17 typeof r.underlying_price === 'number'
18 ? r.underlying_price
19 : typeof r.underlyingPrice === 'number'
20 ? r.underlyingPrice
21 : null
22 if (undPx != null && undPx > 0) {
23 spot = undPx
24 break
25 }
26 }
27 const open = typeof prevCloseRow?.open === 'number' ? prevCloseRow.open : null
28 const change = spot != null && open != null ? Number((spot - open).toFixed(2)) : null
29 const changePct = change != null && open && open > 0 ? Number(((change / open) * 100).toFixed(2)) : null
30 const high = typeof prevCloseRow?.high === 'number' ? prevCloseRow.high : null
31 const low = typeof prevCloseRow?.low === 'number' ? prevCloseRow.low : null
32 const dayRange = low != null && high != null ? `${low.toFixed(2)}-${high.toFixed(2)}` : null
33 const volume = typeof prevCloseRow?.volume === 'number' ? prevCloseRow.volume : null
34
35 const expSet = new Set<string>()
36 for (const r of [...contractsRows, ...chainRows]) {
37 const e = typeof r.expiration === 'string' ? r.expiration : null
38 if (e) expSet.add(e)
39 }
40 const expirations = [...expSet].sort()
41 const selectedExpiration =
42 (requestedExp && expSet.has(requestedExp) ? requestedExp : expirations[0]) ?? requestedExp ?? null
43
44 const sourceList = chainRows.length > 0 ? chainRows : contractsRows
45 const byStrike = new Map<number, ChainRow>()
46 for (const r of sourceList) {
47 const exp = typeof r.expiration === 'string' ? r.expiration : null
48 if (selectedExpiration && exp !== selectedExpiration) continue
49 const strike = typeof r.strike === 'number' ? r.strike : Number(r.strike)
50 if (!Number.isFinite(strike)) continue
51 const type = r.type === 'put' ? 'put' : 'call'
52 const expStr = exp ?? selectedExpiration ?? ''
53 let row = byStrike.get(strike)
54 if (!row) {
55 const cc = toOcc(sym, expStr, 'C', strike)
56 const pc = toOcc(sym, expStr, 'P', strike)
57 row = {
58 contract: `${cc} / ${pc}`,
59 callContract: cc,
60 putContract: pc,
61 expiration: expStr,
62 strike,
63 callBid: null,
64 callAsk: null,
65 callLast: null,
66 callChg: null,
67 callVol: null,
68 callOi: null,
69 callIv: null,
70 callDelta: null,
71 callItm: spot != null ? strike < spot : false,
72 putBid: null,
73 putAsk: null,
74 putLast: null,
75 putChg: null,
76 putVol: null,
77 putOi: null,
78 putIv: null,
79 putDelta: null,
80 putItm: spot != null ? strike > spot : false,
81 }
82 byStrike.set(strike, row)
83 }
84 const rawIv =
85 typeof r.iv === 'number' ? (r.iv <= 5 ? Number((r.iv * 100).toFixed(2)) : Number(r.iv.toFixed(2))) : null
86 const contractStr = typeof r.contract === 'string' ? r.contract.replace(/^O:/, '') : null
87 const lastVal =
88 typeof r.last === 'number'
89 ? r.last
90 : typeof r.close === 'number'
91 ? r.close
92 : typeof r.mid === 'number'
93 ? r.mid
94 : typeof r.midpoint === 'number'
95 ? r.midpoint
96 : null
97 const oiVal =
98 typeof r.open_interest === 'number'
99 ? r.open_interest
100 : typeof r.openInterest === 'number'
101 ? r.openInterest
102 : null
103
104 if (type === 'call') {
105 if (contractStr) row.callContract = contractStr
106 row.callBid = typeof r.bid === 'number' ? r.bid : null
107 row.callAsk = typeof r.ask === 'number' ? r.ask : null
108 row.callLast = lastVal
109 row.callChg = typeof r.change === 'number' ? r.change : null
110 row.callVol = typeof r.volume === 'number' ? r.volume : null
111 row.callOi = oiVal
112 row.callIv = rawIv
113 row.callDelta = typeof r.delta === 'number' ? Number(r.delta.toFixed(3)) : null
114 } else {
115 if (contractStr) row.putContract = contractStr
116 row.putBid = typeof r.bid === 'number' ? r.bid : null
117 row.putAsk = typeof r.ask === 'number' ? r.ask : null
118 row.putLast = lastVal
119 row.putChg = typeof r.change === 'number' ? r.change : null
120 row.putVol = typeof r.volume === 'number' ? r.volume : null
121 row.putOi = oiVal
122 row.putIv = rawIv
123 row.putDelta = typeof r.delta === 'number' ? Number(r.delta.toFixed(3)) : null
124 }
125 row.contract = `${row.callContract} / ${row.putContract}`
126 }
127
128 const filtered = filterStrikesNearSpot([...byStrike.values()], spot, maxStrikes)
129 return computeSummary(filtered, {
130 symbol: sym,
131 provider: 'massive',
132 feedLabel: chainRows.length > 0 ? 'Massive (OPRA Snapshot)' : 'Massive (Reference + Prev Close)',
133 spot,
134 change,
135 changePct,
136 dayRange,
137 volume,
138 expirations,
139 selectedExpiration,
140 note,
141 })
142}
143
144export interface ConsolidatedDailyBar {
145 date: string
146 open: number
147 high: number
148 low: number
149 close: number
150 volume: number
151}
152
153export function consolidateDatabentoOhlcv(ohlcvRows: Record<string, unknown>[]): ConsolidatedDailyBar[] {
154 const byDate = new Map<string, { bar: ConsolidatedDailyBar; topVol: number }>()
155 for (const r of ohlcvRows) {
156 const rawDate = typeof r.date === 'string' ? r.date.slice(0, 10) : typeof r.ts_event === 'string' ? r.ts_event.slice(0, 10) : ''
157 const close = typeof r.close === 'number' ? r.close : Number(r.close)
158 if (!/^\d{4}-\d{2}-\d{2}$/.test(rawDate) || !Number.isFinite(close)) continue
159 const open = typeof r.open === 'number' && Number.isFinite(r.open) ? r.open : close
160 const high = typeof r.high === 'number' && Number.isFinite(r.high) ? r.high : close
161 const low = typeof r.low === 'number' && Number.isFinite(r.low) ? r.low : close
162 const vol = typeof r.volume === 'number' && Number.isFinite(r.volume) ? r.volume : 0
163 const existing = byDate.get(rawDate)
164 if (!existing) {
165 byDate.set(rawDate, {
166 bar: { date: rawDate, open, high, low, close, volume: vol },
167 topVol: vol,
168 })
169 } else {
170 existing.bar.high = Math.max(existing.bar.high, high)
171 existing.bar.low = Math.min(existing.bar.low, low)
172 existing.bar.volume += vol
173 if (vol > existing.topVol) {
174 existing.topVol = vol
175 existing.bar.open = open
176 existing.bar.close = close
177 }
178 }
179 }
180 return [...byDate.values()].map((d) => d.bar).sort((a, b) => a.date.localeCompare(b.date))
181}
182
183function isDatabentoPut(d: Record<string, unknown>): boolean {
184 if (d.put_call === 'P' || d.instrument_class === 'P' || d.right === 'P' || d.type === 'put') return true
185 if (typeof d.raw_symbol === 'string' && /^[A-Z0-9.\s]+\d{6}P\d{8}$/i.test(d.raw_symbol.trim())) return true
186 return false
187}
188
189export function selectDatabentoQuoteSymbols(
190 defsRows: Record<string, unknown>[],
191 ohlcvRows: Record<string, unknown>[],
192 requestedExp: string | null | undefined,
193 maxStrikes: number,
194): string[] {
195 const daily = consolidateDatabentoOhlcv(ohlcvRows)
196 const spot = daily[daily.length - 1]?.close ?? null
197 const expSet = new Set<string>()
198 for (const d of defsRows) {
199 const e = typeof d.expiration === 'string' ? d.expiration.slice(0, 10) : null
200 if (e) expSet.add(e)
201 }
202 const expirations = [...expSet].sort()
203 const selectedExpiration =
204 (requestedExp && expSet.has(requestedExp) ? requestedExp : expirations[0]) ?? requestedExp ?? null
205 if (!selectedExpiration) return []
206
207 const byStrike = new Map<number, string[]>()
208 for (const d of defsRows) {
209 const exp = typeof d.expiration === 'string' ? d.expiration.slice(0, 10) : null
210 if (exp !== selectedExpiration) continue
211 const strike =
212 typeof d.strike_price === 'number'
213 ? d.strike_price
214 : typeof d.strike === 'number'
215 ? d.strike
216 : Number(d.strike_price ?? d.strike)
217 if (!Number.isFinite(strike)) continue
218 const raw = typeof d.raw_symbol === 'string' && d.raw_symbol.trim() ? d.raw_symbol : null
219 if (!raw) continue
220 const list = byStrike.get(strike) ?? []
221 if (!list.includes(raw)) list.push(raw)
222 byStrike.set(strike, list)
223 }
224
225 const strikeRows = [...byStrike.keys()].map((strike) => ({ strike }) as ChainRow)
226 const near = filterStrikesNearSpot(strikeRows, spot, maxStrikes)
227 const symbols: string[] = []
228 for (const r of near) {
229 for (const sym of byStrike.get(r.strike) ?? []) {
230 if (!symbols.includes(sym)) symbols.push(sym)
231 }
232 }
233 return symbols.slice(0, 100)
234}
235
236export function buildDatabentoChain(
237 symbol: string,
238 defsRows: Record<string, unknown>[],
239 quotesRows: Record<string, unknown>[],
240 ohlcvRows: Record<string, unknown>[],
241 requestedExp: string | null | undefined,
242 maxStrikes: number,
243): { rows: ChainRow[]; meta: ChainMeta } {
244 const sym = symbol.toUpperCase()
245 const daily = consolidateDatabentoOhlcv(ohlcvRows)
246 const latestBar = daily[daily.length - 1]
247 const prevBar = daily.length > 1 ? daily[daily.length - 2] : undefined
248 const spot = latestBar ? latestBar.close : null
249 const prevClose = prevBar ? prevBar.close : latestBar ? latestBar.open : null
250 const change = spot != null && prevClose != null ? Number((spot - prevClose).toFixed(2)) : null
251 const changePct = change != null && prevClose && prevClose > 0 ? Number(((change / prevClose) * 100).toFixed(2)) : null
252 const high = latestBar ? latestBar.high : null
253 const low = latestBar ? latestBar.low : null
254 const dayRange = low != null && high != null ? `${low.toFixed(2)}-${high.toFixed(2)}` : null
255 const volume = latestBar ? latestBar.volume : null
256
257 const quoteMap = new Map<string, Record<string, unknown>>()
258 for (const q of quotesRows) {
259 const rawSym =
260 typeof q.raw_symbol === 'string'
261 ? q.raw_symbol.replace(/\s+/g, '')
262 : typeof q.symbol === 'string'
263 ? q.symbol.replace(/\s+/g, '')
264 : null
265 if (rawSym) quoteMap.set(rawSym, q)
266 }
267
268 const expSet = new Set<string>()
269 for (const d of defsRows) {
270 const e = typeof d.expiration === 'string' ? d.expiration.slice(0, 10) : null
271 if (e) expSet.add(e)
272 }
273 const expirations = [...expSet].sort()
274 const selectedExpiration =
275 (requestedExp && expSet.has(requestedExp) ? requestedExp : expirations[0]) ?? requestedExp ?? null
276
277 const byStrike = new Map<number, ChainRow>()
278 for (const d of defsRows) {
279 const exp = typeof d.expiration === 'string' ? d.expiration.slice(0, 10) : null
280 if (selectedExpiration && exp !== selectedExpiration) continue
281 const strike =
282 typeof d.strike_price === 'number'
283 ? d.strike_price
284 : typeof d.strike === 'number'
285 ? d.strike
286 : Number(d.strike_price ?? d.strike)
287 if (!Number.isFinite(strike)) continue
288 const isPut = isDatabentoPut(d)
289 const expStr = exp ?? selectedExpiration ?? ''
290 const rawSym =
291 typeof d.raw_symbol === 'string' && d.raw_symbol.trim()
292 ? d.raw_symbol.replace(/\s+/g, '')
293 : toOcc(sym, expStr, isPut ? 'P' : 'C', strike)
294 const q = quoteMap.get(rawSym)
295 const bid = typeof q?.bid_px === 'number' ? q.bid_px : typeof q?.bid === 'number' ? q.bid : null
296 const ask = typeof q?.ask_px === 'number' ? q.ask_px : typeof q?.ask === 'number' ? q.ask : null
297 const bidSz =
298 typeof q?.bid_sz === 'number'
299 ? q.bid_sz
300 : typeof q?.bid_size === 'number'
301 ? q.bid_size
302 : typeof q?.bidSize === 'number'
303 ? q.bidSize
304 : null
305 const askSz =
306 typeof q?.ask_sz === 'number'
307 ? q.ask_sz
308 : typeof q?.ask_size === 'number'
309 ? q.ask_size
310 : typeof q?.askSize === 'number'
311 ? q.askSize
312 : null
313 const mid = bid != null && ask != null ? Number(((bid + ask) / 2).toFixed(2)) : null
314 const last = typeof q?.last === 'number' ? q.last : mid
315
316 let row = byStrike.get(strike)
317 if (!row) {
318 const cc = toOcc(sym, expStr, 'C', strike)
319 const pc = toOcc(sym, expStr, 'P', strike)
320 row = {
321 contract: `${cc} / ${pc}`,
322 callContract: cc,
323 putContract: pc,
324 expiration: expStr,
325 strike,
326 callBid: null,
327 callBidSize: null,
328 callAsk: null,
329 callAskSize: null,
330 callLast: null,
331 callChg: null,
332 callVol: null,
333 callOi: null,
334 callIv: null,
335 callDelta: null,
336 callItm: spot != null ? strike < spot : false,
337 putBid: null,
338 putBidSize: null,
339 putAsk: null,
340 putAskSize: null,
341 putLast: null,
342 putChg: null,
343 putVol: null,
344 putOi: null,
345 putIv: null,
346 putDelta: null,
347 putItm: spot != null ? strike > spot : false,
348 }
349 byStrike.set(strike, row)
350 }
351 if (!isPut) {
352 row.callContract = rawSym
353 row.callBid = bid
354 row.callBidSize = bidSz
355 row.callAsk = ask
356 row.callAskSize = askSz
357 row.callLast = last
358 } else {
359 row.putContract = rawSym
360 row.putBid = bid
361 row.putBidSize = bidSz
362 row.putAsk = ask
363 row.putAskSize = askSz
364 row.putLast = last
365 }
366 row.contract = `${row.callContract} / ${row.putContract}`
367 }
368
369 const filtered = filterStrikesNearSpot([...byStrike.values()], spot, maxStrikes)
370 return computeSummary(filtered, {
371 symbol: sym,
372 provider: 'databento',
373 feedLabel: 'Databento OPRA',
374 spot,
375 change,
376 changePct,
377 dayRange,
378 volume,
379 expirations,
380 selectedExpiration,
381 })
382}