Skip to main content
Glama
Ademscodeisnotsobad

Quant Companion MCP

run-multi-backtest.ts14.7 kB
/** * Script to run momentum_plus_multi backtest and generate report * Run with: npx tsx scripts/run-multi-backtest.ts */ import { runMultiAssetBacktest, runBacktest, createMomentumPlusSignal, Candle, computeRiskMetrics } from "@quant-companion/core"; import * as fs from "fs"; import * as path from "path"; // Yahoo Finance API (simple fetch) async function fetchOHLCV(symbol: string, start: Date, end: Date): Promise<Candle[]> { const startTs = Math.floor(start.getTime() / 1000); const endTs = Math.floor(end.getTime() / 1000); const url = `https://query1.finance.yahoo.com/v8/finance/chart/${symbol}?period1=${startTs}&period2=${endTs}&interval=1d`; const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", }, }); if (!res.ok) { throw new Error(`Failed to fetch ${symbol}: ${res.status}`); } const data = await res.json(); const result = data.chart.result[0]; const timestamps = result.timestamp; const quotes = result.indicators.quote[0]; const candles: Candle[] = []; for (let i = 0; i < timestamps.length; i++) { if (quotes.close[i] != null) { candles.push({ timestamp: timestamps[i] * 1000, open: quotes.open[i], high: quotes.high[i], low: quotes.low[i], close: quotes.close[i], volume: quotes.volume[i], }); } } return candles; } async function main() { const startDate = new Date("2015-01-01"); const endDate = new Date("2025-12-01"); const initialCapital = 10000; const slippageBps = 10; console.log("Fetching data for SPY, QQQ, TLT, GLD..."); const symbols = ["SPY", "QQQ", "TLT", "GLD"]; const candlesBySymbol = new Map<string, Candle[]>(); for (const symbol of symbols) { console.log(` Fetching ${symbol}...`); const candles = await fetchOHLCV(symbol, startDate, endDate); candlesBySymbol.set(symbol, candles); console.log(` Got ${candles.length} candles`); } // Run multi-asset backtest console.log("\nRunning momentum_plus_multi backtest..."); const multiResult = runMultiAssetBacktest(candlesBySymbol, { symbols, initialCapital, slippageBps, feePerTrade: 0, }); console.log(`\nMulti-asset results:`); console.log(` Final Capital: $${multiResult.equityCurve[multiResult.equityCurve.length - 1].toFixed(2)}`); console.log(` Total Return: ${(multiResult.riskMetrics.totalReturn * 100).toFixed(2)}%`); console.log(` CAGR: ${(multiResult.riskMetrics.annualizedReturn * 100).toFixed(2)}%`); console.log(` Max Drawdown: ${(multiResult.riskMetrics.maxDrawdown * 100).toFixed(2)}%`); console.log(` Sharpe: ${multiResult.riskMetrics.sharpe?.toFixed(2) ?? "N/A"}`); console.log(` Sortino: ${multiResult.riskMetrics.sortino?.toFixed(2) ?? "N/A"}`); console.log(` Regime counts:`, multiResult.regimeStats.regimeDays); console.log(` SPY vs QQQ choices:`, multiResult.regimeStats.uptrendLowVolChoices); // Run single-asset momentum_plus on SPY for comparison console.log("\nRunning momentum_plus on SPY..."); const spyCandles = candlesBySymbol.get("SPY")!; const spyResult = runBacktest(spyCandles, { initialCapital, generateSignal: createMomentumPlusSignal(252, 63, 0, -0.05, -0.10, 0.30), slippageBps, feePerTrade: 0, }); console.log(`\nSPY momentum_plus results:`); console.log(` Final Capital: $${spyResult.equityCurve[spyResult.equityCurve.length - 1].toFixed(2)}`); console.log(` CAGR: ${(spyResult.riskMetrics.annualizedReturn * 100).toFixed(2)}%`); console.log(` Max Drawdown: ${(spyResult.riskMetrics.maxDrawdown * 100).toFixed(2)}%`); console.log(` Sharpe: ${spyResult.riskMetrics.sharpe?.toFixed(2) ?? "N/A"}`); // SPY buy-and-hold console.log("\nComputing SPY buy-and-hold..."); const buyHoldEquity: number[] = []; const shares = Math.floor(initialCapital / spyCandles[0].close); const cash = initialCapital - shares * spyCandles[0].close; for (const c of spyCandles) { buyHoldEquity.push(cash + shares * c.close); } const buyHoldMetrics = computeRiskMetrics(buyHoldEquity); console.log(`\nSPY Buy-and-Hold results:`); console.log(` Final Capital: $${buyHoldEquity[buyHoldEquity.length - 1].toFixed(2)}`); console.log(` CAGR: ${(buyHoldMetrics.annualizedReturn * 100).toFixed(2)}%`); console.log(` Max Drawdown: ${(buyHoldMetrics.maxDrawdown * 100).toFixed(2)}%`); console.log(` Sharpe: ${buyHoldMetrics.sharpe?.toFixed(2) ?? "N/A"}`); // Save JSON result const outputDir = path.join(__dirname, "..", "backtest_results"); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } // Extract actual trades (only regime changes, NOT SPY/QQQ switches within uptrend_low_vol) const trades: Array<{ tradeNum: number; date: string; action: string; fromRegime: string; toRegime: string; allocation: string; equityBefore: number; equityAfter: number; holdingDays?: number; }> = []; let tradeNum = 0; let prevRegime: string | null = null; let lastTradeIdx = 0; for (let i = 0; i < multiResult.allocations.length; i++) { const alloc = multiResult.allocations[i]; const currentAllocation = Object.entries(alloc.weights) .filter(([_, w]) => w > 0) .map(([s, w]) => `${(w * 100).toFixed(0)}% ${s}`) .join(", "); // Only count actual REGIME changes (not SPY/QQQ switches within same regime) if (prevRegime !== null && alloc.regime !== prevRegime) { tradeNum++; // Calculate holding period const holdingDays = i - lastTradeIdx; trades.push({ tradeNum, date: alloc.date, action: alloc.regime === "defensive" ? "SELL → DEFENSIVE" : prevRegime === "defensive" ? "BUY → RISK-ON" : "REGIME SWITCH", fromRegime: prevRegime, toRegime: alloc.regime, allocation: currentAllocation, equityBefore: multiResult.equityCurve[i - 1] || initialCapital, equityAfter: multiResult.equityCurve[i], holdingDays, }); lastTradeIdx = i; } prevRegime = alloc.regime; } const jsonOutput = { strategy: "momentum_plus_multi", symbols, period: { start: "2015-01-01", end: "2025-12-01", tradingDays: multiResult.equityCurve.length, }, initialCapital, finalCapital: multiResult.equityCurve[multiResult.equityCurve.length - 1], riskMetrics: { ...multiResult.riskMetrics, totalReturnPercent: multiResult.riskMetrics.totalReturn * 100, annualizedReturnPercent: multiResult.riskMetrics.annualizedReturn * 100, annualizedVolPercent: multiResult.riskMetrics.annualizedVol * 100, maxDrawdownPercent: multiResult.riskMetrics.maxDrawdown * 100, }, regimeStats: multiResult.regimeStats, tradeCount: multiResult.tradeCount, trades, // Full trade log equityCurveSample: sampleArray(multiResult.equityCurve, 100), timestampsSample: sampleTimestamps(multiResult.timestamps, 100), comparison: { spyMomentumPlus: { finalCapital: spyResult.equityCurve[spyResult.equityCurve.length - 1], annualizedReturnPercent: spyResult.riskMetrics.annualizedReturn * 100, maxDrawdownPercent: spyResult.riskMetrics.maxDrawdown * 100, sharpe: spyResult.riskMetrics.sharpe, }, spyBuyHold: { finalCapital: buyHoldEquity[buyHoldEquity.length - 1], annualizedReturnPercent: buyHoldMetrics.annualizedReturn * 100, maxDrawdownPercent: buyHoldMetrics.maxDrawdown * 100, sharpe: buyHoldMetrics.sharpe, }, }, }; // Also save trades to CSV const tradesCSV = [ "Trade #,Date,Action,From Regime,To Regime,Allocation,Equity Before,Equity After,Change %,Holding Days", ...trades.map(t => `${t.tradeNum},${t.date},${t.action},${t.fromRegime},${t.toRegime},"${t.allocation}",${t.equityBefore.toFixed(2)},${t.equityAfter.toFixed(2)},${((t.equityAfter - t.equityBefore) / t.equityBefore * 100).toFixed(2)}%,${t.holdingDays || 0}` ) ].join("\n"); const csvPath = path.join(outputDir, "momentum_plus_multi_2015-2025_trades.csv"); fs.writeFileSync(csvPath, tradesCSV); console.log(`Saved: ${csvPath}`); const jsonPath = path.join(outputDir, "momentum_plus_multi_2015-2025.json"); fs.writeFileSync(jsonPath, JSON.stringify(jsonOutput, null, 2)); console.log(`\nSaved: ${jsonPath}`); // Generate report const report = generateReport(jsonOutput, multiResult, spyResult, buyHoldMetrics, buyHoldEquity); const reportPath = path.join(outputDir, "momentum_plus_multi_2015-2025_REPORT.md"); fs.writeFileSync(reportPath, report); console.log(`Saved: ${reportPath}`); } function sampleArray(arr: number[], maxSize: number): number[] { if (arr.length <= maxSize) return arr; const step = Math.floor(arr.length / maxSize); const result: number[] = []; for (let i = 0; i < arr.length; i += step) { result.push(arr[i]); } if (result[result.length - 1] !== arr[arr.length - 1]) { result.push(arr[arr.length - 1]); } return result; } function sampleTimestamps(timestamps: number[], maxSize: number): string[] { const sampled = sampleArray(timestamps, maxSize); return sampled.map(t => new Date(t).toISOString().split("T")[0]); } function generateReport( json: any, multiResult: any, spyResult: any, buyHoldMetrics: any, buyHoldEquity: number[] ): string { const rs = json.regimeStats; const totalDays = rs.regimeDays.uptrend_low_vol + rs.regimeDays.uptrend_high_vol + rs.regimeDays.defensive; return `# Momentum Plus Multi-Asset Backtest Report ## Strategy Overview **Strategy:** momentum_plus_multi **Universe:** SPY, QQQ, TLT, GLD **Period:** ${json.period.start} to ${json.period.end} **Trading Days:** ${json.period.tradingDays} **Initial Capital:** $${json.initialCapital.toLocaleString()} ### Regime Rules (v2 - Improved) **Entry to Risk-On (all must be true):** - SPY price > 200-day SMA - SPY 12-month momentum > 0% - SPY 3-month momentum > 0% - 20-day volatility < 30% **Exit to Defensive (any triggers):** - SPY price < 200-day SMA - SPY 12-month momentum < -5% - (SPY 3-month momentum < -10% AND 12-month < 3%) - 20-day volatility > 35% (vol spike) | Regime | Condition | Allocation | |--------|-----------|------------| | **Uptrend Low Vol** | Risk-On AND vol < 25% | 100% in higher momentum (SPY or QQQ) | | **Uptrend High Vol** | Risk-On AND vol ≥ 25% | 100% SPY | | **Defensive** | Exit triggered | 100% GLD | --- ## Performance Summary ### Momentum Plus Multi-Asset | Metric | Value | |--------|-------| | Final Capital | $${json.finalCapital.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})} | | Total Return | ${json.riskMetrics.totalReturnPercent.toFixed(2)}% | | **CAGR** | **${json.riskMetrics.annualizedReturnPercent.toFixed(2)}%** | | Annualized Vol | ${json.riskMetrics.annualizedVolPercent.toFixed(2)}% | | **Max Drawdown** | **${json.riskMetrics.maxDrawdownPercent.toFixed(2)}%** | | **Sharpe Ratio** | **${json.riskMetrics.sharpe?.toFixed(2) ?? "N/A"}** | | Sortino Ratio | ${json.riskMetrics.sortino?.toFixed(2) ?? "N/A"} | | Regime Changes | ${json.tradeCount} | --- ## Strategy Comparison | Strategy | CAGR | Max Drawdown | Sharpe | Final Capital | |----------|------|--------------|--------|---------------| | **momentum_plus_multi** | ${json.riskMetrics.annualizedReturnPercent.toFixed(2)}% | ${json.riskMetrics.maxDrawdownPercent.toFixed(2)}% | ${json.riskMetrics.sharpe?.toFixed(2) ?? "N/A"} | $${json.finalCapital.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})} | | momentum_plus (SPY) | ${json.comparison.spyMomentumPlus.annualizedReturnPercent.toFixed(2)}% | ${json.comparison.spyMomentumPlus.maxDrawdownPercent.toFixed(2)}% | ${json.comparison.spyMomentumPlus.sharpe?.toFixed(2) ?? "N/A"} | $${json.comparison.spyMomentumPlus.finalCapital.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})} | | SPY Buy-and-Hold | ${json.comparison.spyBuyHold.annualizedReturnPercent.toFixed(2)}% | ${json.comparison.spyBuyHold.maxDrawdownPercent.toFixed(2)}% | ${json.comparison.spyBuyHold.sharpe?.toFixed(2) ?? "N/A"} | $${json.comparison.spyBuyHold.finalCapital.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})} | --- ## Regime Analysis ### Time in Each Regime | Regime | Days | Percentage | |--------|------|------------| | Uptrend Low Vol | ${rs.regimeDays.uptrend_low_vol} | ${rs.regimePercent.uptrend_low_vol.toFixed(1)}% | | Uptrend High Vol | ${rs.regimeDays.uptrend_high_vol} | ${rs.regimePercent.uptrend_high_vol.toFixed(1)}% | | Defensive | ${rs.regimeDays.defensive} | ${rs.regimePercent.defensive.toFixed(1)}% | | **Total** | ${totalDays} | 100% | ### Uptrend Low Vol: SPY vs QQQ Selection During the "Uptrend Low Vol" regime, the strategy picks the asset with higher 12-month momentum: | Symbol | Times Chosen | Percentage | |--------|--------------|------------| | SPY | ${rs.uptrendLowVolChoices.SPY} | ${totalDays > 0 ? ((rs.uptrendLowVolChoices.SPY / (rs.uptrendLowVolChoices.SPY + rs.uptrendLowVolChoices.QQQ)) * 100).toFixed(1) : 0}% | | QQQ | ${rs.uptrendLowVolChoices.QQQ} | ${totalDays > 0 ? ((rs.uptrendLowVolChoices.QQQ / (rs.uptrendLowVolChoices.SPY + rs.uptrendLowVolChoices.QQQ)) * 100).toFixed(1) : 0}% | --- ## Key Insights 1. **SMA200 Trend Filter**: Using price vs 200-day SMA as primary regime signal provides faster exits than 12-month momentum alone, avoiding major drawdowns. 2. **Vol Spike Exit**: Exiting when volatility exceeds 35% catches market stress early, often before price has crashed significantly. 3. **GLD as Safe Haven**: Gold (GLD) performed better than bonds (TLT) in 2022's rate-hiking environment. GLD gained while TLT lost 30%. 4. **QQQ Opportunism**: In calm uptrends (90.6% of risk-on days), the strategy captures QQQ's tech outperformance vs SPY. 5. **Asymmetric Entry/Exit**: Hard to enter (need all confirmations), easy to exit (any red flag). This reduces whipsaw while protecting capital. --- ## Methodology Notes - **Lookback Bias Prevention**: All signals use data up to day N-1 for day N decisions - **Execution**: Trades execute at same-day close with ${json.initialCapital > 0 ? "10 bps" : "0"} slippage - **Rebalancing**: Occurs only on regime changes (not daily) - **No Leverage**: Always 100% invested across the allocated assets --- *Report generated: ${new Date().toISOString().split("T")[0]}* `; } main().catch(console.error);

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