/**
* MCP Tool: get_vol_surface
*
* Compute volatility surface (IV vs Strike vs Maturity) from options chain.
*/
import { z } from "zod";
import { computeVolSurface, type VolSurface, type QuantError } from "@quant-companion/core";
import { getDefaultProvider } from "../marketData";
import { createError } from "../errors";
export const getVolSurfaceSchema = z.object({
symbol: z.string().describe("Stock/ETF ticker symbol (e.g., AAPL, SPY, NVDA)"),
maxExpirations: z
.number()
.min(1)
.max(20)
.default(8)
.describe("Maximum number of expirations to include (default: 8)"),
minOpenInterest: z
.number()
.min(0)
.default(10)
.describe("Minimum open interest filter (default: 10)"),
maxBidAskSpreadPct: z
.number()
.min(0)
.max(1)
.default(0.5)
.describe("Maximum bid-ask spread as fraction of mid (default: 0.5)"),
});
export type GetVolSurfaceInput = z.infer<typeof getVolSurfaceSchema>;
export interface GetVolSurfaceOutput {
symbol: string;
spot: number;
asOf: string;
/** Term structure: ATM IV by maturity */
termStructure: Array<{
expiration: string;
timeToMaturityYears: number;
atmIv: number;
}>;
/** Surface statistics */
stats: {
minIv: number;
maxIv: number;
avgAtmIv: number;
termSlope: number | null;
};
/** Smiles summary (full points omitted for brevity) */
smiles: Array<{
expiration: string;
timeToMaturityYears: number;
atmIv: number;
skew: {
riskReversal25d: number | null;
butterfly25d: number | null;
};
pointCount: number;
}>;
/** Interpretation */
interpretation: {
termStructure: "contango" | "backwardation" | "flat";
avgSkew: "put" | "call" | "neutral";
regime: "low_vol" | "normal" | "elevated" | "crisis";
};
}
export const getVolSurfaceDefinition = {
name: "get_vol_surface",
description: `Compute volatility surface (IV vs Strike vs Maturity) from options chain.
Returns:
- Term structure (ATM IV across expirations)
- Surface statistics (min/max IV, average ATM IV, term slope)
- Per-expiration smile summaries with skew metrics
- Market interpretation (contango/backwardation, skew direction, vol regime)
Use this to analyze:
- Is vol term structure in contango or backwardation?
- How does skew evolve across maturities?
- What's the overall volatility regime?`,
inputSchema: {
type: "object" as const,
properties: {
symbol: {
type: "string",
description: "Stock/ETF ticker symbol",
},
maxExpirations: {
type: "number",
description: "Maximum expirations to include (default: 8)",
default: 8,
},
minOpenInterest: {
type: "number",
description: "Minimum open interest filter (default: 10)",
default: 10,
},
maxBidAskSpreadPct: {
type: "number",
description: "Maximum bid-ask spread % (default: 0.5)",
default: 0.5,
},
},
required: ["symbol"],
},
};
function interpretSurface(surface: VolSurface): GetVolSurfaceOutput["interpretation"] {
const { stats, smiles } = surface;
// Term structure interpretation
let termStructure: "contango" | "backwardation" | "flat" = "flat";
if (stats.termSlope !== null) {
if (stats.termSlope > 0.05) termStructure = "contango";
else if (stats.termSlope < -0.05) termStructure = "backwardation";
}
// Average skew direction
const skewValues = smiles
.map((s) => s.skew.riskReversal25d)
.filter((v): v is number => v !== null);
let avgSkew: "put" | "call" | "neutral" = "neutral";
if (skewValues.length > 0) {
const avgRR = skewValues.reduce((a, b) => a + b, 0) / skewValues.length;
if (avgRR > 0.02) avgSkew = "put";
else if (avgRR < -0.02) avgSkew = "call";
}
// Vol regime
let regime: "low_vol" | "normal" | "elevated" | "crisis" = "normal";
if (stats.avgAtmIv < 0.15) regime = "low_vol";
else if (stats.avgAtmIv > 0.40) regime = "crisis";
else if (stats.avgAtmIv > 0.25) regime = "elevated";
return { termStructure, avgSkew, regime };
}
export async function getVolSurface(
input: GetVolSurfaceInput
): Promise<GetVolSurfaceOutput | { error: QuantError }> {
const provider = getDefaultProvider();
try {
const chain = await provider.getOptionsChain({
symbol: input.symbol.toUpperCase(),
});
const surface = computeVolSurface({
chain,
maxExpirations: input.maxExpirations,
minOpenInterest: input.minOpenInterest,
maxBidAskSpreadPct: input.maxBidAskSpreadPct,
});
const interpretation = interpretSurface(surface);
return {
symbol: surface.symbol,
spot: surface.spot,
asOf: surface.asOf,
termStructure: surface.termStructure,
stats: surface.stats,
smiles: surface.smiles.map((s) => ({
expiration: s.expiration,
timeToMaturityYears: s.timeToMaturityYears,
atmIv: s.atmIv,
skew: {
riskReversal25d: s.skew.riskReversal25d,
butterfly25d: s.skew.butterfly25d,
},
pointCount: s.points.length,
})),
interpretation,
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { error: createError("DATA_NOT_FOUND", `Failed to compute vol surface: ${message}`) };
}
}