/**
* Volatility surface analytics
*/
import type { OptionsChain, VolSurface, VolSurfacePoint, VolSmile } from "./types";
import { computeVolSmile, type ComputeVolSmileParams } from "./volSmile";
export interface ComputeVolSurfaceParams {
/** Options chain with multiple expirations */
chain: OptionsChain;
/** Minimum open interest filter (default: 10) */
minOpenInterest?: number;
/** Maximum bid-ask spread % (default: 0.5) */
maxBidAskSpreadPct?: number;
/** Use OTM only (default: true) */
useOtmOnly?: boolean;
/** Maximum number of expirations to include (default: all) */
maxExpirations?: number;
}
/**
* Compute full volatility surface from options chain
*/
export function computeVolSurface(params: ComputeVolSurfaceParams): VolSurface {
const {
chain,
minOpenInterest = 10,
maxBidAskSpreadPct = 0.5,
useOtmOnly = true,
maxExpirations,
} = params;
const spot = chain.underlyingPrice;
const expirations = maxExpirations
? chain.expirations.slice(0, maxExpirations)
: chain.expirations;
// Compute smile for each expiration
const smiles: VolSmile[] = [];
const allPoints: VolSurfacePoint[] = [];
const termStructure: VolSurface["termStructure"] = [];
for (const exp of expirations) {
try {
const smile = computeVolSmile({
chain,
expiration: exp,
minOpenInterest,
maxBidAskSpreadPct,
useOtmOnly,
});
smiles.push(smile);
// Add to term structure
termStructure.push({
expiration: exp,
timeToMaturityYears: smile.timeToMaturityYears,
atmIv: smile.atmIv,
});
// Convert smile points to surface points
for (const p of smile.points) {
allPoints.push({
strike: p.strike,
moneyness: p.moneyness,
timeToMaturityYears: smile.timeToMaturityYears,
expiration: exp,
iv: p.iv,
});
}
} catch {
// Skip expirations with insufficient data
continue;
}
}
if (smiles.length === 0) {
throw new Error("No valid smiles could be computed from the options chain");
}
// Compute stats
const ivValues = allPoints.map((p) => p.iv);
const minIv = Math.min(...ivValues);
const maxIv = Math.max(...ivValues);
const avgAtmIv = termStructure.reduce((sum, t) => sum + t.atmIv, 0) / termStructure.length;
// Term structure slope: (long dated ATM IV - short dated ATM IV) / time diff
let termSlope: number | null = null;
if (termStructure.length >= 2) {
const first = termStructure[0];
const last = termStructure[termStructure.length - 1];
const timeDiff = last.timeToMaturityYears - first.timeToMaturityYears;
if (timeDiff > 0) {
termSlope = (last.atmIv - first.atmIv) / timeDiff;
}
}
return {
symbol: chain.symbol,
spot,
asOf: chain.asOf,
points: allPoints,
smiles,
termStructure,
stats: {
minIv,
maxIv,
avgAtmIv,
termSlope,
},
};
}
/**
* Interpolate IV at a specific (strike, maturity) point on the surface
*/
export function interpolateSurfaceIv(
surface: VolSurface,
strike: number,
timeToMaturity: number
): number | null {
const { smiles, spot } = surface;
if (smiles.length === 0) return null;
// Find bracketing expirations
const sortedSmiles = [...smiles].sort(
(a, b) => a.timeToMaturityYears - b.timeToMaturityYears
);
let below: VolSmile | null = null;
let above: VolSmile | null = null;
for (const smile of sortedSmiles) {
if (smile.timeToMaturityYears <= timeToMaturity) {
below = smile;
}
if (smile.timeToMaturityYears >= timeToMaturity && !above) {
above = smile;
}
}
// Interpolate IV at strike for each bracketing smile
const moneyness = strike / spot;
const ivBelow = below ? interpolateSmileIv(below, moneyness) : null;
const ivAbove = above ? interpolateSmileIv(above, moneyness) : null;
if (ivBelow !== null && ivAbove !== null && below && above && below !== above) {
// Linear interpolation in time
const w =
(timeToMaturity - below.timeToMaturityYears) /
(above.timeToMaturityYears - below.timeToMaturityYears);
return ivBelow + w * (ivAbove - ivBelow);
}
return ivBelow ?? ivAbove;
}
/**
* Interpolate IV at a specific moneyness on a smile
*/
function interpolateSmileIv(smile: VolSmile, targetMoneyness: number): number | null {
const { points } = smile;
if (points.length === 0) return null;
// Find bracketing points
let below: typeof points[0] | null = null;
let above: typeof points[0] | 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;
}
/**
* Get a slice of the surface at a fixed maturity
*/
export function getMaturitySlice(
surface: VolSurface,
expiration: string
): VolSmile | null {
return surface.smiles.find((s) => s.expiration === expiration) ?? null;
}
/**
* Get a slice of the surface at a fixed strike (term structure at that strike)
*/
export function getStrikeSlice(
surface: VolSurface,
strike: number
): Array<{ expiration: string; timeToMaturityYears: number; iv: number }> {
const result: Array<{ expiration: string; timeToMaturityYears: number; iv: number }> = [];
for (const smile of surface.smiles) {
const iv = interpolateSmileIv(smile, strike / surface.spot);
if (iv !== null) {
result.push({
expiration: smile.expiration,
timeToMaturityYears: smile.timeToMaturityYears,
iv,
});
}
}
return result.sort((a, b) => a.timeToMaturityYears - b.timeToMaturityYears);
}