/**
* Volatility smile analytics
*/
import type { OptionContract, VolSmile, VolSmilePoint, OptionsChain } from "./types";
export interface ComputeVolSmileParams {
/** Options chain data */
chain: OptionsChain;
/** Expiration to analyze (ISO string). If omitted, uses nearest expiration. */
expiration?: string;
/** Minimum open interest to include a strike (default: 10) */
minOpenInterest?: number;
/** Maximum bid-ask spread % to include (default: 0.5 = 50%) */
maxBidAskSpreadPct?: number;
/** Use OTM options only for cleaner smile (default: true) */
useOtmOnly?: boolean;
}
/**
* Compute volatility smile from options chain data
*/
export function computeVolSmile(params: ComputeVolSmileParams): VolSmile {
const {
chain,
expiration,
minOpenInterest = 10,
maxBidAskSpreadPct = 0.5,
useOtmOnly = true,
} = params;
const spot = chain.underlyingPrice;
const targetExp = expiration ?? chain.expirations[0];
if (!targetExp) {
throw new Error("No expiration dates available in options chain");
}
// Filter contracts for target expiration
const expContracts = chain.contracts.filter((c) => c.expiration === targetExp);
if (expContracts.length === 0) {
throw new Error(`No contracts found for expiration ${targetExp}`);
}
const timeToMaturity = expContracts[0].timeToMaturityYears;
// Build smile points
const points: VolSmilePoint[] = [];
const strikeSet = new Set<number>();
for (const contract of expContracts) {
// Skip if no IV
if (contract.impliedVol == null || contract.impliedVol <= 0) continue;
// Skip low liquidity
if (contract.openInterest < minOpenInterest) continue;
// Calculate bid-ask spread
const bidAskSpread = contract.ask - contract.bid;
const bidAskSpreadPct = contract.mid > 0 ? bidAskSpread / contract.mid : 1;
if (bidAskSpreadPct > maxBidAskSpreadPct) continue;
const moneyness = contract.strike / spot;
const isOtm =
(contract.right === "call" && contract.strike > spot) ||
(contract.right === "put" && contract.strike < spot);
// If useOtmOnly, skip ITM options (to avoid put-call parity issues)
if (useOtmOnly && !isOtm && Math.abs(moneyness - 1) > 0.01) continue;
// Avoid duplicates at same strike (prefer OTM)
const strikeKey = contract.strike;
if (strikeSet.has(strikeKey)) continue;
strikeSet.add(strikeKey);
points.push({
strike: contract.strike,
moneyness,
logMoneyness: Math.log(moneyness),
iv: contract.impliedVol,
optionType: contract.right,
bidAskSpreadPct,
openInterest: contract.openInterest,
});
}
// Sort by strike
points.sort((a, b) => a.strike - b.strike);
if (points.length === 0) {
throw new Error("No valid IV points found after filtering");
}
// Compute ATM IV (interpolate at moneyness = 1.0)
const atmIv = interpolateAtmIv(points, spot);
// Compute skew metrics
const skew = computeSkewMetrics(points, spot, atmIv);
return {
symbol: chain.symbol,
spot,
expiration: targetExp,
timeToMaturityYears: timeToMaturity,
atmIv,
points,
skew,
};
}
/**
* Interpolate IV at ATM (moneyness = 1.0)
*/
function interpolateAtmIv(points: VolSmilePoint[], spot: number): number {
// Find the two points closest to ATM
let below: VolSmilePoint | null = null;
let above: VolSmilePoint | null = null;
for (const p of points) {
if (p.strike <= spot) {
if (!below || p.strike > below.strike) below = p;
}
if (p.strike >= spot) {
if (!above || p.strike < above.strike) above = p;
}
}
if (below && above && below !== above) {
// Linear interpolation
const w = (spot - below.strike) / (above.strike - below.strike);
return below.iv + w * (above.iv - below.iv);
}
// Fallback to closest point
return below?.iv ?? above?.iv ?? points[0].iv;
}
/**
* Compute skew metrics from smile points
*/
function computeSkewMetrics(
points: VolSmilePoint[],
spot: number,
atmIv: number
): VolSmile["skew"] {
// Find approximate 25-delta strikes (roughly 5-10% OTM)
// For simplicity, use fixed moneyness proxies: 0.95 for puts, 1.05 for calls
const putMoneyness = 0.95;
const callMoneyness = 1.05;
const putIv = findIvAtMoneyness(points, putMoneyness);
const callIv = findIvAtMoneyness(points, callMoneyness);
let riskReversal25d: number | null = null;
let butterfly25d: number | null = null;
let simpleSkew: number | null = null;
if (putIv !== null && callIv !== null) {
// Risk reversal: put IV - call IV (positive means puts are more expensive)
riskReversal25d = putIv - callIv;
// Butterfly: average of wings minus ATM (positive means smile/wings are elevated)
butterfly25d = (putIv + callIv) / 2 - atmIv;
// Simple skew
simpleSkew = (putIv - callIv) / 2;
}
return {
riskReversal25d,
butterfly25d,
simpleSkew,
};
}
/**
* Find IV at a specific moneyness level (linear interpolation)
*/
function findIvAtMoneyness(points: VolSmilePoint[], targetMoneyness: number): number | null {
if (points.length === 0) return null;
// Find bracketing points
let below: VolSmilePoint | null = null;
let above: VolSmilePoint | null = null;
for (const p of points) {
if (p.moneyness <= targetMoneyness) {
if (!below || p.moneyness > below.moneyness) below = p;
}
if (p.moneyness >= targetMoneyness) {
if (!above || p.moneyness < above.moneyness) above = p;
}
}
if (below && above && below !== above) {
const w = (targetMoneyness - below.moneyness) / (above.moneyness - below.moneyness);
return below.iv + w * (above.iv - below.iv);
}
return below?.iv ?? above?.iv ?? null;
}
/**
* Compute vol smiles for all expirations in a chain
*/
export function computeVolSmiles(
chain: OptionsChain,
options?: Omit<ComputeVolSmileParams, "chain" | "expiration">
): VolSmile[] {
const smiles: VolSmile[] = [];
for (const exp of chain.expirations) {
try {
const smile = computeVolSmile({
chain,
expiration: exp,
...options,
});
smiles.push(smile);
} catch {
// Skip expirations with insufficient data
continue;
}
}
return smiles;
}