/** * Black-Scholes IV solver + Greeks calculator for European options. * * Why Black-Scholes for SENSEX weeklies (European, no dividends mid-week)? * - Closed-form, fast (~5ms per position) * - Standard model; bid-ask spreads dominate any model error on weeklies * * Inputs use IST conventions: * - dte: days to expiry (integer or fractional). Expiry assumed at 15:30 IST. * - rfr: 6.5% (RBI repo, default — can be overridden via env) * - dy: 1.2% SENSEX trailing dividend yield (default) */ export interface GreeksInput { spot: number; // Underlying spot price strike: number; // Option strike dte: number; // Days to expiry (fractional ok, e.g. 2.3) ltp: number; // Current option premium type: 'CE' | 'PE'; rfr?: number; // Annualised risk-free rate, default 0.065 dy?: number; // Annualised dividend yield, default 0.012 } export interface Greeks { iv: number; // Implied vol, annualised (e.g. 0.16 = 16%) delta: number; // ∂price/∂spot (per 1.0 spot move) gamma: number; // ∂delta/∂spot theta: number; // ∂price/∂t (per day, NOT per year — we divide by 365) vega: number; // ∂price/∂vol (per 1.0 vol point, i.e. 100%) rho: number; // ∂price/∂rfr (per 1.0 rate point) ok: boolean; // false if IV solver failed (deep OTM, illiquid, etc) } const SQRT_2PI = Math.sqrt(2 * Math.PI); // Standard normal PDF const pdf = (x: number) => Math.exp(-0.5 * x * x) / SQRT_2PI; // Standard normal CDF — Abramowitz & Stegun approximation, |err| < 7.5e-8 function cdf(x: number): number { const sign = x < 0 ? -1 : 1; x = Math.abs(x) / Math.SQRT2; // A&S formula 7.1.26 const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741; const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911; const t = 1.0 / (1.0 + p * x); const y = 1.0 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1) * t * Math.exp(-x * x); return 0.5 * (1.0 + sign * y); } /** * Black-Scholes price for European call or put with continuous dividend yield. * Returns price; helper used by IV solver. */ function bsPrice(spot: number, strike: number, t: number, rfr: number, dy: number, vol: number, type: 'CE' | 'PE'): number { if (t <= 0 || vol <= 0) { // At-expiry intrinsic value return type === 'CE' ? Math.max(spot - strike, 0) : Math.max(strike - spot, 0); } const sqrtT = Math.sqrt(t); const d1 = (Math.log(spot / strike) + (rfr - dy + 0.5 * vol * vol) * t) / (vol * sqrtT); const d2 = d1 - vol * sqrtT; const discS = spot * Math.exp(-dy * t); const discK = strike * Math.exp(-rfr * t); if (type === 'CE') return discS * cdf(d1) - discK * cdf(d2); return discK * cdf(-d2) - discS * cdf(-d1); } /** * Solve for implied vol via Newton-Raphson (vega-based), with bisection fallback. * * Returns IV in annualised decimal form (e.g. 0.16 = 16% annualised). * Returns NaN if no convergence — caller should treat as "Greeks unavailable". */ export function solveIV(input: GreeksInput): number { const { spot, strike, dte, ltp, type } = input; const rfr = input.rfr ?? 0.065; const dy = input.dy ?? 0.012; const t = dte / 365; if (t <= 0 || ltp <= 0) return NaN; // Intrinsic value floor — if premium ≤ intrinsic, IV is effectively 0 const intrinsic = type === 'CE' ? Math.max(spot - strike, 0) : Math.max(strike - spot, 0); if (ltp <= intrinsic + 0.01) return 0; // Newton-Raphson: start with reasonable IV guess let vol = 0.20; // start 20% annualised for (let i = 0; i < 50; i++) { const price = bsPrice(spot, strike, t, rfr, dy, vol, type); const diff = price - ltp; if (Math.abs(diff) < 0.01) return vol; // Vega in per-1.0-vol terms const sqrtT = Math.sqrt(t); const d1 = (Math.log(spot / strike) + (rfr - dy + 0.5 * vol * vol) * t) / (vol * sqrtT); const vega = spot * Math.exp(-dy * t) * pdf(d1) * sqrtT; if (vega < 1e-6) break; // dead zone — bail to bisection vol = vol - diff / vega; // Sanity guard if (vol < 0.001) vol = 0.001; if (vol > 5) vol = 5; } // Bisection fallback (slower but always converges within bracket) let lo = 0.001, hi = 5; for (let i = 0; i < 60; i++) { const mid = (lo + hi) / 2; const price = bsPrice(spot, strike, t, rfr, dy, mid, type); if (Math.abs(price - ltp) < 0.01) return mid; if (price < ltp) lo = mid; else hi = mid; } return NaN; } /** * Compute full Greeks pack for an option position. * * Notes on units: * - delta: per 1 unit move in spot (multiply by qty for position delta) * - gamma: per 1 unit move in spot * - theta: per day (already converted from per-year) * - vega: per 1 vol-point (i.e. price change if IV moves from 0.20 to 0.21 = vega/100) * * Sign convention: position-level (qty applied externally for portfolio aggregation) */ export function calcGreeks(input: GreeksInput): Greeks { const { spot, strike, dte, type } = input; const rfr = input.rfr ?? 0.065; const dy = input.dy ?? 0.012; const t = dte / 365; const iv = solveIV(input); if (!isFinite(iv) || iv <= 0 || t <= 0) { return { iv: NaN, delta: 0, gamma: 0, theta: 0, vega: 0, rho: 0, ok: false }; } const sqrtT = Math.sqrt(t); const d1 = (Math.log(spot / strike) + (rfr - dy + 0.5 * iv * iv) * t) / (iv * sqrtT); const d2 = d1 - iv * sqrtT; const discS = spot * Math.exp(-dy * t); const discK = strike * Math.exp(-rfr * t); const delta = type === 'CE' ? Math.exp(-dy * t) * cdf(d1) : Math.exp(-dy * t) * (cdf(d1) - 1); const gamma = (Math.exp(-dy * t) * pdf(d1)) / (spot * iv * sqrtT); // Theta per year then convert to per-day const thetaPerYear = type === 'CE' ? -(discS * pdf(d1) * iv) / (2 * sqrtT) - rfr * discK * cdf(d2) + dy * discS * cdf(d1) : -(discS * pdf(d1) * iv) / (2 * sqrtT) + rfr * discK * cdf(-d2) - dy * discS * cdf(-d1); const theta = thetaPerYear / 365; // Vega per 1 vol-point (decimal); divide by 100 if you want per 1% vol const vega = discS * pdf(d1) * sqrtT; const rho = type === 'CE' ? strike * t * Math.exp(-rfr * t) * cdf(d2) / 100 : -strike * t * Math.exp(-rfr * t) * cdf(-d2) / 100; return { iv, delta, gamma, theta, vega, rho, ok: true }; } const MONTH_MAP: Record = { JAN:'01',FEB:'02',MAR:'03',APR:'04',MAY:'05',JUN:'06', JUL:'07',AUG:'08',SEP:'09',OCT:'10',NOV:'11',DEC:'12', }; /** * Parse Angel One option symbol -> strike/type/expiry. * Handles three formats observed in the wild: * * Format 1 — YY + M(1-digit) + DD + STRIKE * SENSEX2660475500CE → 2026-06-04, strike 75500 * * Format 2 — YY + MON + DD + STRIKE (NIFTY carry-forward style) * NIFTY28FEB2522500CE → 2025-02-28, strike 22500 * (NOTE: regex groups are DD, MON, YY order — Angel puts day first) * * Format 3 — DD + MON + STRIKE (SENSEX weekly, no year in symbol) * SENSEX26JUN77300PE → 2026-06-26, strike 77300, year inferred */ export function parseOptionSymbol(sym: string): { underlying: string; strike: number; type: 'CE' | 'PE'; expiry: Date } | null { // Format 1: YY + M(1) + DD + STRIKE let m = sym.match(/^([A-Z]+)(\d{2})(\d{1})(\d{2})(\d+)(CE|PE)$/); if (m) { const yy = m[2], mo = m[3].padStart(2, '0'), dd = m[4]; const exp = new Date(`20${yy}-${mo}-${dd}T15:30:00+05:30`); if (!isNaN(exp.getTime())) { return { underlying: m[1], strike: parseInt(m[5]), type: m[6] as 'CE' | 'PE', expiry: exp }; } } // Format 2: YY + MON + DD + STRIKE (kept for backward compat; falls through on invalid dates) // Note: SENSEX26JUN77300PE is NOT this format — it falls through to Format 3 below. m = sym.match(/^([A-Z]+)(\d{2})(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(\d{2})(\d+)(CE|PE)$/i); if (m) { const yy = m[2], mo = MONTH_MAP[m[3].toUpperCase()], dd = m[4]; const exp = new Date(`20${yy}-${mo}-${dd}T15:30:00+05:30`); if (!isNaN(exp.getTime())) { return { underlying: m[1], strike: parseInt(m[5]), type: m[6].toUpperCase() as 'CE' | 'PE', expiry: exp }; } } // Format 3: DD + MON + STRIKE (SENSEX weekly — no year, infer from calendar) m = sym.match(/^([A-Z]+)(\d{1,2})(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(\d+)(CE|PE)$/i); if (m) { const dd = m[2].padStart(2, '0'), mo = MONTH_MAP[m[3].toUpperCase()]; const thisYear = new Date().getFullYear(); // Try current year first; if date is already >7 days in the past, try next year let exp = new Date(`${thisYear}-${mo}-${dd}T15:30:00+05:30`); if (isNaN(exp.getTime()) || exp.getTime() < Date.now() - 7 * 86400000) { exp = new Date(`${thisYear + 1}-${mo}-${dd}T15:30:00+05:30`); } if (!isNaN(exp.getTime())) { return { underlying: m[1], strike: parseInt(m[4]), type: m[5].toUpperCase() as 'CE' | 'PE', expiry: exp }; } } return null; } /** * Days-to-expiry as fractional days (using IST 15:30 closing). * Returns 0 for expired contracts (clamped). */ export function dteFromExpiry(expiry: Date, now: Date = new Date()): number { const ms = expiry.getTime() - now.getTime(); return Math.max(0, ms / 86400000); } /** * Black-Scholes theoretical price at an arbitrary spot using a provided IV. * Used in scenario analysis: compute leg P&L at scenario spots with current market IV. */ export function calcTheoreticalPrice( spot: number, strike: number, dte: number, iv: number, type: 'CE' | 'PE', rfr = 0.065, dy = 0.012 ): number { if (iv <= 0 || dte <= 0 || spot <= 0) { return type === 'CE' ? Math.max(spot - strike, 0) : Math.max(strike - spot, 0); } return bsPrice(spot, strike, dte / 365, rfr, dy, iv, type); }