initial backtests

This commit is contained in:
Boki 2025-07-03 09:07:45 -04:00
parent fa70ada2bb
commit 5a3a23a2ba
6 changed files with 400 additions and 129 deletions

View file

@ -14,14 +14,78 @@ interface BacktestEvent {
} }
interface BacktestResult { interface BacktestResult {
id: string; // Identification
config: any; backtestId: string;
performance: PerformanceMetrics; status: 'completed' | 'failed' | 'cancelled';
trades: any[]; completedAt: string;
equityCurve: { timestamp: number; value: number }[];
drawdown: { timestamp: number; value: number }[]; // Configuration
dailyReturns: number[]; config: {
finalPositions: any[]; name: string;
strategy: string;
symbols: string[];
startDate: string;
endDate: string;
initialCapital: number;
commission: number;
slippage: number;
dataFrequency: string;
};
// Performance metrics
metrics: {
totalReturn: number;
sharpeRatio: number;
maxDrawdown: number;
winRate: number;
totalTrades: number;
profitFactor: number;
profitableTrades: number;
avgWin: number;
avgLoss: number;
expectancy: number;
calmarRatio: number;
sortinoRatio: number;
};
// Chart data
equity: Array<{ date: string; value: number }>;
ohlcData: Record<string, any[]>;
// Trade history
trades: Array<{
id: string;
symbol: string;
entryDate: string;
exitDate: string | null;
entryPrice: number;
exitPrice: number;
quantity: number;
side: string;
pnl: number;
pnlPercent: number;
commission: number;
duration: number;
}>;
// Positions
positions: Array<{
symbol: string;
quantity: number;
averagePrice: number;
currentPrice: number;
unrealizedPnl: number;
realizedPnl: number;
}>;
// Analytics
analytics: {
drawdownSeries: { timestamp: number; value: number }[];
dailyReturns: number[];
monthlyReturns: Record<string, number>;
exposureTime: number;
riskMetrics: Record<string, number>;
};
} }
export class BacktestEngine extends EventEmitter { export class BacktestEngine extends EventEmitter {
@ -117,17 +181,85 @@ export class BacktestEngine extends EventEmitter {
// Get final positions // Get final positions
const finalPositions = await this.getFinalPositions(); const finalPositions = await this.getFinalPositions();
// Store results // Create comprehensive frontend-ready result
const result: BacktestResult = { const result: BacktestResult = {
id: backtestId, // Identification
config: validatedConfig, backtestId,
performance, status: 'completed' as const,
trades: this.trades, completedAt: new Date().toISOString(),
equityCurve: this.equityCurve,
drawdown: this.calculateDrawdown(), // Configuration used
dailyReturns: this.calculateDailyReturns(), config: {
finalPositions, name: validatedConfig.name || 'Backtest',
ohlcData: this.getOHLCData(marketData, validatedConfig.symbols) strategy: validatedConfig.strategy,
symbols: validatedConfig.symbols,
startDate: validatedConfig.startDate,
endDate: validatedConfig.endDate,
initialCapital: validatedConfig.initialCapital,
commission: validatedConfig.commission || 0,
slippage: validatedConfig.slippage || 0,
dataFrequency: validatedConfig.dataFrequency || '1d'
},
// Performance metrics (frontend-ready)
metrics: {
totalReturn: performance.totalReturn,
sharpeRatio: performance.sharpeRatio,
maxDrawdown: performance.maxDrawdown,
winRate: performance.winRate,
totalTrades: performance.totalTrades,
profitFactor: performance.profitFactor || 0,
profitableTrades: Math.round(performance.totalTrades * performance.winRate / 100),
avgWin: performance.avgWin || 0,
avgLoss: performance.avgLoss || 0,
expectancy: performance.expectancy || 0,
calmarRatio: performance.calmarRatio || 0,
sortinoRatio: performance.sortinoRatio || 0
},
// Chart data (frontend-ready format)
equity: this.equityCurve.map(point => ({
date: new Date(point.timestamp).toISOString(),
value: point.value
})),
// OHLC data for charts
ohlcData: this.getOHLCData(marketData, validatedConfig.symbols),
// Trade history (frontend-ready)
trades: this.trades.map(trade => ({
id: `${trade.symbol}-${trade.entryTime}`,
symbol: trade.symbol,
entryDate: new Date(trade.entryTime).toISOString(),
exitDate: trade.exitTime ? new Date(trade.exitTime).toISOString() : null,
entryPrice: trade.entryPrice,
exitPrice: trade.exitPrice || trade.currentPrice,
quantity: trade.quantity,
side: trade.side,
pnl: trade.pnl || 0,
pnlPercent: trade.returnPct || 0,
commission: trade.commission || 0,
duration: trade.holdingPeriod || 0
})),
// Final positions
positions: finalPositions.map(pos => ({
symbol: pos.symbol,
quantity: pos.quantity,
averagePrice: pos.avgPrice,
currentPrice: pos.currentPrice || pos.avgPrice,
unrealizedPnl: pos.unrealizedPnl || 0,
realizedPnl: pos.realizedPnl || 0
})),
// Additional analytics
analytics: {
drawdownSeries: this.calculateDrawdown(),
dailyReturns: this.calculateDailyReturns(),
monthlyReturns: this.calculateMonthlyReturns(),
exposureTime: this.calculateExposureTime(),
riskMetrics: this.calculateRiskMetrics()
}
}; };
await this.storeResults(result); await this.storeResults(result);
@ -484,6 +616,99 @@ export class BacktestEngine extends EventEmitter {
return drawdowns; return drawdowns;
} }
private calculateMonthlyReturns(): Record<string, number> {
const monthlyReturns: Record<string, number> = {};
const monthlyEquity = new Map<string, { start: number; end: number }>();
for (const point of this.equityCurve) {
const date = new Date(point.timestamp);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
if (!monthlyEquity.has(monthKey)) {
monthlyEquity.set(monthKey, { start: point.value, end: point.value });
} else {
const month = monthlyEquity.get(monthKey)!;
month.end = point.value;
}
}
for (const [month, values] of monthlyEquity) {
monthlyReturns[month] = ((values.end - values.start) / values.start) * 100;
}
return monthlyReturns;
}
private calculateExposureTime(): number {
if (this.trades.length === 0) return 0;
let totalExposureTime = 0;
for (const trade of this.trades) {
if (trade.exitTime) {
totalExposureTime += trade.exitTime - trade.entryTime;
}
}
// Use equity curve to determine actual trading period
const startTime = this.equityCurve.length > 0 ? this.equityCurve[0].timestamp : 0;
const endTime = this.equityCurve.length > 0 ? this.equityCurve[this.equityCurve.length - 1].timestamp : 0;
const totalTime = endTime - startTime;
return totalTime > 0 ? (totalExposureTime / totalTime) * 100 : 0;
}
private calculateRiskMetrics(): Record<string, number> {
const returns = this.calculateDailyReturns();
// Calculate various risk metrics
const avgReturn = returns.reduce((a, b) => a + b, 0) / returns.length;
const variance = returns.reduce((sum, r) => sum + Math.pow(r - avgReturn, 2), 0) / returns.length;
const stdDev = Math.sqrt(variance);
// Downside deviation (for Sortino)
const downsideReturns = returns.filter(r => r < 0);
const downsideVariance = downsideReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / downsideReturns.length;
const downsideDeviation = Math.sqrt(downsideVariance);
return {
volatility: stdDev * Math.sqrt(252), // Annualized
downsideDeviation: downsideDeviation * Math.sqrt(252),
var95: this.calculateVaR(returns, 0.95),
var99: this.calculateVaR(returns, 0.99),
cvar95: this.calculateCVaR(returns, 0.95),
maxConsecutiveLosses: this.calculateMaxConsecutiveLosses()
};
}
private calculateVaR(returns: number[], confidence: number): number {
const sorted = [...returns].sort((a, b) => a - b);
const index = Math.floor((1 - confidence) * sorted.length);
return sorted[index] || 0;
}
private calculateCVaR(returns: number[], confidence: number): number {
const sorted = [...returns].sort((a, b) => a - b);
const index = Math.floor((1 - confidence) * sorted.length);
const tail = sorted.slice(0, index + 1);
return tail.reduce((a, b) => a + b, 0) / tail.length;
}
private calculateMaxConsecutiveLosses(): number {
let maxLosses = 0;
let currentLosses = 0;
for (const trade of this.trades) {
if (trade.pnl && trade.pnl < 0) {
currentLosses++;
maxLosses = Math.max(maxLosses, currentLosses);
} else {
currentLosses = 0;
}
}
return maxLosses;
}
private calculateDailyReturns(): number[] { private calculateDailyReturns(): number[] {
const dailyReturns: number[] = []; const dailyReturns: number[] = [];
const dailyEquity = new Map<string, number>(); const dailyEquity = new Map<string, number>();

View file

@ -82,15 +82,22 @@ export class BacktestService {
const result = await response.json(); const result = await response.json();
// Store result when available // Store result directly without transformation
if (result.performance) { if (result.status === 'completed') {
// Backtest completed immediately
backtest.status = 'completed'; backtest.status = 'completed';
backtest.updatedAt = new Date();
backtestStore.set(backtestId, backtest); backtestStore.set(backtestId, backtest);
backtestResults.set(backtestId, result); backtestResults.set(backtestId, result);
logger.info('Backtest completed', {
backtestId,
trades: result.metrics?.totalTrades,
return: result.metrics?.totalReturn
});
} else { } else {
// Update status to running if not completed // Update status to running if not completed
backtest.status = 'running'; backtest.status = 'running';
backtest.updatedAt = new Date();
backtestStore.set(backtestId, backtest); backtestStore.set(backtestId, backtest);
} }
@ -109,35 +116,8 @@ export class BacktestService {
} }
async getBacktestResults(id: string): Promise<any> { async getBacktestResults(id: string): Promise<any> {
const results = backtestResults.get(id); // Return results directly without any transformation
if (!results) return null; return backtestResults.get(id) || null;
// Transform orchestrator response to frontend expected format
return {
backtestId: results.id || id,
metrics: {
totalReturn: results.performance?.totalReturn || 0,
sharpeRatio: results.performance?.sharpeRatio || 0,
maxDrawdown: results.performance?.maxDrawdown || 0,
winRate: results.performance?.winRate || 0,
totalTrades: results.performance?.totalTrades || 0,
profitFactor: results.performance?.profitFactor
},
equity: results.equityCurve?.map((point: any) => ({
date: new Date(point.timestamp).toISOString(),
value: point.value
})) || [],
trades: results.trades?.map((trade: any) => ({
symbol: trade.symbol,
entryDate: new Date(trade.entryTime).toISOString(),
exitDate: new Date(trade.exitTime).toISOString(),
entryPrice: trade.entryPrice,
exitPrice: trade.exitPrice,
quantity: trade.quantity,
pnl: trade.pnl
})) || [],
ohlcData: results.ohlcData || {}
};
} }
async listBacktests(params: { limit: number; offset: number }): Promise<BacktestJob[]> { async listBacktests(params: { limit: number; offset: number }): Promise<BacktestJob[]> {

View file

@ -1,5 +1,5 @@
import * as LightweightCharts from 'lightweight-charts'; import * as LightweightCharts from 'lightweight-charts';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useCallback } from 'react';
export interface ChartData { export interface ChartData {
time: number; time: number;
@ -41,19 +41,15 @@ export function Chart({
const volumeSeriesRef = useRef<LightweightCharts.ISeriesApi<any> | null>(null); const volumeSeriesRef = useRef<LightweightCharts.ISeriesApi<any> | null>(null);
const overlaySeriesRef = useRef<Map<string, LightweightCharts.ISeriesApi<any>>>(new Map()); const overlaySeriesRef = useRef<Map<string, LightweightCharts.ISeriesApi<any>>>(new Map());
// Debug logging // Reset zoom handler
console.log('Chart - data received:', data); const resetZoom = useCallback(() => {
console.log('Chart - data length:', data?.length); if (chartRef.current) {
console.log('Chart - data type:', Array.isArray(data) ? 'array' : typeof data); chartRef.current.timeScale().fitContent();
console.log('Chart - first data point:', data?.[0]); }
}, []);
useEffect(() => { useEffect(() => {
if (!chartContainerRef.current || !data || !data.length) { if (!chartContainerRef.current || !data || !data.length) {
console.log('Chart - early return:', {
hasContainer: !!chartContainerRef.current,
hasData: !!data,
dataLength: data?.length
});
return; return;
} }
@ -89,11 +85,27 @@ export function Chart({
borderColor: theme === 'dark' ? '#1f2937' : '#e5e7eb', borderColor: theme === 'dark' ? '#1f2937' : '#e5e7eb',
timeVisible: true, timeVisible: true,
secondsVisible: false, secondsVisible: false,
rightOffset: 12,
barSpacing: 3,
fixLeftEdge: true,
fixRightEdge: true,
}, },
}); });
chartRef.current = chart; chartRef.current = chart;
// Filter and validate data
const validateAndFilterData = (rawData: any[]) => {
const seen = new Set<number>();
return rawData.filter((item, index) => {
if (seen.has(item.time)) {
return false;
}
seen.add(item.time);
return true;
});
};
// Create main series // Create main series
if (type === 'candlestick' && data[0].open !== undefined) { if (type === 'candlestick' && data[0].open !== undefined) {
mainSeriesRef.current = chart.addCandlestickSeries({ mainSeriesRef.current = chart.addCandlestickSeries({
@ -104,7 +116,8 @@ export function Chart({
wickUpColor: '#10b981', wickUpColor: '#10b981',
wickDownColor: '#ef4444', wickDownColor: '#ef4444',
}); });
mainSeriesRef.current.setData(data as LightweightCharts.CandlestickData[]); const validData = validateAndFilterData(data);
mainSeriesRef.current.setData(validData as LightweightCharts.CandlestickData[]);
} else if (type === 'line' || (type === 'candlestick' && data[0].value !== undefined)) { } else if (type === 'line' || (type === 'candlestick' && data[0].value !== undefined)) {
mainSeriesRef.current = chart.addLineSeries({ mainSeriesRef.current = chart.addLineSeries({
color: '#3b82f6', color: '#3b82f6',
@ -114,7 +127,8 @@ export function Chart({
time: d.time, time: d.time,
value: d.value ?? d.close ?? 0 value: d.value ?? d.close ?? 0
})); }));
mainSeriesRef.current.setData(lineData); const validData = validateAndFilterData(lineData);
mainSeriesRef.current.setData(validData);
} else if (type === 'area') { } else if (type === 'area') {
mainSeriesRef.current = chart.addAreaSeries({ mainSeriesRef.current = chart.addAreaSeries({
lineColor: '#3b82f6', lineColor: '#3b82f6',
@ -126,7 +140,8 @@ export function Chart({
time: d.time, time: d.time,
value: d.value ?? d.close ?? 0 value: d.value ?? d.close ?? 0
})); }));
mainSeriesRef.current.setData(areaData); const validData = validateAndFilterData(areaData);
mainSeriesRef.current.setData(validData);
} }
// Add volume if available // Add volume if available
@ -177,12 +192,46 @@ export function Chart({
}); });
} }
series.setData(overlay.data); // Filter out duplicate timestamps and ensure ascending order
const uniqueData = overlay.data.reduce((acc: any[], curr) => {
if (!acc.length || curr.time > acc[acc.length - 1].time) {
acc.push(curr);
}
return acc;
}, []);
series.setData(uniqueData);
overlaySeriesRef.current.set(overlay.name, series); overlaySeriesRef.current.set(overlay.name, series);
}); });
// Fit content // Fit content with a slight delay to ensure all series are loaded
chart.timeScale().fitContent(); setTimeout(() => {
chart.timeScale().fitContent();
// Also set the visible range to ensure all data is shown
if (data.length > 0) {
const firstTime = data[0].time;
const lastTime = data[data.length - 1].time;
chart.timeScale().setVisibleRange({
from: firstTime as any,
to: lastTime as any,
});
}
}, 100);
// Enable mouse wheel zoom and touch gestures
chart.applyOptions({
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
horzTouchDrag: true,
vertTouchDrag: true,
},
handleScale: {
mouseWheel: true,
pinch: true,
axisPressedMouseMove: true,
},
});
// Handle resize // Handle resize
const handleResize = () => { const handleResize = () => {
@ -210,6 +259,13 @@ export function Chart({
ref={chartContainerRef} ref={chartContainerRef}
style={{ width: '100%', height: `${height}px` }} style={{ width: '100%', height: `${height}px` }}
/> />
<button
onClick={resetZoom}
className="absolute top-2 right-2 px-2 py-1 text-xs bg-surface-primary border border-border rounded hover:bg-surface-secondary transition-colors"
title="Reset zoom"
>
Reset Zoom
</button>
</div> </div>
); );
} }

View file

@ -32,40 +32,10 @@ export function BacktestPage() {
// Current time is not available in the new API, so we'll estimate it based on progress // Current time is not available in the new API, so we'll estimate it based on progress
const currentTime = null; const currentTime = null;
// Adapt the results when they come in // No adaptation needed - results are already in the correct format
useEffect(() => { useEffect(() => {
if (results && config) { setAdaptedResults(results);
setAdaptedResults({ }, [results]);
id: backtest?.id || '',
config,
metrics: {
totalReturn: results.metrics.totalReturn,
sharpeRatio: results.metrics.sharpeRatio,
maxDrawdown: results.metrics.maxDrawdown,
winRate: results.metrics.winRate,
totalTrades: results.metrics.totalTrades,
profitableTrades: Math.round(results.metrics.totalTrades * results.metrics.winRate / 100),
},
positions: [], // Not provided by current API
trades: results.trades?.map(t => ({
id: `${t.symbol}-${t.entryDate}`,
timestamp: t.exitDate,
symbol: t.symbol,
side: t.pnl > 0 ? 'buy' : 'sell',
quantity: t.quantity,
price: t.exitPrice,
commission: 0,
pnl: t.pnl,
})) || [],
performanceData: results.equity.map(e => ({
timestamp: e.date,
portfolioValue: e.value,
pnl: 0, // Would need to calculate from equity curve
drawdown: 0, // Would need to calculate
})),
});
}
}, [results, config, backtest]);
const handleConfigSubmit = useCallback(async (newConfig: BacktestConfig) => { const handleConfigSubmit = useCallback(async (newConfig: BacktestConfig) => {
setConfig(newConfig); setConfig(newConfig);

View file

@ -13,11 +13,6 @@ interface BacktestResultsProps {
} }
export function BacktestResults({ status, results, currentTime }: BacktestResultsProps) { export function BacktestResults({ status, results, currentTime }: BacktestResultsProps) {
// Debug logging
console.log('BacktestResults - results:', results);
console.log('BacktestResults - ohlcData keys:', results?.ohlcData ? Object.keys(results.ohlcData) : 'No ohlcData');
console.log('BacktestResults - first symbol data:', results?.ohlcData && Object.keys(results.ohlcData).length > 0 ? results.ohlcData[Object.keys(results.ohlcData)[0]] : 'No data');
console.log('BacktestResults - equity data:', results?.equity);
if (status === 'idle') { if (status === 'idle') {
return ( return (
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center"> <div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
@ -125,14 +120,9 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult
const hasOhlcData = results.ohlcData && Object.keys(results.ohlcData).length > 0; const hasOhlcData = results.ohlcData && Object.keys(results.ohlcData).length > 0;
const hasEquityData = results.equity && results.equity.length > 0; const hasEquityData = results.equity && results.equity.length > 0;
console.log('Chart section - hasOhlcData:', hasOhlcData);
console.log('Chart section - hasEquityData:', hasEquityData);
if (hasOhlcData) { if (hasOhlcData) {
const firstSymbol = Object.keys(results.ohlcData)[0]; const firstSymbol = Object.keys(results.ohlcData)[0];
const ohlcData = results.ohlcData[firstSymbol]; const ohlcData = results.ohlcData[firstSymbol];
console.log('Chart section - using OHLC data for symbol:', firstSymbol);
console.log('Chart section - OHLC data points:', ohlcData?.length);
return ( return (
<Chart <Chart
@ -156,7 +146,6 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult
/> />
); );
} else if (hasEquityData) { } else if (hasEquityData) {
console.log('Chart section - using equity data only');
return ( return (
<Chart <Chart
data={results.equity.map(point => ({ data={results.equity.map(point => ({
@ -171,7 +160,6 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult
/> />
); );
} else { } else {
console.log('Chart section - showing no data message');
return ( return (
<div className="h-96 bg-background rounded border border-border flex items-center justify-center"> <div className="h-96 bg-background rounded border border-border flex items-center justify-center">
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">
@ -190,13 +178,13 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult
Trade History Trade History
</h3> </h3>
<TradeLog trades={results.trades.map(trade => ({ <TradeLog trades={results.trades.map(trade => ({
id: crypto.randomUUID(), id: trade.id,
timestamp: trade.entryDate, timestamp: trade.exitDate || trade.entryDate,
symbol: trade.symbol, symbol: trade.symbol,
side: 'buy' as const, side: trade.side as 'buy' | 'sell',
quantity: trade.quantity, quantity: trade.quantity,
price: trade.entryPrice, price: trade.exitPrice,
commission: 0, commission: trade.commission,
pnl: trade.pnl pnl: trade.pnl
}))} /> }))} />
</div> </div>

View file

@ -24,26 +24,43 @@ export interface BacktestJob {
} }
export interface BacktestResult { export interface BacktestResult {
// Identification
backtestId: string; backtestId: string;
status: 'completed' | 'failed' | 'cancelled';
completedAt: string;
// Configuration
config: {
name: string;
strategy: string;
symbols: string[];
startDate: string;
endDate: string;
initialCapital: number;
commission: number;
slippage: number;
dataFrequency: string;
};
// Performance metrics
metrics: { metrics: {
totalReturn: number; totalReturn: number;
sharpeRatio: number; sharpeRatio: number;
maxDrawdown: number; maxDrawdown: number;
winRate: number; winRate: number;
totalTrades: number; totalTrades: number;
profitFactor?: number; profitFactor: number;
profitableTrades: number;
avgWin: number;
avgLoss: number;
expectancy: number;
calmarRatio: number;
sortinoRatio: number;
}; };
// Chart data
equity: Array<{ date: string; value: number }>; equity: Array<{ date: string; value: number }>;
trades?: Array<{ ohlcData: Record<string, Array<{
symbol: string;
entryDate: string;
exitDate: string;
entryPrice: number;
exitPrice: number;
quantity: number;
pnl: number;
}>;
ohlcData?: Record<string, Array<{
time: number; time: number;
open: number; open: number;
high: number; high: number;
@ -51,6 +68,41 @@ export interface BacktestResult {
close: number; close: number;
volume?: number; volume?: number;
}>>; }>>;
// Trade history
trades: Array<{
id: string;
symbol: string;
entryDate: string;
exitDate: string | null;
entryPrice: number;
exitPrice: number;
quantity: number;
side: string;
pnl: number;
pnlPercent: number;
commission: number;
duration: number;
}>;
// Positions
positions: Array<{
symbol: string;
quantity: number;
averagePrice: number;
currentPrice: number;
unrealizedPnl: number;
realizedPnl: number;
}>;
// Analytics
analytics: {
drawdownSeries: Array<{ timestamp: number; value: number }>;
dailyReturns: number[];
monthlyReturns: Record<string, number>;
exposureTime: number;
riskMetrics: Record<string, number>;
};
} }
export const backtestApi = { export const backtestApi = {