/**
* MCP Tool: run_backtest
*
* Run a backtest with a configurable strategy.
*/
import { z } from "zod";
import {
runBacktest as coreRunBacktest,
createMACrossoverSignal,
createMomentumSignal,
createMeanReversionSignal,
createDualMomentumSignal,
createVolFilteredTrendSignal,
createAdaptiveMomentumSignal,
createMomentumPlusSignal,
computeBacktestStats,
extractDetailedTrades,
runMultiAssetBacktest,
Candle,
BacktestConfig,
RiskMetrics,
DetailedTradeRecord,
RegimeStats,
MultiAssetBacktestResult,
} from "@quant-companion/core";
import { getDefaultProvider } from "../marketData";
const strategySchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("ma_crossover"),
fastWindow: z.number().int().positive().describe("Fast moving average period"),
slowWindow: z.number().int().positive().describe("Slow moving average period"),
}),
z.object({
type: z.literal("momentum"),
lookback: z.number().int().positive().describe("Lookback period for momentum"),
threshold: z
.number()
.optional()
.default(0)
.describe("Minimum return threshold to go long"),
}),
z.object({
type: z.literal("mean_reversion"),
period: z.number().int().positive().optional().default(14).describe("RSI period"),
oversoldThreshold: z
.number()
.optional()
.default(30)
.describe("RSI level to enter long"),
overboughtThreshold: z
.number()
.optional()
.default(70)
.describe("RSI level to exit"),
}),
z.object({
type: z.literal("dual_momentum"),
lookbackDays: z
.number()
.int()
.positive()
.optional()
.default(252)
.describe("Momentum lookback in trading days (default 252 = ~12 months)"),
}),
z.object({
type: z.literal("vol_filtered_trend"),
fastWindow: z.number().int().positive().optional().default(20).describe("Fast MA period"),
slowWindow: z.number().int().positive().optional().default(50).describe("Slow MA period"),
volLookback: z.number().int().positive().optional().default(20).describe("Volatility lookback"),
maxVolThreshold: z
.number()
.positive()
.optional()
.default(0.25)
.describe("Max annualized vol to enter (default 0.25 = 25%)"),
}),
z.object({
type: z.literal("adaptive_momentum"),
shortLookback: z.number().int().positive().optional().default(60).describe("Short lookback for high vol"),
longLookback: z.number().int().positive().optional().default(252).describe("Long lookback for low vol"),
volThreshold: z
.number()
.positive()
.optional()
.default(0.20)
.describe("Vol threshold to switch lookback (default 0.20 = 20%)"),
}),
z.object({
type: z.literal("momentum_plus"),
longLookback: z.number().int().positive().optional().default(252).describe("12-month lookback (default 252)"),
shortLookback: z.number().int().positive().optional().default(63).describe("3-month lookback (default 63)"),
entryThreshold: z.number().optional().default(0).describe("Min momentum to enter (default 0)"),
exitThreshold: z.number().optional().default(-0.05).describe("Exit when 12mo < this (default -5%)"),
shortExitThreshold: z.number().optional().default(-0.10).describe("Exit when 3mo < this (default -10%)"),
maxVolThreshold: z.number().positive().optional().default(0.30).describe("Max vol to enter (default 30%)"),
}),
z.object({
type: z.literal("momentum_plus_multi"),
}),
]);
export const runBacktestSchema = z.object({
symbol: z.string().optional().describe("Stock/ETF ticker symbol (ignored for momentum_plus_multi)"),
symbols: z.array(z.string()).optional().describe("Multiple symbols for multi-asset strategies"),
start: z.string().describe("Start date in YYYY-MM-DD format"),
end: z.string().describe("End date in YYYY-MM-DD format"),
initialCapital: z
.number()
.positive()
.default(10000)
.describe("Starting capital (default $10,000)"),
strategy: strategySchema.describe("Trading strategy configuration"),
slippageBps: z
.number()
.nonnegative()
.optional()
.default(10)
.describe("Slippage in basis points (default 10 = 0.1%)"),
feePerTrade: z
.number()
.nonnegative()
.optional()
.default(0)
.describe("Fixed fee per trade (default 0)"),
});
export type RunBacktestInput = z.infer<typeof runBacktestSchema>;
export interface RunBacktestOutput {
symbol?: string;
symbols?: string[];
period: {
start: string;
end: string;
tradingDays: number;
};
initialCapital: number;
finalCapital: number;
strategy: RunBacktestInput["strategy"];
riskMetrics: RiskMetrics & {
totalReturnPercent: number;
annualizedReturnPercent: number;
annualizedVolPercent: number;
maxDrawdownPercent: number;
};
tradeStats?: {
totalTrades: number;
winningTrades: number;
losingTrades: number;
winRate: number;
avgWin: number;
avgLoss: number;
profitFactor: number;
};
/** Regime statistics (for multi-asset strategies) */
regimeStats?: RegimeStats;
/** Detailed trade log with entry/exit dates, prices, and P&L */
trades?: DetailedTradeRecord[];
equityCurveSample: number[];
timestampsSample: string[];
}
export async function runBacktest(
input: RunBacktestInput
): Promise<RunBacktestOutput> {
const provider = getDefaultProvider();
const start = new Date(input.start);
const end = new Date(input.end);
if (isNaN(start.getTime())) {
throw new Error(`Invalid start date: ${input.start}`);
}
if (isNaN(end.getTime())) {
throw new Error(`Invalid end date: ${input.end}`);
}
// Handle multi-asset strategy
if (input.strategy.type === "momentum_plus_multi") {
return runMultiAssetBacktestHandler(input, provider, start, end);
}
// Single-asset strategy (existing behavior)
const symbol = input.symbol;
if (!symbol) {
throw new Error("symbol is required for single-asset strategies");
}
// Fetch historical data
const ohlcv = await provider.getHistoricalOHLCV({
symbol,
start,
end,
interval: "1d",
});
if (ohlcv.length < 2) {
throw new Error(`Not enough data for ${symbol} in the specified period`);
}
// Convert to Candle format
const candles: Candle[] = ohlcv.map((c) => ({
timestamp: c.timestamp,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
volume: c.volume,
}));
// Create signal generator based on strategy
let generateSignal: BacktestConfig["generateSignal"];
switch (input.strategy.type) {
case "ma_crossover":
generateSignal = createMACrossoverSignal(
input.strategy.fastWindow,
input.strategy.slowWindow
);
break;
case "momentum":
generateSignal = createMomentumSignal(
input.strategy.lookback,
input.strategy.threshold
);
break;
case "mean_reversion":
generateSignal = createMeanReversionSignal(
input.strategy.period,
input.strategy.oversoldThreshold,
input.strategy.overboughtThreshold
);
break;
case "dual_momentum":
generateSignal = createDualMomentumSignal(
input.strategy.lookbackDays
);
break;
case "vol_filtered_trend":
generateSignal = createVolFilteredTrendSignal(
input.strategy.fastWindow,
input.strategy.slowWindow,
input.strategy.volLookback,
input.strategy.maxVolThreshold
);
break;
case "adaptive_momentum":
generateSignal = createAdaptiveMomentumSignal(
input.strategy.shortLookback,
input.strategy.longLookback,
input.strategy.volThreshold
);
break;
case "momentum_plus":
generateSignal = createMomentumPlusSignal(
input.strategy.longLookback,
input.strategy.shortLookback,
input.strategy.entryThreshold,
input.strategy.exitThreshold,
input.strategy.shortExitThreshold,
input.strategy.maxVolThreshold
);
break;
}
// Run backtest
const result = coreRunBacktest(candles, {
initialCapital: input.initialCapital,
generateSignal,
slippageBps: input.slippageBps,
feePerTrade: input.feePerTrade,
});
// Compute trade statistics
const tradeStats = computeBacktestStats(result);
// Extract detailed trade log with dates and symbol
const trades = extractDetailedTrades(result, candles, symbol);
// Sample equity curve for output (max 100 points)
const { equityCurveSample, timestampsSample } = sampleEquityCurve(
result.equityCurve,
result.timestamps
);
return {
symbol,
period: {
start: input.start,
end: input.end,
tradingDays: candles.length,
},
initialCapital: input.initialCapital,
finalCapital: result.equityCurve[result.equityCurve.length - 1],
strategy: input.strategy,
riskMetrics: {
...result.riskMetrics,
totalReturnPercent: result.riskMetrics.totalReturn * 100,
annualizedReturnPercent: result.riskMetrics.annualizedReturn * 100,
annualizedVolPercent: result.riskMetrics.annualizedVol * 100,
maxDrawdownPercent: result.riskMetrics.maxDrawdown * 100,
},
tradeStats,
trades,
equityCurveSample,
timestampsSample,
};
}
/**
* Sample equity curve for output (max 100 points)
*/
function sampleEquityCurve(
equityCurve: number[],
timestamps: number[]
): { equityCurveSample: number[]; timestampsSample: string[] } {
const sampleSize = Math.min(100, equityCurve.length);
const step = Math.max(1, Math.floor(equityCurve.length / sampleSize));
const equityCurveSample: number[] = [];
const timestampsSample: string[] = [];
for (let i = 0; i < equityCurve.length; i += step) {
equityCurveSample.push(equityCurve[i]);
timestampsSample.push(new Date(timestamps[i]).toISOString().split("T")[0]);
}
// Always include the last point
if (
equityCurveSample[equityCurveSample.length - 1] !==
equityCurve[equityCurve.length - 1]
) {
equityCurveSample.push(equityCurve[equityCurve.length - 1]);
timestampsSample.push(
new Date(timestamps[timestamps.length - 1]).toISOString().split("T")[0]
);
}
return { equityCurveSample, timestampsSample };
}
/**
* Handler for multi-asset backtest
*/
async function runMultiAssetBacktestHandler(
input: RunBacktestInput,
provider: ReturnType<typeof getDefaultProvider>,
start: Date,
end: Date
): Promise<RunBacktestOutput> {
const symbols = ["SPY", "QQQ", "TLT", "GLD"];
// Fetch data for all symbols in parallel
const ohlcvResults = await Promise.all(
symbols.map((symbol) =>
provider.getHistoricalOHLCV({ symbol, start, end, interval: "1d" })
)
);
// Build candles map
const candlesBySymbol = new Map<string, Candle[]>();
for (let i = 0; i < symbols.length; i++) {
const symbol = symbols[i];
const ohlcv = ohlcvResults[i];
if (ohlcv.length < 253) {
throw new Error(
`Not enough data for ${symbol}: need at least 253 days, got ${ohlcv.length}`
);
}
candlesBySymbol.set(
symbol,
ohlcv.map((c) => ({
timestamp: c.timestamp,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
volume: c.volume,
}))
);
}
// Run multi-asset backtest
const result = runMultiAssetBacktest(candlesBySymbol, {
symbols,
initialCapital: input.initialCapital,
slippageBps: input.slippageBps,
feePerTrade: input.feePerTrade,
});
// Sample equity curve
const { equityCurveSample, timestampsSample } = sampleEquityCurve(
result.equityCurve,
result.timestamps
);
return {
symbols: result.symbols,
period: {
start: input.start,
end: input.end,
tradingDays: result.equityCurve.length,
},
initialCapital: input.initialCapital,
finalCapital: result.equityCurve[result.equityCurve.length - 1],
strategy: input.strategy,
riskMetrics: {
...result.riskMetrics,
totalReturnPercent: result.riskMetrics.totalReturn * 100,
annualizedReturnPercent: result.riskMetrics.annualizedReturn * 100,
annualizedVolPercent: result.riskMetrics.annualizedVol * 100,
maxDrawdownPercent: result.riskMetrics.maxDrawdown * 100,
},
regimeStats: result.regimeStats,
equityCurveSample,
timestampsSample,
};
}
export const runBacktestDefinition = {
name: "run_backtest",
description:
"Run a backtest on historical data with a specified trading strategy. Returns equity curve, risk metrics, trade statistics, and detailed trade log. Strategies: ma_crossover, momentum, mean_reversion, dual_momentum (12-month absolute momentum), vol_filtered_trend (trend following with vol filter), adaptive_momentum (adapts lookback to vol regime), momentum_plus (optimized dual timeframe momentum targeting 12%+ CAGR), momentum_plus_multi (multi-asset regime allocator using SPY/QQQ/TLT/GLD).",
inputSchema: {
type: "object",
properties: {
symbol: {
type: "string",
description: "Stock/ETF ticker symbol (e.g., SPY, QQQ). Not required for momentum_plus_multi.",
},
symbols: {
type: "array",
items: { type: "string" },
description: "Multiple symbols for multi-asset strategies (auto-set for momentum_plus_multi)",
},
start: {
type: "string",
description: "Start date in YYYY-MM-DD format",
},
end: {
type: "string",
description: "End date in YYYY-MM-DD format",
},
initialCapital: {
type: "number",
description: "Starting capital in dollars (default 10000)",
},
strategy: {
type: "object",
description:
"Strategy types: 'ma_crossover' (fastWindow, slowWindow), 'momentum' (lookback, threshold), 'mean_reversion' (period, oversoldThreshold, overboughtThreshold), 'dual_momentum' (lookbackDays=252), 'vol_filtered_trend' (fastWindow, slowWindow, volLookback, maxVolThreshold), 'adaptive_momentum' (shortLookback, longLookback, volThreshold), 'momentum_plus' (optimized: longLookback, shortLookback, entryThreshold, exitThreshold, shortExitThreshold, maxVolThreshold), 'momentum_plus_multi' (multi-asset regime allocator: SPY/QQQ/TLT/GLD)",
properties: {
type: {
type: "string",
enum: ["ma_crossover", "momentum", "mean_reversion", "dual_momentum", "vol_filtered_trend", "adaptive_momentum", "momentum_plus", "momentum_plus_multi"],
},
fastWindow: { type: "number", description: "Fast MA period" },
slowWindow: { type: "number", description: "Slow MA period" },
lookback: { type: "number", description: "Lookback period (for momentum)" },
lookbackDays: { type: "number", description: "Lookback in days (for dual_momentum, default 252)" },
threshold: { type: "number", description: "Return threshold (for momentum)" },
period: { type: "number", description: "RSI period (for mean_reversion)" },
oversoldThreshold: { type: "number", description: "Oversold RSI level" },
overboughtThreshold: { type: "number", description: "Overbought RSI level" },
volLookback: { type: "number", description: "Vol calculation period (for vol_filtered_trend)" },
maxVolThreshold: { type: "number", description: "Max vol to enter (for vol_filtered_trend, default 0.25)" },
shortLookback: { type: "number", description: "Short lookback for high vol (adaptive_momentum)" },
longLookback: { type: "number", description: "Long lookback for low vol (adaptive_momentum) or 12-month (momentum_plus)" },
volThreshold: { type: "number", description: "Vol regime threshold (adaptive_momentum, default 0.20)" },
entryThreshold: { type: "number", description: "Min momentum to enter (momentum_plus, default 0)" },
exitThreshold: { type: "number", description: "Exit when 12mo below this (momentum_plus, default -0.05)" },
shortExitThreshold: { type: "number", description: "Exit when 3mo below this (momentum_plus, default -0.10)" },
},
required: ["type"],
},
slippageBps: {
type: "number",
description: "Slippage in basis points (default 10 = 0.1%)",
},
feePerTrade: {
type: "number",
description: "Fixed fee per trade in dollars (default 0)",
},
},
required: ["start", "end", "strategy"],
},
};