/**
* MCP Tool: compare_models_forecast_distribution
*
* Compare price distribution forecasts across multiple models:
* GBM, Local Vol, SABR, and Heston.
*/
import { z } from "zod";
import {
simulateTerminalPrices,
simulateWithLocalVol,
computeVolSurface,
computeHistoricalVol,
computeVolSmile,
calibrateSabrSmile,
sampleSabrDistribution,
simulateHestonPaths,
calibrateHestonSurface,
computeDistributionStats,
computeDisagreement,
daysToYears,
type ForecastDistribution,
type DisagreementMetrics,
} from "@quant-companion/core";
import { getDefaultProvider } from "../marketData";
export const compareModelsForecastSchema = z.object({
symbol: z.string().describe("Stock/ETF ticker symbol"),
horizonDays: z.number().int().min(1).max(365).describe("Forecast horizon in days"),
models: z
.array(z.enum(["gbm", "local_vol", "sabr", "heston"]))
.optional()
.default(["gbm", "local_vol", "sabr", "heston"])
.describe("Models to compare (default: all)"),
paths: z
.number()
.int()
.min(1000)
.max(100000)
.optional()
.default(30000)
.describe("Simulation paths per model"),
});
export type CompareModelsForecastInput = z.infer<typeof compareModelsForecastSchema>;
export interface ModelForecast {
name: "gbm" | "local_vol" | "sabr" | "heston";
available: boolean;
error?: string;
mean?: number;
median?: number;
std?: number;
percentile5?: number;
percentile25?: number;
percentile75?: number;
percentile95?: number;
probUp20?: number;
}
export interface CompareModelsForecastOutput {
symbol: string;
spot: number;
horizonDays: number;
horizonYears: number;
riskFreeRate: number;
historicalVol: number;
models: ModelForecast[];
disagreementIndex: DisagreementMetrics;
summary: string;
}
export const compareModelsForecastDefinition = {
name: "compare_models_forecast_distribution",
description: `Compare price forecasts across multiple stochastic models.
Models available:
- GBM: Standard Geometric Brownian Motion with historical vol
- Local Vol: Uses implied vol surface for skew-adjusted simulation
- SABR: Stochastic alpha-beta-rho model calibrated to near-term smile
- Heston: Stochastic volatility model with mean reversion
Returns:
- Distribution stats per model (mean, median, percentiles)
- Probability of +20% move for each model
- Model disagreement index (LOW/MEDIUM/HIGH)
Use this to understand model uncertainty and tail risk differences.`,
inputSchema: {
type: "object" as const,
properties: {
symbol: { type: "string", description: "Stock/ETF ticker symbol" },
horizonDays: { type: "number", description: "Forecast horizon in days (1-365)" },
models: {
type: "array",
items: { type: "string", enum: ["gbm", "local_vol", "sabr", "heston"] },
description: "Models to compare (default: all)",
},
paths: { type: "number", description: "Simulation paths (default: 30000)" },
},
required: ["symbol", "horizonDays"],
},
};
export async function compareModelsForecast(
input: CompareModelsForecastInput
): Promise<CompareModelsForecastOutput> {
const provider = getDefaultProvider();
const symbol = input.symbol.toUpperCase();
const horizonYears = daysToYears(input.horizonDays);
const paths = input.paths;
// Fetch market data
const quote = await provider.getQuote(symbol);
const spot = quote.price;
// Get rate and dividend yield
const rateCurve = provider.getRateCurve ? await provider.getRateCurve() : [{ maturityYears: 1, rate: 0.045 }];
const rate = rateCurve[0]?.rate ?? 0.045;
const dividendYield = provider.getDividendYield ? await provider.getDividendYield(symbol) : 0;
// Get historical volatility
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 60);
const historical = await provider.getHistoricalOHLCV({ symbol, start, end, interval: "1d" });
const closes = historical.map((c) => c.close);
const hvResult = computeHistoricalVol(closes, 30);
const historicalVol = hvResult.volatility;
const targetPrice = spot * 1.2; // +20% target
const models: ModelForecast[] = [];
// GBM Model
if (input.models.includes("gbm")) {
try {
const terminalPrices = simulateTerminalPrices({
spot,
rate,
vol: historicalVol,
timeToMaturity: horizonYears,
dividendYield,
paths,
});
const stats = computeDistributionStats(terminalPrices, targetPrice);
models.push({
name: "gbm",
available: true,
mean: stats.mean,
median: stats.median,
std: stats.std,
percentile5: stats.percentile5,
percentile25: stats.percentile25,
percentile75: stats.percentile75,
percentile95: stats.percentile95,
probUp20: stats.probAboveTarget,
});
} catch (e) {
models.push({
name: "gbm",
available: false,
error: e instanceof Error ? e.message : "Unknown error",
});
}
}
// Local Vol Model
if (input.models.includes("local_vol")) {
try {
const chain = await provider.getOptionsChain({ symbol });
const surface = computeVolSurface({ chain, maxExpirations: 6 });
const lvResult = simulateWithLocalVol({
spot,
rate,
dividendYield,
timeHorizonYears: horizonYears,
surface,
paths,
steps: 50,
targetPrice,
});
models.push({
name: "local_vol",
available: true,
mean: lvResult.distribution.mean,
median: lvResult.distribution.median,
std: lvResult.distribution.stdDev,
percentile5: lvResult.distribution.percentile5,
percentile25: lvResult.distribution.percentile25,
percentile75: lvResult.distribution.percentile75,
percentile95: lvResult.distribution.percentile95,
probUp20: lvResult.targetProbability,
});
} catch (e) {
models.push({
name: "local_vol",
available: false,
error: e instanceof Error ? e.message : "Unknown error",
});
}
}
// SABR Model
if (input.models.includes("sabr")) {
try {
const chain = await provider.getOptionsChain({ symbol });
// Get the nearest expiration smile
const smile = computeVolSmile({ chain, minOpenInterest: 5 });
// Calibrate SABR to the smile
const forward = spot * Math.exp((rate - dividendYield) * smile.timeToMaturityYears);
const strikes = smile.points.map((p) => p.strike);
const marketIvs = smile.points.map((p) => p.iv);
if (strikes.length < 3) {
throw new Error("Insufficient strikes for SABR calibration");
}
const sabrParams = calibrateSabrSmile({
forward,
timeToMaturityYears: horizonYears,
strikes,
marketIvs,
beta: 0.5, // Use backbone model
});
// Sample from SABR distribution
const terminalPrices = sampleSabrDistribution(
{
forward,
timeToMaturityYears: horizonYears,
alpha: sabrParams.alpha,
beta: sabrParams.beta,
rho: sabrParams.rho,
nu: sabrParams.nu,
},
rate,
paths
);
const stats = computeDistributionStats(terminalPrices, targetPrice);
models.push({
name: "sabr",
available: true,
mean: stats.mean,
median: stats.median,
std: stats.std,
percentile5: stats.percentile5,
percentile25: stats.percentile25,
percentile75: stats.percentile75,
percentile95: stats.percentile95,
probUp20: stats.probAboveTarget,
});
} catch (e) {
models.push({
name: "sabr",
available: false,
error: e instanceof Error ? e.message : "Unknown error",
});
}
}
// Heston Model
if (input.models.includes("heston")) {
try {
// Use simplified Heston with estimated parameters
// In production, would calibrate to vol surface
const v0 = historicalVol * historicalVol;
const theta = v0; // Long-term variance = current variance
const kappa = 2.0; // Mean reversion speed
const xi = 0.4; // Vol of vol
const rho = -0.7; // Typical negative correlation
const hestonResult = simulateHestonPaths({
spot,
rate,
kappa,
theta,
xi,
rho,
v0,
timeToMaturityYears: horizonYears,
steps: 50,
paths,
dividendYield,
});
const stats = computeDistributionStats(hestonResult.terminalPrices, targetPrice);
models.push({
name: "heston",
available: true,
mean: stats.mean,
median: stats.median,
std: stats.std,
percentile5: stats.percentile5,
percentile25: stats.percentile25,
percentile75: stats.percentile75,
percentile95: stats.percentile95,
probUp20: stats.probAboveTarget,
});
} catch (e) {
models.push({
name: "heston",
available: false,
error: e instanceof Error ? e.message : "Unknown error",
});
}
}
// Compute disagreement across available models
const availableForecasts = models
.filter((m) => m.available && m.median !== undefined)
.map((m) => ({
median: m.median!,
percentile95: m.percentile95!,
probUp20: m.probUp20!,
}));
const disagreementIndex = computeDisagreement(availableForecasts);
// Generate summary
const availableCount = models.filter((m) => m.available).length;
const medians = models.filter((m) => m.available).map((m) => m.median!);
const avgMedian = medians.length > 0 ? medians.reduce((a, b) => a + b, 0) / medians.length : 0;
const probs = models.filter((m) => m.available).map((m) => m.probUp20!);
const avgProbUp20 = probs.length > 0 ? probs.reduce((a, b) => a + b, 0) / probs.length : 0;
const summary = `${availableCount}/${input.models.length} models computed. ` +
`Average median forecast: $${avgMedian.toFixed(2)} (${((avgMedian / spot - 1) * 100).toFixed(1)}%). ` +
`Average prob of +20%: ${(avgProbUp20 * 100).toFixed(1)}%. ` +
`Model disagreement: ${disagreementIndex.level}.`;
return {
symbol,
spot,
horizonDays: input.horizonDays,
horizonYears,
riskFreeRate: rate,
historicalVol,
models,
disagreementIndex,
summary,
};
}