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 | |
| 3 | export function filterStrikesNearSpot<T extends { strike: number }>( |
| 4 | rows: T[], |
| 5 | spot: number | null, |
| 6 | maxStrikes: number, |
| 7 | ): T[] { |
| 8 | const sorted = [...rows].sort((a, b) => a.strike - b.strike) |
| 9 | if (!maxStrikes || maxStrikes <= 0 || sorted.length <= maxStrikes) return sorted |
| 10 | const ref = spot ?? sorted[Math.floor(sorted.length / 2)]!.strike |
| 11 | return [...sorted] |
| 12 | .sort((a, b) => Math.abs(a.strike - ref) - Math.abs(b.strike - ref) || a.strike - b.strike) |
| 13 | .slice(0, maxStrikes) |
| 14 | .sort((a, b) => a.strike - b.strike) |
| 15 | } |
| 16 | |
| 17 | export function buildAlpacaChain( |
| 18 | symbol: string, |
| 19 | alpacaFeed: 'indicative' | 'opra', |
| 20 | optRows: Record<string, unknown>[], |
| 21 | snapRow: Record<string, unknown> | undefined, |
| 22 | requestedExp: string | null | undefined, |
| 23 | maxStrikes: number, |
| 24 | allExpirations?: string[], |
| 25 | ): { rows: ChainRow[]; meta: ChainMeta } { |
| 26 | const sym = symbol.toUpperCase() |
| 27 | const spot = |
| 28 | typeof snapRow?.last === 'number' |
| 29 | ? snapRow.last |
| 30 | : typeof snapRow?.price === 'number' |
| 31 | ? snapRow.price |
| 32 | : typeof snapRow?.prev_close === 'number' |
| 33 | ? snapRow.prev_close |
| 34 | : typeof snapRow?.prevClose === 'number' |
| 35 | ? snapRow.prevClose |
| 36 | : null |
| 37 | const change = typeof snapRow?.change === 'number' ? snapRow.change : null |
| 38 | const changePct = |
| 39 | typeof snapRow?.change_pct === 'number' |
| 40 | ? snapRow.change_pct |
| 41 | : typeof snapRow?.changePct === 'number' |
| 42 | ? snapRow.changePct |
| 43 | : null |
| 44 | const dayHigh = |
| 45 | typeof snapRow?.day_high === 'number' |
| 46 | ? snapRow.day_high |
| 47 | : typeof snapRow?.dayHigh === 'number' |
| 48 | ? snapRow.dayHigh |
| 49 | : null |
| 50 | const dayLow = |
| 51 | typeof snapRow?.day_low === 'number' |
| 52 | ? snapRow.day_low |
| 53 | : typeof snapRow?.dayLow === 'number' |
| 54 | ? snapRow.dayLow |
| 55 | : null |
| 56 | const dayRange = dayLow != null && dayHigh != null ? `${dayLow.toFixed(2)}-${dayHigh.toFixed(2)}` : null |
| 57 | const volume = |
| 58 | typeof snapRow?.day_volume === 'number' |
| 59 | ? snapRow.day_volume |
| 60 | : typeof snapRow?.dayVolume === 'number' |
| 61 | ? snapRow.dayVolume |
| 62 | : null |
| 63 | |
| 64 | let sessionDate: string | null = |
| 65 | typeof snapRow?.day_time === 'string' |
| 66 | ? snapRow.day_time.slice(0, 10) |
| 67 | : typeof snapRow?.last_time === 'string' |
| 68 | ? snapRow.last_time.slice(0, 10) |
| 69 | : null |
| 70 | |
| 71 | const expSet = new Set<string>(allExpirations ?? []) |
| 72 | for (const r of optRows) { |
| 73 | const e = typeof r.expiration === 'string' ? r.expiration : null |
| 74 | if (e) expSet.add(e) |
| 75 | const qt = typeof r.quote_time === 'string' ? r.quote_time.slice(0, 10) : null |
| 76 | if (qt && (!sessionDate || qt > sessionDate)) sessionDate = qt |
| 77 | } |
| 78 | const expirations = [...expSet].sort() |
| 79 | const selectedExpiration = |
| 80 | (requestedExp && expSet.has(requestedExp) ? requestedExp : expirations[0]) ?? requestedExp ?? null |
| 81 | |
| 82 | const byStrike = new Map<number, ChainRow>() |
| 83 | for (const r of optRows) { |
| 84 | const exp = typeof r.expiration === 'string' ? r.expiration : null |
| 85 | if (selectedExpiration && exp !== selectedExpiration) continue |
| 86 | const strike = typeof r.strike === 'number' ? r.strike : Number(r.strike) |
| 87 | if (!Number.isFinite(strike)) continue |
| 88 | const type = r.type === 'put' ? 'put' : 'call' |
| 89 | const expStr = exp ?? selectedExpiration ?? '' |
| 90 | let row = byStrike.get(strike) |
| 91 | if (!row) { |
| 92 | const cc = toOcc(sym, expStr, 'C', strike) |
| 93 | const pc = toOcc(sym, expStr, 'P', strike) |
| 94 | row = { |
| 95 | contract: `${cc} / ${pc}`, |
| 96 | callContract: cc, |
| 97 | putContract: pc, |
| 98 | expiration: expStr, |
| 99 | strike, |
| 100 | callBid: null, |
| 101 | callBidSize: null, |
| 102 | callAsk: null, |
| 103 | callAskSize: null, |
| 104 | callLast: null, |
| 105 | callLastTime: null, |
| 106 | callChg: null, |
| 107 | callVol: null, |
| 108 | callOi: null, |
| 109 | callIv: null, |
| 110 | callDelta: null, |
| 111 | callItm: spot != null ? strike < spot : false, |
| 112 | putBid: null, |
| 113 | putBidSize: null, |
| 114 | putAsk: null, |
| 115 | putAskSize: null, |
| 116 | putLast: null, |
| 117 | putLastTime: null, |
| 118 | putChg: null, |
| 119 | putVol: null, |
| 120 | putOi: null, |
| 121 | putIv: null, |
| 122 | putDelta: null, |
| 123 | putItm: spot != null ? strike > spot : false, |
| 124 | } |
| 125 | byStrike.set(strike, row) |
| 126 | } |
| 127 | const rawIv = |
| 128 | typeof r.iv === 'number' ? (r.iv <= 5 ? Number((r.iv * 100).toFixed(2)) : Number(r.iv.toFixed(2))) : null |
| 129 | const lastTime = typeof r.last_time === 'string' ? r.last_time : null |
| 130 | const isCurrentSessionTrade = |
| 131 | !sessionDate || (lastTime != null && lastTime.slice(0, 10) >= sessionDate) |
| 132 | const rawLast = |
| 133 | typeof r.last === 'number' ? r.last : typeof r.lastPrice === 'number' ? r.lastPrice : null |
| 134 | const rawVol = |
| 135 | typeof r.day_volume === 'number' ? r.day_volume : typeof r.volume === 'number' ? r.volume : null |
| 136 | const rawOi = |
| 137 | typeof r.open_interest === 'number' ? r.open_interest : typeof r.openInterest === 'number' ? r.openInterest : null |
| 138 | const lastPx = isCurrentSessionTrade ? rawLast : null |
| 139 | const volNum = isCurrentSessionTrade ? rawVol : null |
| 140 | const bidSz = typeof r.bid_size === 'number' ? r.bid_size : typeof r.bidSize === 'number' ? r.bidSize : null |
| 141 | const askSz = typeof r.ask_size === 'number' ? r.ask_size : typeof r.askSize === 'number' ? r.askSize : null |
| 142 | |
| 143 | if (type === 'call') { |
| 144 | if (typeof r.symbol === 'string') row.callContract = r.symbol |
| 145 | row.callBid = typeof r.bid === 'number' ? r.bid : null |
| 146 | row.callBidSize = bidSz |
| 147 | row.callAsk = typeof r.ask === 'number' ? r.ask : null |
| 148 | row.callAskSize = askSz |
| 149 | row.callLast = lastPx |
| 150 | row.callLastTime = lastTime |
| 151 | row.callVol = volNum |
| 152 | row.callOi = rawOi |
| 153 | row.callIv = rawIv |
| 154 | row.callDelta = typeof r.delta === 'number' ? Number(r.delta.toFixed(3)) : null |
| 155 | } else { |
| 156 | if (typeof r.symbol === 'string') row.putContract = r.symbol |
| 157 | row.putBid = typeof r.bid === 'number' ? r.bid : null |
| 158 | row.putBidSize = bidSz |
| 159 | row.putAsk = typeof r.ask === 'number' ? r.ask : null |
| 160 | row.putAskSize = askSz |
| 161 | row.putLast = lastPx |
| 162 | row.putLastTime = lastTime |
| 163 | row.putVol = volNum |
| 164 | row.putOi = rawOi |
| 165 | row.putIv = rawIv |
| 166 | row.putDelta = typeof r.delta === 'number' ? Number(r.delta.toFixed(3)) : null |
| 167 | } |
| 168 | row.contract = `${row.callContract} / ${row.putContract}` |
| 169 | } |
| 170 | |
| 171 | const filtered = filterStrikesNearSpot([...byStrike.values()], spot, maxStrikes) |
| 172 | return computeSummary(filtered, { |
| 173 | symbol: sym, |
| 174 | provider: 'alpaca', |
| 175 | feedLabel: `Alpaca (${alpacaFeed.toUpperCase()})`, |
| 176 | spot, |
| 177 | change, |
| 178 | changePct, |
| 179 | dayRange, |
| 180 | volume, |
| 181 | expirations, |
| 182 | selectedExpiration, |
| 183 | }) |
| 184 | } |