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 | export interface ChainRow { |
| 2 | contract: string |
| 3 | callContract: string |
| 4 | putContract: string |
| 5 | expiration: string |
| 6 | strike: number |
| 7 | callBid: number | null |
| 8 | callBidSize?: number | null |
| 9 | callAsk: number | null |
| 10 | callAskSize?: number | null |
| 11 | callLast: number | null |
| 12 | callLastTime?: string | null |
| 13 | callChg: number | null |
| 14 | callVol: number | null |
| 15 | callOi: number | null |
| 16 | callIv: number | null |
| 17 | callDelta: number | null |
| 18 | callItm: boolean |
| 19 | putBid: number | null |
| 20 | putBidSize?: number | null |
| 21 | putAsk: number | null |
| 22 | putAskSize?: number | null |
| 23 | putLast: number | null |
| 24 | putLastTime?: string | null |
| 25 | putChg: number | null |
| 26 | putVol: number | null |
| 27 | putOi: number | null |
| 28 | putIv: number | null |
| 29 | putDelta: number | null |
| 30 | putItm: boolean |
| 31 | [key: string]: unknown |
| 32 | } |
| 33 | |
| 34 | export interface ChainMeta { |
| 35 | symbol: string |
| 36 | provider: 'yahoo' | 'alpaca' | 'massive' | 'databento' |
| 37 | feedLabel: string |
| 38 | spot: number | null |
| 39 | change: number | null |
| 40 | changePct: number | null |
| 41 | dayRange: string | null |
| 42 | week52Range: string | null |
| 43 | volume: number | null |
| 44 | expirations: string[] |
| 45 | selectedExpiration: string | null |
| 46 | dte: number | null |
| 47 | atmStrike: number | null |
| 48 | atmIv: number | null |
| 49 | straddlePrice: number | null |
| 50 | straddlePct: number | null |
| 51 | pcOiRatio: number | null |
| 52 | pcVolRatio: number | null |
| 53 | callOiTotal: number |
| 54 | putOiTotal: number |
| 55 | callVolTotal: number |
| 56 | putVolTotal: number |
| 57 | maxPain: number | null |
| 58 | note: string | null |
| 59 | } |
| 60 | |
| 61 | export function toOcc(symbol: string, expiration: string, type: 'C' | 'P', strike: number): string { |
| 62 | const clean = symbol.replace(/[^A-Z0-9]/gi, '').toUpperCase().slice(0, 6) |
| 63 | const exp = expiration.slice(0, 10).replace(/-/g, '').slice(2, 8) |
| 64 | const k = Math.round(strike * 1000).toString().padStart(8, '0') |
| 65 | return `${clean}${exp}${type}${k}` |
| 66 | } |
| 67 | |
| 68 | export function calcDte(expiration: string | null): number | null { |
| 69 | if (!expiration) return null |
| 70 | const expMs = Date.parse(`${expiration.slice(0, 10)}T16:00:00Z`) |
| 71 | if (!Number.isFinite(expMs)) return null |
| 72 | const diff = Math.ceil((expMs - Date.now()) / 86_400_000) |
| 73 | return Math.max(0, diff) |
| 74 | } |
| 75 | |
| 76 | function normCdf(x: number): number { |
| 77 | const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741, a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911 |
| 78 | const sign = x < 0 ? -1 : 1 |
| 79 | const absX = Math.abs(x) / Math.sqrt(2) |
| 80 | const t = 1 / (1 + p * absX) |
| 81 | const y = 1 - (((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t) * Math.exp(-absX * absX) |
| 82 | return 0.5 * (1 + sign * y) |
| 83 | } |
| 84 | |
| 85 | export function approxDelta( |
| 86 | spot: number | null, |
| 87 | strike: number, |
| 88 | dte: number | null, |
| 89 | ivPct: number | null, |
| 90 | isCall: boolean, |
| 91 | ): number | null { |
| 92 | if (!spot || spot <= 0 || strike <= 0) return null |
| 93 | if (ivPct == null || !Number.isFinite(ivPct) || ivPct <= 0) return null |
| 94 | if (dte == null || !Number.isFinite(dte) || dte < 0) return null |
| 95 | const sigma = ivPct / 100 |
| 96 | const T = Math.max(dte / 365, 1 / 365) |
| 97 | const d1 = (Math.log(spot / strike) + (0.045 + 0.5 * sigma * sigma) * T) / (sigma * Math.sqrt(T)) |
| 98 | const cd = normCdf(d1) |
| 99 | return Number((isCall ? cd : cd - 1).toFixed(3)) |
| 100 | } |
| 101 | |
| 102 | export function computeSummary( |
| 103 | rows: ChainRow[], |
| 104 | base: Partial<ChainMeta> & { symbol: string; provider: ChainMeta['provider'] }, |
| 105 | ): { rows: ChainRow[]; meta: ChainMeta } { |
| 106 | const spot = base.spot ?? null |
| 107 | const exp = base.selectedExpiration !== undefined ? base.selectedExpiration : (rows[0]?.expiration ?? null) |
| 108 | const dte = base.dte !== undefined ? base.dte : calcDte(exp) |
| 109 | let callOiTotal = 0, putOiTotal = 0, callVolTotal = 0, putVolTotal = 0 |
| 110 | |
| 111 | for (const r of rows) { |
| 112 | callOiTotal += r.callOi ?? 0 |
| 113 | putOiTotal += r.putOi ?? 0 |
| 114 | callVolTotal += r.callVol ?? 0 |
| 115 | putVolTotal += r.putVol ?? 0 |
| 116 | if (r.callDelta === null && spot) r.callDelta = approxDelta(spot, r.strike, dte, r.callIv, true) |
| 117 | if (r.putDelta === null && spot) r.putDelta = approxDelta(spot, r.strike, dte, r.putIv, false) |
| 118 | } |
| 119 | |
| 120 | let atmRow: ChainRow | null = null |
| 121 | if (rows.length > 0) { |
| 122 | atmRow = rows.reduce((best, r) => { |
| 123 | if (!spot) return best |
| 124 | return Math.abs(r.strike - spot) < Math.abs(best.strike - spot) ? r : best |
| 125 | }, rows[0]!) |
| 126 | } |
| 127 | |
| 128 | const atmStrike = atmRow?.strike ?? null |
| 129 | const callMid = atmRow |
| 130 | ? atmRow.callBid != null && atmRow.callAsk != null && (atmRow.callBid > 0 || atmRow.callAsk > 0) |
| 131 | ? (atmRow.callBid + atmRow.callAsk) / 2 |
| 132 | : atmRow.callLast |
| 133 | : null |
| 134 | const putMid = atmRow |
| 135 | ? atmRow.putBid != null && atmRow.putAsk != null && (atmRow.putBid > 0 || atmRow.putAsk > 0) |
| 136 | ? (atmRow.putBid + atmRow.putAsk) / 2 |
| 137 | : atmRow.putLast |
| 138 | : null |
| 139 | const straddlePrice = callMid != null && putMid != null ? Number((callMid + putMid).toFixed(2)) : null |
| 140 | const straddlePct = straddlePrice != null && spot && spot > 0 ? Number(((straddlePrice / spot) * 100).toFixed(2)) : null |
| 141 | const atmIv = atmRow |
| 142 | ? atmRow.callIv != null && atmRow.putIv != null |
| 143 | ? Number(((atmRow.callIv + atmRow.putIv) / 2).toFixed(2)) |
| 144 | : (atmRow.callIv ?? atmRow.putIv ?? null) |
| 145 | : null |
| 146 | |
| 147 | let maxPain: number | null = null |
| 148 | if (rows.length > 0 && (callOiTotal > 0 || putOiTotal > 0)) { |
| 149 | let minVal = Infinity |
| 150 | for (const settle of rows) { |
| 151 | let pain = 0 |
| 152 | for (const r of rows) { |
| 153 | if (settle.strike > r.strike) pain += (settle.strike - r.strike) * (r.callOi ?? 0) |
| 154 | if (settle.strike < r.strike) pain += (r.strike - settle.strike) * (r.putOi ?? 0) |
| 155 | } |
| 156 | if (pain < minVal) { |
| 157 | minVal = pain |
| 158 | maxPain = settle.strike |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | return { |
| 164 | rows, |
| 165 | meta: { |
| 166 | symbol: base.symbol, |
| 167 | provider: base.provider, |
| 168 | feedLabel: base.feedLabel ?? base.provider.toUpperCase(), |
| 169 | spot, |
| 170 | change: base.change ?? null, |
| 171 | changePct: base.changePct ?? null, |
| 172 | dayRange: base.dayRange ?? null, |
| 173 | week52Range: base.week52Range ?? null, |
| 174 | volume: base.volume ?? null, |
| 175 | expirations: base.expirations ?? (exp ? [exp] : []), |
| 176 | selectedExpiration: exp, |
| 177 | dte, |
| 178 | atmStrike, |
| 179 | atmIv, |
| 180 | straddlePrice, |
| 181 | straddlePct, |
| 182 | pcOiRatio: callOiTotal > 0 ? Number((putOiTotal / callOiTotal).toFixed(2)) : null, |
| 183 | pcVolRatio: callVolTotal > 0 ? Number((putVolTotal / callVolTotal).toFixed(2)) : null, |
| 184 | callOiTotal, |
| 185 | putOiTotal, |
| 186 | callVolTotal, |
| 187 | putVolTotal, |
| 188 | maxPain, |
| 189 | note: base.note ?? null, |
| 190 | }, |
| 191 | } |
| 192 | } |