export interface ChainRow { contract: string callContract: string putContract: string expiration: string strike: number callBid: number | null callBidSize?: number | null callAsk: number | null callAskSize?: number | null callLast: number | null callLastTime?: string | null callChg: number | null callVol: number | null callOi: number | null callIv: number | null callDelta: number | null callItm: boolean putBid: number | null putBidSize?: number | null putAsk: number | null putAskSize?: number | null putLast: number | null putLastTime?: string | null putChg: number | null putVol: number | null putOi: number | null putIv: number | null putDelta: number | null putItm: boolean [key: string]: unknown } export interface ChainMeta { symbol: string provider: 'yahoo' | 'alpaca' | 'massive' | 'databento' feedLabel: string spot: number | null change: number | null changePct: number | null dayRange: string | null week52Range: string | null volume: number | null expirations: string[] selectedExpiration: string | null dte: number | null atmStrike: number | null atmIv: number | null straddlePrice: number | null straddlePct: number | null pcOiRatio: number | null pcVolRatio: number | null callOiTotal: number putOiTotal: number callVolTotal: number putVolTotal: number maxPain: number | null note: string | null } export function toOcc(symbol: string, expiration: string, type: 'C' | 'P', strike: number): string { const clean = symbol.replace(/[^A-Z0-9]/gi, '').toUpperCase().slice(0, 6) const exp = expiration.slice(0, 10).replace(/-/g, '').slice(2, 8) const k = Math.round(strike * 1000).toString().padStart(8, '0') return `${clean}${exp}${type}${k}` } export function calcDte(expiration: string | null): number | null { if (!expiration) return null const expMs = Date.parse(`${expiration.slice(0, 10)}T16:00:00Z`) if (!Number.isFinite(expMs)) return null const diff = Math.ceil((expMs - Date.now()) / 86_400_000) return Math.max(0, diff) } function normCdf(x: number): number { const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741, a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911 const sign = x < 0 ? -1 : 1 const absX = Math.abs(x) / Math.sqrt(2) const t = 1 / (1 + p * absX) const y = 1 - (((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t) * Math.exp(-absX * absX) return 0.5 * (1 + sign * y) } export function approxDelta( spot: number | null, strike: number, dte: number | null, ivPct: number | null, isCall: boolean, ): number | null { if (!spot || spot <= 0 || strike <= 0) return null if (ivPct == null || !Number.isFinite(ivPct) || ivPct <= 0) return null if (dte == null || !Number.isFinite(dte) || dte < 0) return null const sigma = ivPct / 100 const T = Math.max(dte / 365, 1 / 365) const d1 = (Math.log(spot / strike) + (0.045 + 0.5 * sigma * sigma) * T) / (sigma * Math.sqrt(T)) const cd = normCdf(d1) return Number((isCall ? cd : cd - 1).toFixed(3)) } export function computeSummary( rows: ChainRow[], base: Partial & { symbol: string; provider: ChainMeta['provider'] }, ): { rows: ChainRow[]; meta: ChainMeta } { const spot = base.spot ?? null const exp = base.selectedExpiration !== undefined ? base.selectedExpiration : (rows[0]?.expiration ?? null) const dte = base.dte !== undefined ? base.dte : calcDte(exp) let callOiTotal = 0, putOiTotal = 0, callVolTotal = 0, putVolTotal = 0 for (const r of rows) { callOiTotal += r.callOi ?? 0 putOiTotal += r.putOi ?? 0 callVolTotal += r.callVol ?? 0 putVolTotal += r.putVol ?? 0 if (r.callDelta === null && spot) r.callDelta = approxDelta(spot, r.strike, dte, r.callIv, true) if (r.putDelta === null && spot) r.putDelta = approxDelta(spot, r.strike, dte, r.putIv, false) } let atmRow: ChainRow | null = null if (rows.length > 0) { atmRow = rows.reduce((best, r) => { if (!spot) return best return Math.abs(r.strike - spot) < Math.abs(best.strike - spot) ? r : best }, rows[0]!) } const atmStrike = atmRow?.strike ?? null const callMid = atmRow ? atmRow.callBid != null && atmRow.callAsk != null && (atmRow.callBid > 0 || atmRow.callAsk > 0) ? (atmRow.callBid + atmRow.callAsk) / 2 : atmRow.callLast : null const putMid = atmRow ? atmRow.putBid != null && atmRow.putAsk != null && (atmRow.putBid > 0 || atmRow.putAsk > 0) ? (atmRow.putBid + atmRow.putAsk) / 2 : atmRow.putLast : null const straddlePrice = callMid != null && putMid != null ? Number((callMid + putMid).toFixed(2)) : null const straddlePct = straddlePrice != null && spot && spot > 0 ? Number(((straddlePrice / spot) * 100).toFixed(2)) : null const atmIv = atmRow ? atmRow.callIv != null && atmRow.putIv != null ? Number(((atmRow.callIv + atmRow.putIv) / 2).toFixed(2)) : (atmRow.callIv ?? atmRow.putIv ?? null) : null let maxPain: number | null = null if (rows.length > 0 && (callOiTotal > 0 || putOiTotal > 0)) { let minVal = Infinity for (const settle of rows) { let pain = 0 for (const r of rows) { if (settle.strike > r.strike) pain += (settle.strike - r.strike) * (r.callOi ?? 0) if (settle.strike < r.strike) pain += (r.strike - settle.strike) * (r.putOi ?? 0) } if (pain < minVal) { minVal = pain maxPain = settle.strike } } } return { rows, meta: { symbol: base.symbol, provider: base.provider, feedLabel: base.feedLabel ?? base.provider.toUpperCase(), spot, change: base.change ?? null, changePct: base.changePct ?? null, dayRange: base.dayRange ?? null, week52Range: base.week52Range ?? null, volume: base.volume ?? null, expirations: base.expirations ?? (exp ? [exp] : []), selectedExpiration: exp, dte, atmStrike, atmIv, straddlePrice, straddlePct, pcOiRatio: callOiTotal > 0 ? Number((putOiTotal / callOiTotal).toFixed(2)) : null, pcVolRatio: callVolTotal > 0 ? Number((putVolTotal / callVolTotal).toFixed(2)) : null, callOiTotal, putOiTotal, callVolTotal, putVolTotal, maxPain, note: base.note ?? null, }, } }