/**
* MCP Tool: simulate_price_with_local_vol
*
* Monte Carlo simulation using local volatility from the vol surface.
* Captures skew and term structure effects for more realistic tail risk.
*/
import { z } from "zod";
import {
simulateWithLocalVol,
computeVolSurface,
type QuantError,
} from "@quant-companion/core";
import { getDefaultProvider } from "../marketData";
import { createError, LIMITS } from "../errors";
export const simulateWithLocalVolSchema = z.object({
symbol: z.string().describe("Stock/ETF ticker symbol (e.g., AAPL, SPY, NVDA)"),
timeHorizonYears: z
.number()
.min(0.01)
.max(2)
.describe("Time horizon in years (e.g., 0.25 for 3 months)"),
rate: z
.number()
.default(0.05)
.describe("Risk-free rate (annualized decimal, default: 0.05)"),
dividendYield: z
.number()
.default(0)
.describe("Dividend yield (annualized decimal, default: 0)"),
paths: z
.number()
.min(1000)
.max(LIMITS.MAX_PATHS)
.default(LIMITS.DEFAULT_PATHS)
.describe(`Number of simulation paths (default: ${LIMITS.DEFAULT_PATHS}, max: ${LIMITS.MAX_PATHS})`),
steps: z
.number()
.min(10)
.max(252)
.default(50)
.describe("Number of time steps (default: 50)"),
targetPrice: z
.number()
.optional()
.describe("Optional target price to calculate probability of reaching"),
});
export type SimulateWithLocalVolInput = z.infer<typeof simulateWithLocalVolSchema>;
export interface SimulateWithLocalVolOutput {
symbol: string;
spot: number;
timeHorizonYears: number;
distribution: {
mean: number;
median: number;
stdDev: number;
percentile5: number;
percentile25: number;
percentile75: number;
percentile95: number;
min: number;
max: number;
};
targetProbability?: number;
riskMetrics: {
var95: number;
var99: number;
expectedShortfall95: number;
};
avgLocalVol: number;
params: {
paths: number;
steps: number;
};
interpretation: {
expectedReturn: string;
tailRisk: "low" | "moderate" | "high" | "extreme";
distributionShape: "symmetric" | "left_skewed" | "right_skewed";
};
}
export const simulateWithLocalVolDefinition = {
name: "simulate_price_with_local_vol",
description: `Run Monte Carlo simulation using local volatility from the vol surface.
Unlike constant-vol GBM, this captures:
- Volatility skew (puts more expensive → fatter left tail)
- Term structure (vol changes over time)
- More realistic tail risk estimates
Returns:
- Price distribution statistics
- VaR and Expected Shortfall
- Target price probability (if specified)
- Distribution shape interpretation
Use this for:
- Realistic tail risk analysis
- Skew-adjusted probability estimates
- Stress testing with market-implied dynamics`,
inputSchema: {
type: "object" as const,
properties: {
symbol: { type: "string", description: "Stock/ETF ticker symbol" },
timeHorizonYears: {
type: "number",
description: "Time horizon in years (e.g., 0.25 for 3 months)",
},
rate: {
type: "number",
description: "Risk-free rate (default: 0.05)",
default: 0.05,
},
dividendYield: {
type: "number",
description: "Dividend yield (default: 0)",
default: 0,
},
paths: {
type: "number",
description: `Simulation paths (default: ${LIMITS.DEFAULT_PATHS})`,
default: LIMITS.DEFAULT_PATHS,
},
steps: {
type: "number",
description: "Time steps (default: 50)",
default: 50,
},
targetPrice: {
type: "number",
description: "Optional target price for probability calculation",
},
},
required: ["symbol", "timeHorizonYears"],
},
};
function interpretResults(
spot: number,
result: Awaited<ReturnType<typeof simulateWithLocalVol>>
): SimulateWithLocalVolOutput["interpretation"] {
const { distribution, riskMetrics } = result;
// Expected return
const expectedReturn = ((distribution.mean - spot) / spot) * 100;
const expectedReturnStr =
expectedReturn >= 0
? `+${expectedReturn.toFixed(1)}%`
: `${expectedReturn.toFixed(1)}%`;
// Tail risk assessment based on ES95
let tailRisk: "low" | "moderate" | "high" | "extreme" = "moderate";
if (riskMetrics.expectedShortfall95 < 0.1) tailRisk = "low";
else if (riskMetrics.expectedShortfall95 > 0.3) tailRisk = "extreme";
else if (riskMetrics.expectedShortfall95 > 0.2) tailRisk = "high";
// Distribution shape based on mean vs median
let distributionShape: "symmetric" | "left_skewed" | "right_skewed" = "symmetric";
const skewRatio = distribution.mean / distribution.median;
if (skewRatio < 0.98) distributionShape = "left_skewed";
else if (skewRatio > 1.02) distributionShape = "right_skewed";
return {
expectedReturn: expectedReturnStr,
tailRisk,
distributionShape,
};
}
export async function simulateWithLocalVolTool(
input: SimulateWithLocalVolInput
): Promise<SimulateWithLocalVolOutput | { error: QuantError }> {
const provider = getDefaultProvider();
try {
// Fetch options chain
const chain = await provider.getOptionsChain({
symbol: input.symbol.toUpperCase(),
});
// Compute vol surface
const surface = computeVolSurface({
chain,
maxExpirations: 8,
minOpenInterest: 10,
});
// Run local vol simulation
const result = simulateWithLocalVol({
spot: chain.underlyingPrice,
rate: input.rate,
dividendYield: input.dividendYield,
timeHorizonYears: input.timeHorizonYears,
surface,
paths: input.paths,
steps: input.steps,
targetPrice: input.targetPrice,
});
const interpretation = interpretResults(chain.underlyingPrice, result);
return {
symbol: input.symbol.toUpperCase(),
spot: chain.underlyingPrice,
timeHorizonYears: input.timeHorizonYears,
distribution: result.distribution,
targetProbability: result.targetProbability,
riskMetrics: result.riskMetrics,
avgLocalVol: result.avgLocalVol,
params: {
paths: result.params.paths,
steps: result.params.steps,
},
interpretation,
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return {
error: createError("COMPUTATION_ERROR", `Local vol simulation failed: ${message}`),
};
}
}