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>
246 lines
9.5 KiB
TypeScript
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);
|
|
}
|