Skip to main content
Glama
Ademscodeisnotsobad

Quant Companion MCP

backtest.ts30.2 kB
/** * Simple backtesting engine */ import { Candle, BacktestConfig, BacktestResult, RiskMetrics, RegimeType, AllocationWeights, DailyAllocation, MultiAssetBacktestConfig, MultiAssetBacktestResult, RegimeStats, } from "./types"; import { computeRiskMetrics } from "./risk"; /** * Run a simple long/flat backtest on candle data * * @param candles - OHLCV candle data * @param config - Backtest configuration including signal generator * @returns Backtest results with equity curve and risk metrics */ export function runBacktest( candles: Candle[], config: BacktestConfig ): BacktestResult { const { initialCapital, generateSignal, slippageBps = 0, feePerTrade = 0, } = config; if (candles.length === 0) { throw new Error("Cannot backtest with empty candle data"); } const equityCurve: number[] = []; const positions: ("long" | "flat")[] = []; const timestamps: number[] = []; let cash = initialCapital; let shares = 0; let currentPosition: "long" | "flat" = "flat"; for (let i = 0; i < candles.length; i++) { const candle = candles[i]; const signal = generateSignal({ candles, index: i, currentPosition }); // Calculate slippage multiplier const slippageMultiplier = slippageBps / 10000; // Handle position changes if (signal === "long" && currentPosition === "flat") { // Buy at close with slippage const buyPrice = candle.close * (1 + slippageMultiplier); const maxShares = Math.floor((cash - feePerTrade) / buyPrice); if (maxShares > 0) { shares = maxShares; cash = cash - shares * buyPrice - feePerTrade; currentPosition = "long"; } } else if (signal === "flat" && currentPosition === "long") { // Sell at close with slippage const sellPrice = candle.close * (1 - slippageMultiplier); cash = cash + shares * sellPrice - feePerTrade; shares = 0; currentPosition = "flat"; } // Calculate portfolio value at close const portfolioValue = cash + shares * candle.close; equityCurve.push(portfolioValue); positions.push(currentPosition); timestamps.push(candle.timestamp); } // Compute risk metrics const riskMetrics = computeRiskMetrics(equityCurve); return { equityCurve, positions, timestamps, riskMetrics, }; } /** * Create a simple moving average crossover signal generator */ export function createMACrossoverSignal( fastWindow: number, slowWindow: number ): BacktestConfig["generateSignal"] { return ({ candles, index }) => { if (index < slowWindow - 1) { return "flat"; } let fastSum = 0; for (let i = index - fastWindow + 1; i <= index; i++) { fastSum += candles[i].close; } const fastMA = fastSum / fastWindow; let slowSum = 0; for (let i = index - slowWindow + 1; i <= index; i++) { slowSum += candles[i].close; } const slowMA = slowSum / slowWindow; return fastMA > slowMA ? "long" : "flat"; }; } /** * Create a simple momentum signal generator */ export function createMomentumSignal( lookback: number, threshold: number = 0 ): BacktestConfig["generateSignal"] { return ({ candles, index }) => { if (index < lookback) { return "flat"; } const pastPrice = candles[index - lookback].close; const currentPrice = candles[index].close; const momentum = (currentPrice - pastPrice) / pastPrice; return momentum > threshold ? "long" : "flat"; }; } /** * Create a mean reversion signal generator (RSI-based) */ export function createMeanReversionSignal( period: number = 14, oversoldThreshold: number = 30, overboughtThreshold: number = 70 ): BacktestConfig["generateSignal"] { return ({ candles, index, currentPosition }) => { if (index < period) { return "flat"; } let gains = 0; let losses = 0; for (let i = index - period + 1; i <= index; i++) { const change = candles[i].close - candles[i - 1].close; if (change > 0) { gains += change; } else { losses -= change; } } const avgGain = gains / period; const avgLoss = losses / period; const rsi = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss); if (currentPosition === "flat" && rsi < oversoldThreshold) { return "long"; } if (currentPosition === "long" && rsi > overboughtThreshold) { return "flat"; } return currentPosition; }; } /** * Create a Dual Momentum signal generator (12-month absolute momentum) */ export function createDualMomentumSignal( lookbackDays: number = 252, safeAssetReturns?: number[] ): BacktestConfig["generateSignal"] { return ({ candles, index }) => { if (index < lookbackDays) { return "flat"; } const pastPrice = candles[index - lookbackDays].close; const currentPrice = candles[index].close; const primaryMomentum = (currentPrice - pastPrice) / pastPrice; if (!safeAssetReturns || safeAssetReturns.length <= index) { return primaryMomentum > 0 ? "long" : "flat"; } const safeMomentum = safeAssetReturns[index]; if (primaryMomentum > 0 && primaryMomentum > safeMomentum) { return "long"; } return "flat"; }; } /** * Create a Volatility-Filtered Trend signal generator */ export function createVolFilteredTrendSignal( fastWindow: number = 20, slowWindow: number = 50, volLookback: number = 20, maxVolThreshold: number = 0.25 ): BacktestConfig["generateSignal"] { return ({ candles, index, currentPosition }) => { if (index < Math.max(slowWindow, volLookback)) { return "flat"; } let fastSum = 0; for (let i = index - fastWindow + 1; i <= index; i++) { fastSum += candles[i].close; } const fastMA = fastSum / fastWindow; let slowSum = 0; for (let i = index - slowWindow + 1; i <= index; i++) { slowSum += candles[i].close; } const slowMA = slowSum / slowWindow; const returns: number[] = []; for (let i = index - volLookback + 1; i <= index; i++) { const ret = Math.log(candles[i].close / candles[i - 1].close); returns.push(ret); } const meanRet = returns.reduce((a, b) => a + b, 0) / returns.length; const variance = returns.reduce((sum, r) => sum + Math.pow(r - meanRet, 2), 0) / returns.length; const dailyVol = Math.sqrt(variance); const annualizedVol = dailyVol * Math.sqrt(252); const isBullish = fastMA > slowMA; const isVolOk = annualizedVol < maxVolThreshold; if (currentPosition === "flat" && isBullish && isVolOk) { return "long"; } if (currentPosition === "long" && (!isBullish || annualizedVol > maxVolThreshold * 1.2)) { return "flat"; } return currentPosition; }; } /** * Create an Adaptive Momentum signal generator */ export function createAdaptiveMomentumSignal( shortLookback: number = 60, longLookback: number = 252, volThreshold: number = 0.20 ): BacktestConfig["generateSignal"] { return ({ candles, index }) => { if (index < longLookback) { return "flat"; } const volReturns: number[] = []; for (let i = index - 19; i <= index; i++) { volReturns.push(Math.log(candles[i].close / candles[i - 1].close)); } const meanRet = volReturns.reduce((a, b) => a + b, 0) / volReturns.length; const variance = volReturns.reduce((sum, r) => sum + Math.pow(r - meanRet, 2), 0) / volReturns.length; const annualizedVol = Math.sqrt(variance) * Math.sqrt(252); const lookback = annualizedVol > volThreshold ? shortLookback : longLookback; const pastPrice = candles[index - lookback].close; const currentPrice = candles[index].close; const momentum = (currentPrice - pastPrice) / pastPrice; return momentum > 0 ? "long" : "flat"; }; } /** * Create an optimized Momentum Plus signal generator * * Improvements over basic dual_momentum: * - Uses both 12-month AND 3-month momentum (confirmation) * - Asymmetric exit thresholds (stays in longer) * - Volatility filter on entry (avoids choppy markets) * * Target: 12%+ CAGR with reduced whipsaw */ export function createMomentumPlusSignal( longLookback: number = 252, // 12-month momentum shortLookback: number = 63, // 3-month momentum entryThreshold: number = 0, // min momentum to enter exitThreshold: number = -0.05, // exit when 12mo drops below -5% shortExitThreshold: number = -0.10, // or 3mo drops below -10% maxVolThreshold: number = 0.30 // don't enter if vol > 30% ): BacktestConfig["generateSignal"] { return ({ candles, index, currentPosition }) => { if (index < longLookback) { return "flat"; } // Calculate 12-month momentum const longPastPrice = candles[index - longLookback].close; const currentPrice = candles[index].close; const longMomentum = (currentPrice - longPastPrice) / longPastPrice; // Calculate 3-month momentum const shortPastPrice = candles[index - shortLookback].close; const shortMomentum = (currentPrice - shortPastPrice) / shortPastPrice; // Calculate 20-day volatility const volReturns: number[] = []; for (let i = index - 19; i <= index; i++) { volReturns.push(Math.log(candles[i].close / candles[i - 1].close)); } const meanRet = volReturns.reduce((a, b) => a + b, 0) / volReturns.length; const variance = volReturns.reduce((sum, r) => sum + Math.pow(r - meanRet, 2), 0) / volReturns.length; const annualizedVol = Math.sqrt(variance) * Math.sqrt(252); // ENTRY: Both momentums positive AND vol not extreme if (currentPosition === "flat") { if (longMomentum > entryThreshold && shortMomentum > entryThreshold && annualizedVol < maxVolThreshold) { return "long"; } return "flat"; } // EXIT: Only exit on clear trend break (asymmetric - harder to exit) if (currentPosition === "long") { // Exit if 12-month momentum deeply negative if (longMomentum < exitThreshold) { return "flat"; } // Exit if 3-month momentum crashes (early warning) if (shortMomentum < shortExitThreshold && longMomentum < 0.03) { return "flat"; } // Stay in otherwise return "long"; } return currentPosition; }; } /** * Detailed trade record */ export interface DetailedTradeRecord { tradeNumber: number; symbol: string; action: "BUY" | "SELL"; entryDate: string; entryPrice: number; exitDate: string; exitPrice: number; shares: number; entryValue: number; exitValue: number; pnl: number; returnPct: number; holdingDays: number; } /** * Extract detailed trade records from backtest result */ export function extractDetailedTrades( result: BacktestResult, candles: Candle[], symbol: string ): DetailedTradeRecord[] { const trades: DetailedTradeRecord[] = []; let entryIndex: number | null = null; let entryValue: number | null = null; let tradeNum = 0; for (let i = 1; i < result.positions.length; i++) { const prevPos = result.positions[i - 1]; const currPos = result.positions[i]; if (prevPos === "flat" && currPos === "long") { entryIndex = i; entryValue = result.equityCurve[i]; } else if (prevPos === "long" && currPos === "flat" && entryIndex !== null && entryValue !== null) { tradeNum++; const exitValue = result.equityCurve[i]; const entryCandle = candles[entryIndex]; const exitCandle = candles[i]; const entryPrice = entryCandle.close; const exitPrice = exitCandle.close; const shares = Math.floor(entryValue / entryPrice); const entryDate = new Date(result.timestamps[entryIndex]); const exitDate = new Date(result.timestamps[i]); const holdingDays = Math.round((exitDate.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24)); trades.push({ tradeNumber: tradeNum, symbol: symbol, action: "SELL", entryDate: entryDate.toISOString().split("T")[0], entryPrice: entryPrice, exitDate: exitDate.toISOString().split("T")[0], exitPrice: exitPrice, shares: shares, entryValue: entryValue, exitValue: exitValue, pnl: exitValue - entryValue, returnPct: ((exitValue - entryValue) / entryValue) * 100, holdingDays: holdingDays, }); entryIndex = null; entryValue = null; } } if (result.positions[result.positions.length - 1] === "long" && entryIndex !== null && entryValue !== null) { tradeNum++; const lastIdx = result.equityCurve.length - 1; const exitValue = result.equityCurve[lastIdx]; const entryCandle = candles[entryIndex]; const exitCandle = candles[lastIdx]; const entryPrice = entryCandle.close; const exitPrice = exitCandle.close; const shares = Math.floor(entryValue / entryPrice); const entryDate = new Date(result.timestamps[entryIndex]); const exitDate = new Date(result.timestamps[lastIdx]); const holdingDays = Math.round((exitDate.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24)); trades.push({ tradeNumber: tradeNum, symbol: symbol, action: "SELL", entryDate: entryDate.toISOString().split("T")[0], entryPrice: entryPrice, exitDate: exitDate.toISOString().split("T")[0] + " (EOY)", exitPrice: exitPrice, shares: shares, entryValue: entryValue, exitValue: exitValue, pnl: exitValue - entryValue, returnPct: ((exitValue - entryValue) / entryValue) * 100, holdingDays: holdingDays, }); } return trades; } /** * Calculate backtest statistics */ export function computeBacktestStats(result: BacktestResult): { totalTrades: number; winningTrades: number; losingTrades: number; winRate: number; avgWin: number; avgLoss: number; profitFactor: number; } { const trades: { entry: number; exit: number; pnl: number }[] = []; let entryValue: number | null = null; for (let i = 1; i < result.positions.length; i++) { const prevPos = result.positions[i - 1]; const currPos = result.positions[i]; if (prevPos === "flat" && currPos === "long") { entryValue = result.equityCurve[i]; } else if (prevPos === "long" && currPos === "flat" && entryValue !== null) { const exitValue = result.equityCurve[i]; trades.push({ entry: entryValue, exit: exitValue, pnl: exitValue - entryValue, }); entryValue = null; } } if (result.positions[result.positions.length - 1] === "long" && entryValue !== null) { const exitValue = result.equityCurve[result.equityCurve.length - 1]; trades.push({ entry: entryValue, exit: exitValue, pnl: exitValue - entryValue, }); } const winningTrades = trades.filter((t) => t.pnl > 0); const losingTrades = trades.filter((t) => t.pnl < 0); const totalWins = winningTrades.reduce((sum, t) => sum + t.pnl, 0); const totalLosses = Math.abs(losingTrades.reduce((sum, t) => sum + t.pnl, 0)); return { totalTrades: trades.length, winningTrades: winningTrades.length, losingTrades: losingTrades.length, winRate: trades.length > 0 ? winningTrades.length / trades.length : 0, avgWin: winningTrades.length > 0 ? totalWins / winningTrades.length : 0, avgLoss: losingTrades.length > 0 ? totalLosses / losingTrades.length : 0, profitFactor: totalLosses > 0 ? totalWins / totalLosses : Infinity, }; } // ============================================ // MULTI-ASSET MOMENTUM PLUS STRATEGY // ============================================ /** * Aligned multi-asset candle data by date */ export interface AlignedCandles { /** Date string (YYYY-MM-DD) */ dates: string[]; /** Candles per symbol, indexed by date string */ bySymbol: Map<string, Map<string, Candle>>; /** All timestamps in order */ timestamps: number[]; } /** * Align candles from multiple symbols by date. * Uses forward-fill for missing dates. */ export function alignCandlesByDate( candlesBySymbol: Map<string, Candle[]> ): AlignedCandles { // Collect all unique dates const allDates = new Set<string>(); const dateToTimestamp = new Map<string, number>(); for (const [, candles] of candlesBySymbol) { for (const c of candles) { const dateStr = new Date(c.timestamp).toISOString().split("T")[0]; allDates.add(dateStr); if (!dateToTimestamp.has(dateStr)) { dateToTimestamp.set(dateStr, c.timestamp); } } } // Sort dates const sortedDates = Array.from(allDates).sort(); const timestamps = sortedDates.map((d) => dateToTimestamp.get(d)!); // Build per-symbol date maps with forward-fill const bySymbol = new Map<string, Map<string, Candle>>(); for (const [symbol, candles] of candlesBySymbol) { const dateMap = new Map<string, Candle>(); // First pass: index by date for (const c of candles) { const dateStr = new Date(c.timestamp).toISOString().split("T")[0]; dateMap.set(dateStr, c); } // Forward-fill missing dates let lastCandle: Candle | null = null; for (const dateStr of sortedDates) { if (dateMap.has(dateStr)) { lastCandle = dateMap.get(dateStr)!; } else if (lastCandle) { // Forward-fill with last close dateMap.set(dateStr, { timestamp: dateToTimestamp.get(dateStr)!, open: lastCandle.close, high: lastCandle.close, low: lastCandle.close, close: lastCandle.close, volume: 0, }); } } bySymbol.set(symbol, dateMap); } return { dates: sortedDates, bySymbol, timestamps }; } /** * Compute 12-month and 3-month momentum for a symbol at a given index. */ function computeMomentum( candles: Candle[], index: number, longLookback: number = 252, shortLookback: number = 63 ): { longMomentum: number | null; shortMomentum: number | null } { if (index < longLookback) { return { longMomentum: null, shortMomentum: null }; } const currentPrice = candles[index].close; const longPastPrice = candles[index - longLookback].close; const shortPastPrice = candles[index - shortLookback].close; return { longMomentum: (currentPrice - longPastPrice) / longPastPrice, shortMomentum: (currentPrice - shortPastPrice) / shortPastPrice, }; } /** * Compute 20-day annualized volatility at a given index. */ function computeVol20(candles: Candle[], index: number): number | null { if (index < 20) return null; const returns: number[] = []; for (let i = index - 19; i <= index; i++) { returns.push(Math.log(candles[i].close / candles[i - 1].close)); } const meanRet = returns.reduce((a, b) => a + b, 0) / returns.length; const variance = returns.reduce((sum, r) => sum + Math.pow(r - meanRet, 2), 0) / returns.length; return Math.sqrt(variance) * Math.sqrt(252); } /** * Calculate simple moving average */ function calculateSMA(prices: number[], period: number): number | null { if (prices.length < period) return null; let sum = 0; for (let i = 0; i < period; i++) { sum += prices[i]; } return sum / period; } /** * Determine regime and allocations based on SPY signals. * * IMPROVED RULES (v2): * * Entry to Risk-On: * - SPY price > 200-day SMA AND * - SPY 12m momentum > 0 AND * - SPY 3m momentum > 0 AND * - 20d vol < 30% * * Stay in Risk-On until exit trigger: * - SPY price < 200-day SMA OR * - SPY 12m momentum < -5% OR * - (SPY 3m momentum < -10% AND 12m < 3%) OR * - 20d vol > 35% (vol spike exit) * * When in Risk-On: * - If vol < 25%: 100% in higher momentum (SPY or QQQ) * - If vol >= 25%: 100% SPY (more stable) * * When Defensive: * - 100% GLD (gold as safe haven - performed better than TLT in 2022) */ function determineRegimeAndAllocationV2( spyPrice: number, spySMA200: number | null, spyMom12m: number, spyMom3m: number, spyVol: number, qqqMom12m: number, currentlyInRiskOn: boolean ): { regime: RegimeType; weights: AllocationWeights; chosen?: string } { // Can't compute without SMA200 if (spySMA200 === null) { return { regime: "defensive", weights: { SPY: 0, QQQ: 0, TLT: 0, GLD: 1.0 }, }; } const aboveSMA = spyPrice > spySMA200; // Exit triggers (more aggressive to avoid drawdowns) const exitTrigger = !aboveSMA || // Price below 200-day SMA spyMom12m < -0.05 || // 12m momentum deeply negative (spyMom3m < -0.10 && spyMom12m < 0.03) || // Short-term crash spyVol > 0.35; // Vol spike // Entry triggers (need confirmation) const entryTrigger = aboveSMA && spyMom12m > 0 && spyMom3m > 0 && spyVol < 0.30; // Determine if we should be in risk-on let inRiskOn: boolean; if (currentlyInRiskOn) { // Stay in unless exit triggered inRiskOn = !exitTrigger; } else { // Enter only if all entry conditions met inRiskOn = entryTrigger; } if (inRiskOn) { if (spyVol < 0.25) { // Low vol: pick higher momentum between SPY and QQQ const chosen = spyMom12m >= qqqMom12m ? "SPY" : "QQQ"; return { regime: "uptrend_low_vol", weights: { SPY: chosen === "SPY" ? 1.0 : 0, QQQ: chosen === "QQQ" ? 1.0 : 0, TLT: 0, GLD: 0 }, chosen, }; } else { // High vol but still trending up: 100% SPY (more stable) return { regime: "uptrend_high_vol", weights: { SPY: 1.0, QQQ: 0, TLT: 0, GLD: 0 }, }; } } else { // Defensive: 100% GLD (gold held up better than TLT in 2022) return { regime: "defensive", weights: { SPY: 0, QQQ: 0, TLT: 0, GLD: 1.0 }, }; } } /** * Run multi-asset momentum plus backtest. * * Universe: SPY, QQQ, TLT, GLD * Signals computed using each symbol's data. * Regime determined by SPY's 12m momentum and 20d vol. * * @param candlesBySymbol - Map of symbol to candle array (must include SPY, QQQ, TLT, GLD) * @param config - Backtest configuration * @returns Multi-asset backtest result */ export function runMultiAssetBacktest( candlesBySymbol: Map<string, Candle[]>, config: MultiAssetBacktestConfig ): MultiAssetBacktestResult { const requiredSymbols = ["SPY", "QQQ", "TLT", "GLD"]; for (const sym of requiredSymbols) { if (!candlesBySymbol.has(sym)) { throw new Error(`Missing required symbol: ${sym}`); } } const slippageBps = config.slippageBps ?? 10; const feePerTrade = config.feePerTrade ?? 0; // Align candles by date const aligned = alignCandlesByDate(candlesBySymbol); const { dates, bySymbol, timestamps } = aligned; // Need at least 252 days for momentum calculation if (dates.length < 253) { throw new Error("Need at least 253 days of data for 12-month momentum"); } // Find the first date where ALL symbols have data let startDateIdx = 0; for (let d = 0; d < dates.length; d++) { const dateStr = dates[d]; let allHaveData = true; for (const sym of requiredSymbols) { if (!bySymbol.get(sym)!.has(dateStr)) { allHaveData = false; break; } } if (allHaveData) { startDateIdx = d; break; } } // Build candle arrays in date order starting from startDateIdx const validDates = dates.slice(startDateIdx); const validTimestamps = timestamps.slice(startDateIdx); const candleArrays = new Map<string, Candle[]>(); for (const sym of requiredSymbols) { const arr: Candle[] = []; const symMap = bySymbol.get(sym)!; for (const dateStr of validDates) { const candle = symMap.get(dateStr); if (!candle) { throw new Error(`Missing candle for ${sym} on ${dateStr} after alignment`); } arr.push(candle); } candleArrays.set(sym, arr); } // Verify all arrays have same length const expectedLen = validDates.length; for (const [sym, arr] of candleArrays) { if (arr.length !== expectedLen) { throw new Error(`Candle array length mismatch for ${sym}: ${arr.length} vs ${expectedLen}`); } } // Initialize state let equity = config.initialCapital; const equityCurve: number[] = []; const allocations: DailyAllocation[] = []; const resultTimestamps: number[] = []; // Regime tracking const regimeDays: Record<RegimeType, number> = { uptrend_low_vol: 0, uptrend_high_vol: 0, defensive: 0, }; const uptrendChoices = { SPY: 0, QQQ: 0 }; // Current holdings (shares per symbol) let currentHoldings: Map<string, number> = new Map(); let currentRegime: RegimeType | null = null; let tradeCount = 0; let inRiskOn = false; // Track if we're currently in risk-on mode // Need at least 252 days for momentum lookback if (validDates.length < 253) { throw new Error(`Need at least 253 days of aligned data, got ${validDates.length}`); } // Start from day 252 (need lookback data) const startIdx = 252; for (let i = startIdx; i < validDates.length; i++) { const dateStr = validDates[i]; const ts = validTimestamps[i]; // Get candle arrays at current index const spyCandles = candleArrays.get("SPY")!; const qqqCandles = candleArrays.get("QQQ")!; const tltCandles = candleArrays.get("TLT")!; const gldCandles = candleArrays.get("GLD")!; // Compute signals (using data up to day i-1 to avoid lookahead) const signalIdx = i - 1; const spyMom = computeMomentum(spyCandles, signalIdx); const spyVol = computeVol20(spyCandles, signalIdx); const qqqMom = computeMomentum(qqqCandles, signalIdx); // Build price history for SMA calculation (newest first) const spyPriceHistory: number[] = []; for (let j = signalIdx; j >= 0 && spyPriceHistory.length < 200; j--) { spyPriceHistory.push(spyCandles[j].close); } const spySMA200 = calculateSMA(spyPriceHistory, 200); // Skip if signals not available if (spyMom.longMomentum === null || spyMom.shortMomentum === null || spyVol === null || qqqMom.longMomentum === null) { continue; } // Determine regime and target allocation using V2 logic const { regime, weights, chosen } = determineRegimeAndAllocationV2( spyCandles[signalIdx].close, spySMA200, spyMom.longMomentum, spyMom.shortMomentum, spyVol, qqqMom.longMomentum, inRiskOn ); // Update risk-on state inRiskOn = regime !== "defensive"; // Record regime regimeDays[regime]++; if (regime === "uptrend_low_vol" && chosen) { uptrendChoices[chosen as "SPY" | "QQQ"]++; } // Get current prices (for execution at day's close) const prices = new Map<string, number>([ ["SPY", spyCandles[i].close], ["QQQ", qqqCandles[i].close], ["TLT", tltCandles[i].close], ["GLD", gldCandles[i].close], ]); // Check if we need to rebalance: // 1. Regime changed // 2. First day (no holdings yet) // Note: We do NOT switch SPY<->QQQ within uptrend_low_vol regime to avoid whipsaw. // The SPY/QQQ choice is made once when entering the regime. const regimeChanged = regime !== currentRegime; const shouldRebalance = regimeChanged || currentHoldings.size === 0; if (shouldRebalance) { // Calculate slippage for selling and buying const slippageMult = slippageBps / 10000; let fees = 0; // Sell current holdings let cashFromSales = 0; for (const [sym, shares] of currentHoldings) { if (shares > 0) { const sellPrice = prices.get(sym)! * (1 - slippageMult); cashFromSales += shares * sellPrice; fees += feePerTrade; } } // Add existing cash (equity minus holdings value before trade) // Since we're fully invested, cash = 0 in normal operation const availableCash = cashFromSales + (currentHoldings.size === 0 ? equity : 0); const netCash = availableCash - fees; // Buy new positions currentHoldings = new Map(); for (const [sym, weight] of Object.entries(weights)) { if (weight > 0) { const buyPrice = prices.get(sym)! * (1 + slippageMult); const targetValue = netCash * weight; const shares = Math.floor(targetValue / buyPrice); if (shares > 0) { currentHoldings.set(sym, shares); fees += feePerTrade; } } } // Calculate remaining cash after buying let spent = 0; for (const [sym, shares] of currentHoldings) { const buyPrice = prices.get(sym)! * (1 + slippageMult); spent += shares * buyPrice; } if (currentRegime !== null) { tradeCount++; } currentRegime = regime; } // Calculate portfolio value at close let portfolioValue = 0; for (const [sym, shares] of currentHoldings) { const price = prices.get(sym); if (price === undefined || price === 0 || isNaN(price)) { throw new Error(`Invalid price for ${sym} on ${dateStr}: ${price}`); } portfolioValue += shares * price; } equity = portfolioValue; // Record equityCurve.push(equity); resultTimestamps.push(ts); allocations.push({ date: dateStr, regime, weights, chosenSymbol: chosen, }); } // Compute risk metrics const riskMetrics = computeRiskMetrics(equityCurve); // Compute regime stats const totalDays = regimeDays.uptrend_low_vol + regimeDays.uptrend_high_vol + regimeDays.defensive; const regimeStats: RegimeStats = { regimeDays, regimePercent: { uptrend_low_vol: totalDays > 0 ? (regimeDays.uptrend_low_vol / totalDays) * 100 : 0, uptrend_high_vol: totalDays > 0 ? (regimeDays.uptrend_high_vol / totalDays) * 100 : 0, defensive: totalDays > 0 ? (regimeDays.defensive / totalDays) * 100 : 0, }, uptrendLowVolChoices: uptrendChoices, }; return { symbols: requiredSymbols, equityCurve, timestamps: resultTimestamps, allocations, riskMetrics, regimeStats, tradeCount, }; }

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