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