/**
* 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,
};
}