Options Chain: Shows a calls/puts straddle table by strike (IV, delta, OI, volume, max pain) sourced from Yahoo, Alpaca, Massive, or Databento plugins.
| 1 | import { computeSummary, toOcc, type ChainMeta, type ChainRow } from './math.ts' |
| 2 | import { filterStrikesNearSpot } from './vendors.ts' |
| 3 | |
| 4 | export 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 | |
| 144 | export interface ConsolidatedDailyBar { |
| 145 | date: string |
| 146 | open: number |
| 147 | high: number |
| 148 | low: number |
| 149 | close: number |
| 150 | volume: number |
| 151 | } |
| 152 | |
| 153 | export 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 | |
| 183 | function 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 | |
| 189 | export 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 | |
| 236 | export 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 | } |