diff --git a/apps/stock/orchestrator/src/backtest/BacktestEngine.ts b/apps/stock/orchestrator/src/backtest/BacktestEngine.ts index 48f9a22..31c7efe 100644 --- a/apps/stock/orchestrator/src/backtest/BacktestEngine.ts +++ b/apps/stock/orchestrator/src/backtest/BacktestEngine.ts @@ -14,14 +14,78 @@ interface BacktestEvent { } interface BacktestResult { - id: string; - config: any; - performance: PerformanceMetrics; - trades: any[]; - equityCurve: { timestamp: number; value: number }[]; - drawdown: { timestamp: number; value: number }[]; - dailyReturns: number[]; - finalPositions: any[]; + // Identification + 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: { + 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; + + // 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; + exposureTime: number; + riskMetrics: Record; + }; } export class BacktestEngine extends EventEmitter { @@ -117,17 +181,85 @@ export class BacktestEngine extends EventEmitter { // Get final positions const finalPositions = await this.getFinalPositions(); - // Store results + // Create comprehensive frontend-ready result const result: BacktestResult = { - id: backtestId, - config: validatedConfig, - performance, - trades: this.trades, - equityCurve: this.equityCurve, - drawdown: this.calculateDrawdown(), - dailyReturns: this.calculateDailyReturns(), - finalPositions, - ohlcData: this.getOHLCData(marketData, validatedConfig.symbols) + // Identification + backtestId, + status: 'completed' as const, + completedAt: new Date().toISOString(), + + // Configuration used + config: { + name: validatedConfig.name || 'Backtest', + 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); @@ -484,6 +616,99 @@ export class BacktestEngine extends EventEmitter { return drawdowns; } + private calculateMonthlyReturns(): Record { + const monthlyReturns: Record = {}; + const monthlyEquity = new Map(); + + 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 { + 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[] { const dailyReturns: number[] = []; const dailyEquity = new Map(); diff --git a/apps/stock/web-api/src/services/backtest.service.ts b/apps/stock/web-api/src/services/backtest.service.ts index 7313f2d..f2f3288 100644 --- a/apps/stock/web-api/src/services/backtest.service.ts +++ b/apps/stock/web-api/src/services/backtest.service.ts @@ -82,15 +82,22 @@ export class BacktestService { const result = await response.json(); - // Store result when available - if (result.performance) { - // Backtest completed immediately + // Store result directly without transformation + if (result.status === 'completed') { backtest.status = 'completed'; + backtest.updatedAt = new Date(); backtestStore.set(backtestId, backtest); backtestResults.set(backtestId, result); + + logger.info('Backtest completed', { + backtestId, + trades: result.metrics?.totalTrades, + return: result.metrics?.totalReturn + }); } else { // Update status to running if not completed backtest.status = 'running'; + backtest.updatedAt = new Date(); backtestStore.set(backtestId, backtest); } @@ -109,35 +116,8 @@ export class BacktestService { } async getBacktestResults(id: string): Promise { - const results = backtestResults.get(id); - if (!results) return 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 || {} - }; + // Return results directly without any transformation + return backtestResults.get(id) || null; } async listBacktests(params: { limit: number; offset: number }): Promise { diff --git a/apps/stock/web-app/src/components/charts/Chart.tsx b/apps/stock/web-app/src/components/charts/Chart.tsx index 101cd57..55524eb 100644 --- a/apps/stock/web-app/src/components/charts/Chart.tsx +++ b/apps/stock/web-app/src/components/charts/Chart.tsx @@ -1,5 +1,5 @@ import * as LightweightCharts from 'lightweight-charts'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useCallback } from 'react'; export interface ChartData { time: number; @@ -40,20 +40,16 @@ export function Chart({ const mainSeriesRef = useRef | null>(null); const volumeSeriesRef = useRef | null>(null); const overlaySeriesRef = useRef>>(new Map()); - - // Debug logging - console.log('Chart - data received:', data); - console.log('Chart - data length:', data?.length); - console.log('Chart - data type:', Array.isArray(data) ? 'array' : typeof data); - console.log('Chart - first data point:', data?.[0]); + + // Reset zoom handler + const resetZoom = useCallback(() => { + if (chartRef.current) { + chartRef.current.timeScale().fitContent(); + } + }, []); useEffect(() => { if (!chartContainerRef.current || !data || !data.length) { - console.log('Chart - early return:', { - hasContainer: !!chartContainerRef.current, - hasData: !!data, - dataLength: data?.length - }); return; } @@ -89,11 +85,27 @@ export function Chart({ borderColor: theme === 'dark' ? '#1f2937' : '#e5e7eb', timeVisible: true, secondsVisible: false, + rightOffset: 12, + barSpacing: 3, + fixLeftEdge: true, + fixRightEdge: true, }, }); chartRef.current = chart; + // Filter and validate data + const validateAndFilterData = (rawData: any[]) => { + const seen = new Set(); + return rawData.filter((item, index) => { + if (seen.has(item.time)) { + return false; + } + seen.add(item.time); + return true; + }); + }; + // Create main series if (type === 'candlestick' && data[0].open !== undefined) { mainSeriesRef.current = chart.addCandlestickSeries({ @@ -104,7 +116,8 @@ export function Chart({ wickUpColor: '#10b981', 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)) { mainSeriesRef.current = chart.addLineSeries({ color: '#3b82f6', @@ -114,7 +127,8 @@ export function Chart({ time: d.time, value: d.value ?? d.close ?? 0 })); - mainSeriesRef.current.setData(lineData); + const validData = validateAndFilterData(lineData); + mainSeriesRef.current.setData(validData); } else if (type === 'area') { mainSeriesRef.current = chart.addAreaSeries({ lineColor: '#3b82f6', @@ -126,7 +140,8 @@ export function Chart({ time: d.time, value: d.value ?? d.close ?? 0 })); - mainSeriesRef.current.setData(areaData); + const validData = validateAndFilterData(areaData); + mainSeriesRef.current.setData(validData); } // 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); }); - // Fit content - chart.timeScale().fitContent(); + // Fit content with a slight delay to ensure all series are loaded + 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 const handleResize = () => { @@ -210,6 +259,13 @@ export function Chart({ ref={chartContainerRef} style={{ width: '100%', height: `${height}px` }} /> + ); } \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/BacktestPage.tsx b/apps/stock/web-app/src/features/backtest/BacktestPage.tsx index 947e603..c7f7369 100644 --- a/apps/stock/web-app/src/features/backtest/BacktestPage.tsx +++ b/apps/stock/web-app/src/features/backtest/BacktestPage.tsx @@ -32,40 +32,10 @@ export function BacktestPage() { // Current time is not available in the new API, so we'll estimate it based on progress const currentTime = null; - // Adapt the results when they come in + // No adaptation needed - results are already in the correct format useEffect(() => { - if (results && config) { - setAdaptedResults({ - 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]); + setAdaptedResults(results); + }, [results]); const handleConfigSubmit = useCallback(async (newConfig: BacktestConfig) => { setConfig(newConfig); diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx index fe16c3e..56d6560 100644 --- a/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx +++ b/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx @@ -13,11 +13,6 @@ interface 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') { return (
@@ -125,14 +120,9 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult const hasOhlcData = results.ohlcData && Object.keys(results.ohlcData).length > 0; const hasEquityData = results.equity && results.equity.length > 0; - console.log('Chart section - hasOhlcData:', hasOhlcData); - console.log('Chart section - hasEquityData:', hasEquityData); - if (hasOhlcData) { const firstSymbol = Object.keys(results.ohlcData)[0]; 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 ( ); } else if (hasEquityData) { - console.log('Chart section - using equity data only'); return ( ({ @@ -171,7 +160,6 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult /> ); } else { - console.log('Chart section - showing no data message'); return (

@@ -190,13 +178,13 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult Trade History ({ - id: crypto.randomUUID(), - timestamp: trade.entryDate, + id: trade.id, + timestamp: trade.exitDate || trade.entryDate, symbol: trade.symbol, - side: 'buy' as const, + side: trade.side as 'buy' | 'sell', quantity: trade.quantity, - price: trade.entryPrice, - commission: 0, + price: trade.exitPrice, + commission: trade.commission, pnl: trade.pnl }))} />

diff --git a/apps/stock/web-app/src/features/backtest/services/backtestApi.ts b/apps/stock/web-app/src/features/backtest/services/backtestApi.ts index a1ad43c..6386351 100644 --- a/apps/stock/web-app/src/features/backtest/services/backtestApi.ts +++ b/apps/stock/web-app/src/features/backtest/services/backtestApi.ts @@ -24,26 +24,43 @@ export interface BacktestJob { } export interface BacktestResult { + // Identification 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: { totalReturn: number; sharpeRatio: number; maxDrawdown: number; winRate: 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 }>; - trades?: Array<{ - symbol: string; - entryDate: string; - exitDate: string; - entryPrice: number; - exitPrice: number; - quantity: number; - pnl: number; - }>; - ohlcData?: Record>; + + // 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; + exposureTime: number; + riskMetrics: Record; + }; } export const backtestApi = {