position-tracker/src/ai/greeks.ts
Manohar 1fbc9279a4 fix: parse SENSEX weekly symbol format DD+MON+STRIKE (no year)
Angel One SENSEX weeklies use e.g. SENSEX26JUN77300PE where 26=day,
JUN=month, 77300=strike — no year in the symbol. The previous parser
tried YY+MON+DD, treating 26 as year and 77 as day, giving an invalid
date (2026-06-77) and returning null for ALL option positions.

Added Format 3: DD(1-2) + MON(3-letter) + STRIKE + TYPE
Year is inferred: current year, or next year if date is >7 days past.
Format 2 (YY+MON+DD) is left as-is so it still falls through to Format 3
when its parsed date is invalid (day=77 etc).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 16:02:50 +05:30

246 lines
9.5 KiB
TypeScript

/**
* 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<string, string> = {
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);
}