Skip to main content
Glama
Ademscodeisnotsobad

Quant Companion MCP

backtest.ts15.5 kB
/** * Virtual Broker / Backtest Execution Loop * * Simulates strategy execution on historical data. * Pure and deterministic - no external I/O. */ import { StrategyContext, StrategyFn, StrategyBacktestConfig, StrategyBacktestResult, StrategyBacktestMetrics, BacktestSummary, PriceBar, TradeRecord, RiskState, DEFAULT_RISK_LIMITS, } from "./types"; import { createRiskState, updateRiskState, applyRiskManagement, } from "./risk"; import { computeRiskMetrics } from "../risk"; /** * Internal state for backtest execution. */ interface BacktestState { equity: number; position: number; avgEntryPrice: number; cash: number; riskState: RiskState; equityCurve: number[]; dates: Date[]; trades: TradeRecord[]; riskInterventions: number; } /** * Calculate simple moving average from price history. */ export function calculateSMA(prices: number[], period: number): number | undefined { if (prices.length < period) return undefined; const slice = prices.slice(0, period); return slice.reduce((sum, p) => sum + p, 0) / period; } /** * Calculate RSI from price history. */ export function calculateRSI(prices: number[], period: number = 14): number | undefined { if (prices.length < period + 1) return undefined; let gains = 0; let losses = 0; for (let i = 0; i < period; i++) { const change = prices[i] - prices[i + 1]; // prices are newest first if (change > 0) { gains += change; } else { losses += Math.abs(change); } } const avgGain = gains / period; const avgLoss = losses / period; if (avgLoss === 0) return 100; const rs = avgGain / avgLoss; return 100 - (100 / (1 + rs)); } /** * Calculate historical volatility from price history. */ export function calculateHistoricalVol(prices: number[], window: number): number | undefined { if (prices.length < window + 1) return undefined; const returns: number[] = []; for (let i = 0; i < window; i++) { if (prices[i + 1] > 0) { returns.push(Math.log(prices[i] / prices[i + 1])); } } if (returns.length < window) return undefined; const mean = returns.reduce((s, r) => s + r, 0) / returns.length; const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / returns.length; const dailyVol = Math.sqrt(variance); return dailyVol * Math.sqrt(252); // Annualized } /** * Build strategy context from current state and price history. */ function buildContext( state: BacktestState, bar: PriceBar, symbol: string, priceHistory: number[], lookbackBars: number ): StrategyContext { const unrealizedPnl = state.position !== 0 ? (bar.close - state.avgEntryPrice) * state.position : 0; // Limit price history to lookback const limitedHistory = priceHistory.slice(0, lookbackBars); // Calculate analytics const hv20 = calculateHistoricalVol(limitedHistory, 20); const hv60 = calculateHistoricalVol(limitedHistory, 60); const sma20 = calculateSMA(limitedHistory, 20); const sma50 = calculateSMA(limitedHistory, 50); const sma200 = calculateSMA(limitedHistory, 200); const rsi14 = calculateRSI(limitedHistory, 14); // Determine vol regime let volRegime: "LOW" | "NORMAL" | "HIGH" | "SKEW_STRESSED" | undefined; if (hv20 !== undefined) { if (hv20 < 0.15) volRegime = "LOW"; else if (hv20 < 0.25) volRegime = "NORMAL"; else if (hv20 < 0.40) volRegime = "HIGH"; else volRegime = "SKEW_STRESSED"; } return { date: bar.date, symbol, price: bar.close, equity: state.equity, position: state.position, avgEntryPrice: state.position !== 0 ? state.avgEntryPrice : undefined, unrealizedPnl: state.position !== 0 ? unrealizedPnl : undefined, priceHistory: limitedHistory, analytics: { volRegime, hv20, hv60, sma20, sma50, sma200, rsi14, }, }; } /** * Execute a trade and update state. */ function executeTrade( state: BacktestState, targetShares: number, bar: PriceBar, symbol: string, reason: string, wasRiskAdjusted: boolean, slippageBps: number, commissionPerShare: number ): TradeRecord | null { if (targetShares === state.position) { return null; // No trade needed } const sharesDelta = targetShares - state.position; const isBuy = sharesDelta > 0; // Apply slippage (worse price for us) const slippageMult = isBuy ? (1 + slippageBps / 10000) : (1 - slippageBps / 10000); const fillPrice = bar.close * slippageMult; // Calculate trade cost const tradeCost = Math.abs(sharesDelta) * fillPrice; const commission = Math.abs(sharesDelta) * commissionPerShare; // Update cash if (isBuy) { state.cash -= tradeCost + commission; } else { state.cash += tradeCost - commission; } // Update average entry price if (targetShares === 0) { // Closing position state.avgEntryPrice = 0; } else if (state.position === 0) { // Opening new position state.avgEntryPrice = fillPrice; } else if (isBuy && state.position > 0) { // Adding to long const totalCost = state.avgEntryPrice * state.position + fillPrice * sharesDelta; state.avgEntryPrice = totalCost / targetShares; } else if (!isBuy && state.position < 0) { // Adding to short const totalCost = state.avgEntryPrice * Math.abs(state.position) + fillPrice * Math.abs(sharesDelta); state.avgEntryPrice = totalCost / Math.abs(targetShares); } // For reducing positions, keep existing avg entry price const trade: TradeRecord = { date: bar.date, symbol, fillPrice, positionBefore: state.position, positionAfter: targetShares, sharesDelta, notional: Math.abs(sharesDelta) * fillPrice, reason, wasRiskAdjusted, }; state.position = targetShares; return trade; } /** * Update equity based on current position and price. */ function updateEquity(state: BacktestState, price: number): void { const positionValue = state.position * price; state.equity = state.cash + positionValue; } /** * Run backtest on historical price data. * * @param strategyName - Name for identification * @param strategy - Strategy function to test * @param bars - Historical price bars (oldest first) * @param config - Backtest configuration * @returns Complete backtest results */ export function runBacktest( strategyName: string, strategy: StrategyFn, bars: PriceBar[], config: StrategyBacktestConfig ): StrategyBacktestResult { if (bars.length < 2) { throw new Error("Need at least 2 bars for backtest"); } const lookbackBars = config.lookbackBars ?? 200; const slippageBps = config.slippageBps ?? 10; const commissionPerShare = config.commissionPerShare ?? 0; const limits = config.riskLimits ?? DEFAULT_RISK_LIMITS; // Initialize state const state: BacktestState = { equity: config.initialEquity, position: 0, avgEntryPrice: 0, cash: config.initialEquity, riskState: createRiskState(config.initialEquity), equityCurve: [config.initialEquity], dates: [bars[0].date], trades: [], riskInterventions: 0, }; // Build price history (newest first for lookback) const priceHistory: number[] = [bars[0].close]; // Walk through bars (starting from second bar) for (let i = 1; i < bars.length; i++) { const bar = bars[i]; const prevBar = bars[i - 1]; // 1. Update equity with new price (mark-to-market) updateEquity(state, bar.close); // 2. Update risk state state.riskState = updateRiskState(state.riskState, state.equity, bar.date); // 3. Update price history (newest first) priceHistory.unshift(bar.close); if (priceHistory.length > lookbackBars + 1) { priceHistory.pop(); } // 4. Build context const context = buildContext(state, bar, config.symbol, priceHistory, lookbackBars); // 5. Get strategy decision const rawDecision = strategy(context); // 6. Apply risk management const adjustedDecision = applyRiskManagement( rawDecision, context, limits, state.riskState ); if (adjustedDecision.wasAdjusted) { state.riskInterventions++; } // 7. Execute trade if needed const trade = executeTrade( state, adjustedDecision.targetPositionShares, bar, config.symbol, adjustedDecision.reason, adjustedDecision.wasAdjusted, slippageBps, commissionPerShare ); if (trade) { state.trades.push(trade); } // 8. Update equity after trade updateEquity(state, bar.close); // 9. Record equity and date state.equityCurve.push(state.equity); state.dates.push(bar.date); } // Compute metrics const metrics = computeBacktestMetrics( state.equityCurve, state.trades, config.initialEquity ); // Build summary const summary = buildBacktestSummary( state, bars, config.initialEquity, state.riskInterventions ); return { symbol: config.symbol, strategyName, equityCurve: state.equityCurve, dates: state.dates, trades: state.trades, metrics, config, summary, }; } /** * Compute backtest metrics from equity curve and trades. */ function computeBacktestMetrics( equityCurve: number[], trades: TradeRecord[], initialEquity: number ): StrategyBacktestMetrics { // Use existing risk metrics const riskMetrics = computeRiskMetrics(equityCurve); // Calculate trade statistics const tradeReturns: number[] = []; let grossProfit = 0; let grossLoss = 0; for (let i = 0; i < trades.length; i++) { const trade = trades[i]; // Calculate return for this trade (simplified - uses fill prices) if (trade.positionBefore !== 0 && trade.positionAfter === 0) { // Closing trade - we can calculate return // Find the opening trade let openingPrice = trade.fillPrice; // fallback for (let j = i - 1; j >= 0; j--) { if (trades[j].positionBefore === 0) { openingPrice = trades[j].fillPrice; break; } } const pnl = (trade.fillPrice - openingPrice) * trade.positionBefore; const tradeReturn = pnl / (openingPrice * Math.abs(trade.positionBefore)); tradeReturns.push(tradeReturn); if (pnl > 0) grossProfit += pnl; else grossLoss += Math.abs(pnl); } } const winningTrades = tradeReturns.filter(r => r > 0); const losingTrades = tradeReturns.filter(r => r < 0); const totalReturn = (equityCurve[equityCurve.length - 1] - initialEquity) / initialEquity; const tradingDays = equityCurve.length; const years = tradingDays / 252; const annualizedReturn = years > 0 ? Math.pow(1 + totalReturn, 1 / years) - 1 : totalReturn; const calmarRatio = riskMetrics.maxDrawdown > 0 ? annualizedReturn / riskMetrics.maxDrawdown : 0; return { totalReturn, annualizedReturn, annualizedVolatility: riskMetrics.annualizedVol, sharpeRatio: riskMetrics.sharpe ?? 0, sortinoRatio: riskMetrics.sortino ?? 0, maxDrawdown: riskMetrics.maxDrawdown, calmarRatio, winRate: tradeReturns.length > 0 ? winningTrades.length / tradeReturns.length : 0, profitFactor: grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0, avgTradeReturn: tradeReturns.length > 0 ? tradeReturns.reduce((s, r) => s + r, 0) / tradeReturns.length : 0, avgWinReturn: winningTrades.length > 0 ? winningTrades.reduce((s, r) => s + r, 0) / winningTrades.length : 0, avgLossReturn: losingTrades.length > 0 ? losingTrades.reduce((s, r) => s + r, 0) / losingTrades.length : 0, }; } /** * Build human-readable backtest summary. */ function buildBacktestSummary( state: BacktestState, bars: PriceBar[], initialEquity: number, riskInterventions: number ): BacktestSummary { const startDate = bars[0].date; const endDate = bars[bars.length - 1].date; const durationDays = Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); // Count winning/losing trades let winningTrades = 0; let losingTrades = 0; let totalDurationMs = 0; let completedTrades = 0; for (let i = 0; i < state.trades.length; i++) { const trade = state.trades[i]; if (trade.positionBefore !== 0 && trade.positionAfter === 0) { // Closing trade completedTrades++; // Find opening trade for (let j = i - 1; j >= 0; j--) { if (state.trades[j].positionBefore === 0) { const pnl = (trade.fillPrice - state.trades[j].fillPrice) * trade.positionBefore; if (pnl > 0) winningTrades++; else if (pnl < 0) losingTrades++; totalDurationMs += trade.date.getTime() - state.trades[j].date.getTime(); break; } } } } const avgTradeDurationDays = completedTrades > 0 ? totalDurationMs / completedTrades / (1000 * 60 * 60 * 24) : 0; return { startDate, endDate, durationDays, startingEquity: initialEquity, endingEquity: state.equity, totalTrades: state.trades.length, winningTrades, losingTrades, avgTradeDurationDays, riskInterventions, }; } /** * Print a formatted backtest summary to console. */ export function printBacktestSummary(result: StrategyBacktestResult): string { const { metrics, summary } = result; const lines = [ `\n========== BACKTEST RESULTS: ${result.strategyName} ==========`, `Symbol: ${result.symbol}`, `Period: ${summary.startDate.toISOString().split("T")[0]} to ${summary.endDate.toISOString().split("T")[0]} (${summary.durationDays} days)`, ``, `--- RETURNS ---`, `Starting Equity: $${summary.startingEquity.toLocaleString()}`, `Ending Equity: $${summary.endingEquity.toLocaleString()}`, `Total Return: ${(metrics.totalReturn * 100).toFixed(2)}%`, `Annualized Return: ${(metrics.annualizedReturn * 100).toFixed(2)}%`, ``, `--- RISK ---`, `Max Drawdown: ${(metrics.maxDrawdown * 100).toFixed(2)}%`, `Sharpe Ratio: ${metrics.sharpeRatio.toFixed(2)}`, `Sortino Ratio: ${metrics.sortinoRatio.toFixed(2)}`, `Calmar Ratio: ${metrics.calmarRatio.toFixed(2)}`, `Annualized Vol: ${(metrics.annualizedVolatility * 100).toFixed(2)}%`, ``, `--- TRADES ---`, `Total Trades: ${summary.totalTrades}`, `Winning: ${summary.winningTrades}`, `Losing: ${summary.losingTrades}`, `Win Rate: ${(metrics.winRate * 100).toFixed(1)}%`, `Profit Factor: ${metrics.profitFactor === Infinity ? "∞" : metrics.profitFactor.toFixed(2)}`, `Avg Trade Return: ${(metrics.avgTradeReturn * 100).toFixed(2)}%`, `Avg Trade Duration: ${summary.avgTradeDurationDays.toFixed(1)} days`, ``, `--- RISK MANAGEMENT ---`, `Risk Interventions: ${summary.riskInterventions}`, `================================================\n`, ]; return lines.join("\n"); } /** * Convert OHLCV data to PriceBar format. */ export function ohlcvToPriceBars( data: Array<{ timestamp: number; open: number; high: number; low: number; close: number; volume?: number }> ): PriceBar[] { return data.map(d => ({ date: new Date(d.timestamp), open: d.open, high: d.high, low: d.low, close: d.close, volume: d.volume, })); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Ademscodeisnotsobad/Quant-Companion-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server