diff --git a/apps/stock/orchestrator/src/api/rest/backtest.ts b/apps/stock/orchestrator/src/api/rest/backtest.ts index 303634c..c99dbf8 100644 --- a/apps/stock/orchestrator/src/api/rest/backtest.ts +++ b/apps/stock/orchestrator/src/api/rest/backtest.ts @@ -86,17 +86,73 @@ export function createBacktestRoutes(container: IServiceContainer): Hono { }, 500); } }); + + // Pause running backtest + app.post('/pause', async (c) => { + try { + await backtestEngine.pauseBacktest(); + + return c.json({ + message: 'Backtest paused', + timestamp: new Date().toISOString() + }); + } catch (error) { + container.logger.error('Error pausing backtest:', error); + return c.json({ + error: error instanceof Error ? error.message : 'Failed to pause backtest' + }, 500); + } + }); + + // Resume paused backtest + app.post('/resume', async (c) => { + try { + await backtestEngine.resumeBacktest(); + + return c.json({ + message: 'Backtest resumed', + timestamp: new Date().toISOString() + }); + } catch (error) { + container.logger.error('Error resuming backtest:', error); + return c.json({ + error: error instanceof Error ? error.message : 'Failed to resume backtest' + }, 500); + } + }); + + // Set backtest speed + app.post('/speed', async (c) => { + try { + const body = await c.req.json(); + const speed = body.speed ?? 1; + + backtestEngine.setSpeedMultiplier(speed); + + return c.json({ + message: 'Backtest speed updated', + speed: speed === null ? 'unlimited' : speed, + timestamp: new Date().toISOString() + }); + } catch (error) { + container.logger.error('Error setting backtest speed:', error); + return c.json({ + error: error instanceof Error ? error.message : 'Failed to set speed' + }, 500); + } + }); // Get backtest progress app.get('/progress', async (c) => { try { - // In real implementation, would track progress + const status = backtestEngine.getStatus(); + return c.json({ - status: 'running', - progress: 0.5, - processed: 10000, - total: 20000, - currentTime: new Date().toISOString() + status: status.isPaused ? 'paused' : (status.isRunning ? 'running' : 'idle'), + progress: status.progress, + currentTime: new Date(status.currentTime).toISOString(), + isRunning: status.isRunning, + isPaused: status.isPaused }); } catch (error) { container.logger.error('Error getting backtest progress:', error); diff --git a/apps/stock/orchestrator/src/backtest/BacktestEngine.ts b/apps/stock/orchestrator/src/backtest/BacktestEngine.ts index 565f2ca..9c6d10d 100644 --- a/apps/stock/orchestrator/src/backtest/BacktestEngine.ts +++ b/apps/stock/orchestrator/src/backtest/BacktestEngine.ts @@ -93,11 +93,17 @@ export class BacktestEngine extends EventEmitter { private currentTime: number = 0; private equityCurve: { timestamp: number; value: number }[] = []; private isRunning = false; + private isPaused = false; + private speedMultiplier: number | null = 1; // null means unlimited speed + private lastProcessTime = 0; private dataManager: DataManager; private marketSimulator: MarketSimulator; private performanceAnalyzer: PerformanceAnalyzer; private microstructures: Map = new Map(); private container: IServiceContainer; + private runId: string | null = null; + private progressUpdateInterval = 100; // Update progress every 100 events + private totalEvents = 0; private initialCapital: number = 100000; private commission: number = 0.001; // Default 0.1% private slippage: number = 0.0001; // Default 0.01% @@ -155,8 +161,33 @@ export class BacktestEngine extends EventEmitter { this.container.logger.info(`[BacktestEngine] Initial capital set to ${this.initialCapital}, equity curve initialized with ${this.equityCurve.length} points`); - // Generate backtest ID + // Generate backtest ID and store runId if provided const backtestId = `backtest_${Date.now()}`; + this.runId = config.runId || null; + + // Set speed multiplier from config + if (validatedConfig.speed) { + switch (validatedConfig.speed) { + case 'max': + this.speedMultiplier = null; // Unlimited speed + break; + case 'realtime': + this.speedMultiplier = 1; + break; + case '2x': + this.speedMultiplier = 2; + break; + case '5x': + this.speedMultiplier = 5; + break; + case '10x': + this.speedMultiplier = 10; + break; + default: + this.speedMultiplier = null; // Default to max speed + } + this.container.logger.info(`[BacktestEngine] Speed set to: ${validatedConfig.speed} (multiplier: ${this.speedMultiplier})`); + } try { // Load historical data with multi-resolution support @@ -486,10 +517,46 @@ export class BacktestEngine extends EventEmitter { let lastEquityUpdate = 0; const equityUpdateInterval = 60000; // Update equity every minute - this.container.logger.info(`[BacktestEngine] Processing ${this.eventQueue.length} events`); + this.totalEvents = this.eventQueue.length; + this.container.logger.info(`[BacktestEngine] Processing ${this.totalEvents} events with speed multiplier: ${this.speedMultiplier}`); + + let processedCount = 0; while (this.eventQueue.length > 0 && this.isRunning) { + // Check if paused + if (this.isPaused) { + await new Promise(resolve => setTimeout(resolve, 100)); + continue; + } + const event = this.eventQueue.shift()!; + processedCount++; + + // Apply speed control if not unlimited + if (this.speedMultiplier !== null && this.speedMultiplier > 0) { + // Base delay in milliseconds for processing each event + // For 1x speed: 1000ms per event (1 event per second) + // For 2x speed: 500ms per event (2 events per second) + // For 10x speed: 100ms per event (10 events per second) + const baseDelay = 1000; // milliseconds + const actualDelay = baseDelay / this.speedMultiplier; + + // Add the delay + if (actualDelay > 1) { + if (processedCount % 50 === 0) { // Log every 50 events to avoid spam + this.container.logger.debug(`[BacktestEngine] Applying delay: ${actualDelay}ms (speed: ${this.speedMultiplier}x)`); + } + await new Promise(resolve => setTimeout(resolve, actualDelay)); + } + + // Also send progress updates more frequently at slower speeds + if (processedCount % Math.max(1, Math.floor(10 / this.speedMultiplier)) === 0) { + const progress = Math.round((processedCount / this.totalEvents) * 100); + this.sendProgressUpdate(progress, new Date(event.timestamp).toISOString()); + } + } + + this.lastProcessTime = Date.now(); // Advance time this.currentTime = event.timestamp; @@ -518,18 +585,27 @@ export class BacktestEngine extends EventEmitter { lastEquityUpdate = this.currentTime; } - // Emit progress - if (this.eventQueue.length % 1000 === 0) { + // Send progress update via WebSocket (unless already sent in speed control) + if (this.speedMultiplier === null && processedCount % this.progressUpdateInterval === 0) { + const progress = Math.round((processedCount / this.totalEvents) * 100); + this.sendProgressUpdate(progress, new Date(this.currentTime).toISOString()); + + // Also emit local progress event this.emit('progress', { - processed: this.eventQueue.length, + processed: processedCount, + total: this.totalEvents, remaining: this.eventQueue.length, - currentTime: new Date(this.currentTime) + currentTime: new Date(this.currentTime), + progress }); } } // Final equity update await this.updateEquityCurve(); + + // Send 100% progress + this.sendProgressUpdate(100, new Date(this.currentTime).toISOString()); } private async processMarketData(data: MarketData): Promise { @@ -757,6 +833,25 @@ export class BacktestEngine extends EventEmitter { }; } + private sendProgressUpdate(progress: number, currentDate: string): void { + if (!this.runId) return; + + try { + // Try to send WebSocket update to web-api + const url = `http://localhost:2003/api/runs/${this.runId}/progress`; + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ progress, currentDate }) + }).catch(err => { + // Ignore errors - WebSocket updates are best-effort + this.container.logger.debug('Failed to send progress update:', err); + }); + } catch (error) { + // Ignore errors + } + } + private calculateDrawdown(): { timestamp: number; value: number }[] { const drawdowns: { timestamp: number; value: number }[] = []; let peak = this.equityCurve[0]?.value || 0; @@ -913,6 +1008,9 @@ export class BacktestEngine extends EventEmitter { this.equityCurve = []; this.pendingOrders.clear(); this.ordersListenerSetup = false; + this.isPaused = false; + this.speedMultiplier = 1; + this.lastProcessTime = 0; this.marketSimulator.reset(); } @@ -1195,4 +1293,37 @@ export class BacktestEngine extends EventEmitter { } } + // Playback control methods + async pauseBacktest(): Promise { + this.isPaused = true; + this.container.logger.info('Backtest paused'); + } + + async resumeBacktest(): Promise { + this.isPaused = false; + this.container.logger.info('Backtest resumed'); + } + + async stopBacktest(): Promise { + this.isRunning = false; + this.isPaused = false; + } + + setSpeedMultiplier(speed: number | null): void { + this.speedMultiplier = speed; + this.container.logger.info(`Backtest speed multiplier set to: ${speed === null ? 'unlimited' : speed}`); + } + + getStatus(): { isRunning: boolean; isPaused: boolean; progress: number; currentTime: number } { + const progress = this.totalEvents > 0 + ? Math.round(((this.totalEvents - this.eventQueue.length) / this.totalEvents) * 100) + : 0; + + return { + isRunning: this.isRunning, + isPaused: this.isPaused, + progress, + currentTime: this.currentTime + }; + } } \ No newline at end of file diff --git a/apps/stock/orchestrator/src/services/StorageService.ts b/apps/stock/orchestrator/src/services/StorageService.ts index 944c55a..b44a1a3 100644 --- a/apps/stock/orchestrator/src/services/StorageService.ts +++ b/apps/stock/orchestrator/src/services/StorageService.ts @@ -298,25 +298,41 @@ export class StorageService { const msPerInterval = this.getIntervalMilliseconds(interval); let currentTime = new Date(startTime); - // Starting price based on symbol - let basePrice = symbol === 'AAPL' ? 150 : - symbol === 'MSFT' ? 300 : - symbol === 'GOOGL' ? 120 : 100; + // Use current timestamp to seed randomness for different data each run + const seed = Date.now(); + const seedMultiplier = (seed % 1000) / 1000; // 0-1 based on milliseconds + + // Starting price based on symbol with some randomness + let basePrice = symbol === 'AAPL' ? 150 + (seedMultiplier * 20 - 10) : + symbol === 'MSFT' ? 300 + (seedMultiplier * 30 - 15) : + symbol === 'GOOGL' ? 120 + (seedMultiplier * 15 - 7.5) : + 100 + (seedMultiplier * 10 - 5); + + // Random walk seed to make each run different + let walkSeed = seedMultiplier; while (currentTime <= endTime) { + // Use a pseudo-random based on walkSeed for deterministic but different results + walkSeed = (walkSeed * 9.73 + 0.27) % 1; + // Random walk with trend - increased volatility for testing const trend = 0.0002; // Slight upward trend - const volatility = 0.01; // 1% volatility (increased from 0.2%) - const change = (Math.random() - 0.5 + trend) * volatility; + const volatility = 0.01; // 1% volatility + const change = (walkSeed - 0.5 + trend) * volatility; basePrice *= (1 + change); // Generate OHLC data with more realistic volatility - const open = basePrice * (1 + (Math.random() - 0.5) * 0.005); + const openSeed = (walkSeed * 7.13 + 0.31) % 1; + const highSeed = (walkSeed * 5.17 + 0.41) % 1; + const lowSeed = (walkSeed * 3.19 + 0.59) % 1; + const volumeSeed = (walkSeed * 11.23 + 0.67) % 1; + + const open = basePrice * (1 + (openSeed - 0.5) * 0.005); const close = basePrice; - const high = Math.max(open, close) * (1 + Math.random() * 0.008); - const low = Math.min(open, close) * (1 - Math.random() * 0.008); - const volume = 1000000 + Math.random() * 500000; + const high = Math.max(open, close) * (1 + highSeed * 0.008); + const low = Math.min(open, close) * (1 - lowSeed * 0.008); + const volume = 1000000 + volumeSeed * 500000; bars.push({ symbol, @@ -332,6 +348,8 @@ export class StorageService { currentTime = new Date(currentTime.getTime() + msPerInterval); } + this.container.logger.info(`Generated ${bars.length} mock bars for ${symbol} with seed ${seed}`); + return bars; } diff --git a/apps/stock/web-api/src/index.ts b/apps/stock/web-api/src/index.ts index baa3029..0d5365e 100644 --- a/apps/stock/web-api/src/index.ts +++ b/apps/stock/web-api/src/index.ts @@ -1,13 +1,10 @@ /** * Stock Bot Web API - * Simplified entry point using ServiceApplication framework + * Entry point with WebSocket support */ -import { ServiceApplication } from '@stock-bot/di'; import { getLogger } from '@stock-bot/logger'; import { initializeStockConfig } from '@stock-bot/stock-config'; -// Local imports -import { createRoutes } from './routes/create-routes'; // Initialize configuration with service-specific overrides const config = initializeStockConfig('webApi'); @@ -23,66 +20,10 @@ if (config.queue) { const logger = getLogger('web-api'); logger.info('Service configuration:', config); -// Create service application -const app = new ServiceApplication( - config, - { - serviceName: 'web-api', - enableHandlers: false, // Web API doesn't use handlers - enableScheduledJobs: false, // Web API doesn't use scheduled jobs - corsConfig: { - origin: ['http://localhost:4200', 'http://localhost:3000', 'http://localhost:3002', 'http://localhost:5173', 'http://localhost:5174'], - allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'Authorization'], - credentials: true, - }, - serviceMetadata: { - version: '1.0.0', - description: 'Stock Bot REST API', - endpoints: { - health: '/health', - exchanges: '/api/exchanges', - }, - }, - }, - { - // Custom lifecycle hooks - onStarted: _port => { - const logger = getLogger('web-api'); - logger.info('Web API service startup initiated with ServiceApplication framework'); - }, - } -); - -// Container factory function -async function createContainer(config: any) { - const { ServiceContainerBuilder } = await import('@stock-bot/di'); - - const container = await new ServiceContainerBuilder() - .withConfig(config) - .withOptions({ - enableQuestDB: false, // Disable QuestDB for now - enableMongoDB: true, - enablePostgres: true, - enableCache: true, - enableQueue: true, // Enable for pipeline operations - enableBrowser: false, // Web API doesn't need browser - enableProxy: false, // Web API doesn't need proxy - }) - .build(); // This automatically initializes services - - // Run database migrations - if (container.postgres) { - const { runMigrations } = await import('./migrations/migration-runner'); - await runMigrations(container); - } - - return container; -} - -// Start the service -app.start(createContainer, createRoutes).catch(error => { - const logger = getLogger('web-api'); - logger.fatal('Failed to start web API service', { error }); - process.exit(1); +// Import and start WebSocket-enabled server +import('./server-with-websocket').then(({ startServerWithWebSocket }) => { + startServerWithWebSocket().catch(error => { + logger.fatal('Failed to start web API service with WebSocket', { error }); + process.exit(1); + }); }); diff --git a/apps/stock/web-api/src/routes/backtests-v2.router.ts b/apps/stock/web-api/src/routes/backtests-v2.router.ts new file mode 100644 index 0000000..c511f1c --- /dev/null +++ b/apps/stock/web-api/src/routes/backtests-v2.router.ts @@ -0,0 +1,178 @@ +import { Router } from 'express'; +import { BacktestServiceV2 } from '../services/backtest-v2.service'; +import type { IServiceContainer } from '@stock-bot/handlers'; +import { getLogger } from '@stock-bot/logger'; + +const logger = getLogger('backtests-v2-router'); + +export function createBacktestsV2Router(container: IServiceContainer): Router { + const router = Router(); + const backtestService = new BacktestServiceV2(container); + + // Backtest endpoints + router.get('/backtests', async (req, res) => { + try { + const limit = parseInt(req.query.limit as string) || 50; + const offset = parseInt(req.query.offset as string) || 0; + + const backtests = await backtestService.listBacktests({ limit, offset }); + res.json(backtests); + } catch (error) { + logger.error('Failed to list backtests', error); + res.status(500).json({ error: 'Failed to list backtests' }); + } + }); + + router.get('/backtests/:id', async (req, res) => { + try { + const backtest = await backtestService.getBacktest(req.params.id); + if (!backtest) { + return res.status(404).json({ error: 'Backtest not found' }); + } + res.json(backtest); + } catch (error) { + logger.error('Failed to get backtest', error); + res.status(500).json({ error: 'Failed to get backtest' }); + } + }); + + router.post('/backtests', async (req, res) => { + try { + const backtest = await backtestService.createBacktest(req.body); + res.status(201).json(backtest); + } catch (error) { + logger.error('Failed to create backtest', error); + res.status(500).json({ error: 'Failed to create backtest' }); + } + }); + + router.put('/backtests/:id', async (req, res) => { + try { + const backtest = await backtestService.updateBacktest(req.params.id, req.body); + if (!backtest) { + return res.status(404).json({ error: 'Backtest not found' }); + } + res.json(backtest); + } catch (error) { + logger.error('Failed to update backtest', error); + res.status(500).json({ error: 'Failed to update backtest' }); + } + }); + + router.delete('/backtests/:id', async (req, res) => { + try { + await backtestService.deleteBacktest(req.params.id); + res.status(204).send(); + } catch (error) { + logger.error('Failed to delete backtest', error); + res.status(500).json({ error: 'Failed to delete backtest' }); + } + }); + + // Run endpoints + router.get('/backtests/:backtestId/runs', async (req, res) => { + try { + const runs = await backtestService.listRuns(req.params.backtestId); + res.json(runs); + } catch (error) { + logger.error('Failed to list runs', error); + res.status(500).json({ error: 'Failed to list runs' }); + } + }); + + router.post('/backtests/:backtestId/runs', async (req, res) => { + try { + const run = await backtestService.createRun({ + backtestId: req.params.backtestId, + speedMultiplier: req.body.speedMultiplier + }); + res.status(201).json(run); + } catch (error) { + logger.error('Failed to create run', error); + res.status(500).json({ error: 'Failed to create run' }); + } + }); + + router.get('/runs/:id', async (req, res) => { + try { + const run = await backtestService.getRun(req.params.id); + if (!run) { + return res.status(404).json({ error: 'Run not found' }); + } + res.json(run); + } catch (error) { + logger.error('Failed to get run', error); + res.status(500).json({ error: 'Failed to get run' }); + } + }); + + router.get('/runs/:id/results', async (req, res) => { + try { + const results = await backtestService.getRunResults(req.params.id); + if (!results) { + return res.status(404).json({ error: 'Results not found' }); + } + res.json(results); + } catch (error) { + logger.error('Failed to get run results', error); + res.status(500).json({ error: 'Failed to get run results' }); + } + }); + + router.post('/runs/:id/pause', async (req, res) => { + try { + await backtestService.pauseRun(req.params.id); + res.json({ message: 'Run paused' }); + } catch (error) { + logger.error('Failed to pause run', error); + res.status(500).json({ error: 'Failed to pause run' }); + } + }); + + router.post('/runs/:id/resume', async (req, res) => { + try { + await backtestService.resumeRun(req.params.id); + res.json({ message: 'Run resumed' }); + } catch (error) { + logger.error('Failed to resume run', error); + res.status(500).json({ error: 'Failed to resume run' }); + } + }); + + router.post('/runs/:id/cancel', async (req, res) => { + try { + await backtestService.cancelRun(req.params.id); + res.json({ message: 'Run cancelled' }); + } catch (error) { + logger.error('Failed to cancel run', error); + res.status(500).json({ error: 'Failed to cancel run' }); + } + }); + + router.put('/runs/:id/speed', async (req, res) => { + try { + await backtestService.updateRunSpeed(req.params.id, req.body.speedMultiplier); + res.json({ message: 'Speed updated' }); + } catch (error) { + logger.error('Failed to update run speed', error); + res.status(500).json({ error: 'Failed to update run speed' }); + } + }); + + // WebSocket endpoint for real-time run updates + router.ws('/runs/:id/stream', (ws, req) => { + const runId = req.params.id; + logger.info('WebSocket connection established for run', { runId }); + + // TODO: Implement real-time updates + ws.on('message', (msg) => { + logger.debug('Received WebSocket message', { runId, msg }); + }); + + ws.on('close', () => { + logger.info('WebSocket connection closed', { runId }); + }); + }); + + return router; +} \ No newline at end of file diff --git a/apps/stock/web-api/src/routes/backtests-v2.routes.ts b/apps/stock/web-api/src/routes/backtests-v2.routes.ts new file mode 100644 index 0000000..2f74996 --- /dev/null +++ b/apps/stock/web-api/src/routes/backtests-v2.routes.ts @@ -0,0 +1,196 @@ +import { Hono } from 'hono'; +import type { IServiceContainer } from '@stock-bot/handlers'; +import { BacktestServiceV2 } from '../services/backtest-v2.service'; +import { getLogger } from '@stock-bot/logger'; + +const logger = getLogger('backtests-v2-routes'); + +export function createBacktestV2Routes(container: IServiceContainer) { + const app = new Hono(); + const backtestService = new BacktestServiceV2(container); + + // Backtest endpoints + app.get('/api/v2/backtests', async (c) => { + try { + const limit = parseInt(c.req.query('limit') || '50'); + const offset = parseInt(c.req.query('offset') || '0'); + + const backtests = await backtestService.listBacktests({ limit, offset }); + return c.json(backtests); + } catch (error) { + logger.error('Failed to list backtests', error); + return c.json({ error: 'Failed to list backtests' }, 500); + } + }); + + app.get('/api/v2/backtests/:id', async (c) => { + try { + const backtest = await backtestService.getBacktest(c.req.param('id')); + if (!backtest) { + return c.json({ error: 'Backtest not found' }, 404); + } + return c.json(backtest); + } catch (error) { + logger.error('Failed to get backtest', error); + return c.json({ error: 'Failed to get backtest' }, 500); + } + }); + + app.post('/api/v2/backtests', async (c) => { + try { + const body = await c.req.json(); + const backtest = await backtestService.createBacktest(body); + return c.json(backtest, 201); + } catch (error) { + logger.error('Failed to create backtest', error); + return c.json({ error: 'Failed to create backtest' }, 500); + } + }); + + app.put('/api/v2/backtests/:id', async (c) => { + try { + const body = await c.req.json(); + const backtest = await backtestService.updateBacktest(c.req.param('id'), body); + if (!backtest) { + return c.json({ error: 'Backtest not found' }, 404); + } + return c.json(backtest); + } catch (error) { + logger.error('Failed to update backtest', error); + return c.json({ error: 'Failed to update backtest' }, 500); + } + }); + + app.delete('/api/v2/backtests/:id', async (c) => { + try { + await backtestService.deleteBacktest(c.req.param('id')); + return c.text('', 204); + } catch (error) { + logger.error('Failed to delete backtest', error); + return c.json({ error: 'Failed to delete backtest' }, 500); + } + }); + + // Run endpoints + app.get('/api/v2/backtests/:backtestId/runs', async (c) => { + try { + const runs = await backtestService.listRuns(c.req.param('backtestId')); + return c.json(runs); + } catch (error) { + logger.error('Failed to list runs', error); + return c.json({ error: 'Failed to list runs' }, 500); + } + }); + + app.post('/api/v2/backtests/:backtestId/runs', async (c) => { + try { + const body = await c.req.json(); + const run = await backtestService.createRun({ + backtestId: c.req.param('backtestId'), + speedMultiplier: body.speedMultiplier + }); + return c.json(run, 201); + } catch (error) { + logger.error('Failed to create run', error); + return c.json({ error: 'Failed to create run' }, 500); + } + }); + + app.get('/api/v2/runs/:id', async (c) => { + try { + const run = await backtestService.getRun(c.req.param('id')); + if (!run) { + return c.json({ error: 'Run not found' }, 404); + } + return c.json(run); + } catch (error) { + logger.error('Failed to get run', error); + return c.json({ error: 'Failed to get run' }, 500); + } + }); + + app.get('/api/v2/runs/:id/results', async (c) => { + try { + const results = await backtestService.getRunResults(c.req.param('id')); + if (!results) { + return c.json({ error: 'Results not found' }, 404); + } + return c.json(results); + } catch (error) { + logger.error('Failed to get run results', error); + return c.json({ error: 'Failed to get run results' }, 500); + } + }); + + app.post('/api/v2/runs/:id/pause', async (c) => { + try { + await backtestService.pauseRun(c.req.param('id')); + return c.json({ message: 'Run paused' }); + } catch (error) { + logger.error('Failed to pause run', error); + return c.json({ error: 'Failed to pause run' }, 500); + } + }); + + app.post('/api/v2/runs/:id/resume', async (c) => { + try { + await backtestService.resumeRun(c.req.param('id')); + return c.json({ message: 'Run resumed' }); + } catch (error) { + logger.error('Failed to resume run', error); + return c.json({ error: 'Failed to resume run' }, 500); + } + }); + + app.post('/api/v2/runs/:id/cancel', async (c) => { + try { + await backtestService.cancelRun(c.req.param('id')); + return c.json({ message: 'Run cancelled' }); + } catch (error) { + logger.error('Failed to cancel run', error); + return c.json({ error: 'Failed to cancel run' }, 500); + } + }); + + app.put('/api/v2/runs/:id/speed', async (c) => { + try { + const body = await c.req.json(); + await backtestService.updateRunSpeed(c.req.param('id'), body.speedMultiplier); + return c.json({ message: 'Speed updated' }); + } catch (error) { + logger.error('Failed to update run speed', error); + return c.json({ error: 'Failed to update run speed' }, 500); + } + }); + + // Progress update endpoint (called by orchestrator) + app.post('/api/runs/:id/progress', async (c) => { + try { + const runId = c.req.param('id'); + const body = await c.req.json(); + const { progress, currentDate } = body; + + // Update run progress in database + await backtestService.updateRunStatus(runId, 'running', { + progress, + currentDate + }); + + // Broadcast via WebSocket if handler is available + const wsHandler = (global as any).wsHandler; + if (wsHandler) { + wsHandler.sendProgressUpdate(runId, progress, currentDate); + } + + return c.json({ message: 'Progress updated' }); + } catch (error) { + logger.error('Failed to update run progress', error); + return c.json({ error: 'Failed to update run progress' }, 500); + } + }); + + // TODO: WebSocket endpoint for real-time run updates + // This needs additional setup with Hono WebSocket support + + return app; +} \ No newline at end of file diff --git a/apps/stock/web-api/src/routes/create-routes.ts b/apps/stock/web-api/src/routes/create-routes.ts index 0e084b4..b4e0f1a 100644 --- a/apps/stock/web-api/src/routes/create-routes.ts +++ b/apps/stock/web-api/src/routes/create-routes.ts @@ -4,15 +4,25 @@ */ import { Hono } from 'hono'; +import { cors } from 'hono/cors'; import type { IServiceContainer } from '@stock-bot/handlers'; import { createExchangeRoutes } from './exchange.routes'; import { createHealthRoutes } from './health.routes'; import { createMonitoringRoutes } from './monitoring.routes'; import { createPipelineRoutes } from './pipeline.routes'; import { createBacktestRoutes } from './backtest.routes'; +import { createBacktestV2Routes } from './backtests-v2.routes'; export function createRoutes(container: IServiceContainer): Hono { const app = new Hono(); + + // Add CORS middleware + app.use('*', cors({ + origin: ['http://localhost:4200', 'http://localhost:3000', 'http://localhost:3002', 'http://localhost:5173', 'http://localhost:5174'], + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + credentials: true, + })); // Create routes with container const healthRoutes = createHealthRoutes(container); @@ -20,6 +30,7 @@ export function createRoutes(container: IServiceContainer): Hono { const monitoringRoutes = createMonitoringRoutes(container); const pipelineRoutes = createPipelineRoutes(container); const backtestRoutes = createBacktestRoutes(container); + const backtestV2Routes = createBacktestV2Routes(container); // Mount routes app.route('/health', healthRoutes); @@ -27,6 +38,7 @@ export function createRoutes(container: IServiceContainer): Hono { app.route('/api/system/monitoring', monitoringRoutes); app.route('/api/pipeline', pipelineRoutes); app.route('/', backtestRoutes); // Mounted at root since routes already have /api prefix + app.route('/', backtestV2Routes); // V2 routes also mounted at root return app; } diff --git a/apps/stock/web-api/src/routes/websocket.routes.ts b/apps/stock/web-api/src/routes/websocket.routes.ts new file mode 100644 index 0000000..38366d5 --- /dev/null +++ b/apps/stock/web-api/src/routes/websocket.routes.ts @@ -0,0 +1,47 @@ +import type { IServiceContainer } from '@stock-bot/handlers'; +import { createWebSocketHandler } from '../websocket/run-updates'; +import { getLogger } from '@stock-bot/logger'; + +const logger = getLogger('websocket-routes'); + +export function createWebSocketRoute(container: IServiceContainer) { + const wsHandler = createWebSocketHandler(container); + + // Make the handler available globally for other services + (global as any).wsHandler = wsHandler; + + return (request: Request, server: any) => { + const url = new URL(request.url); + const runId = url.searchParams.get('runId'); + + if (!runId) { + return new Response('Missing runId parameter', { status: 400 }); + } + + logger.info(`WebSocket upgrade request for run: ${runId}`); + + // Upgrade the connection to WebSocket + const success = server.upgrade(request, { + data: { runId } + }); + + if (success) { + logger.info(`WebSocket upgrade successful for run: ${runId}`); + // Return undefined to indicate successful upgrade + return; + } else { + logger.error(`WebSocket upgrade failed for run: ${runId}`); + return new Response('WebSocket upgrade failed', { status: 500 }); + } + }; +} + +// WebSocket connection handler for Bun +export function handleWebSocketConnection(ws: any) { + const { runId } = ws.data; + const wsHandler = (global as any).wsHandler; + + if (wsHandler && runId) { + wsHandler.handleConnection(ws, runId); + } +} \ No newline at end of file diff --git a/apps/stock/web-api/src/server-with-websocket.ts b/apps/stock/web-api/src/server-with-websocket.ts new file mode 100644 index 0000000..b2be9bb --- /dev/null +++ b/apps/stock/web-api/src/server-with-websocket.ts @@ -0,0 +1,131 @@ +import { getLogger } from '@stock-bot/logger'; +import { initializeStockConfig } from '@stock-bot/stock-config'; +import { createRoutes } from './routes/create-routes'; +import { createWebSocketRoute, handleWebSocketConnection } from './routes/websocket.routes'; +import type { IServiceContainer } from '@stock-bot/handlers'; + +// Initialize configuration with service-specific overrides +const config = initializeStockConfig('webApi'); + +// Override queue settings for web-api (no workers needed) +if (config.queue) { + config.queue.workers = 0; + config.queue.concurrency = 0; + config.queue.enableScheduledJobs = false; +} + +// Log the full configuration +const logger = getLogger('web-api'); +logger.info('Service configuration:', config); + +// Container factory function +async function createContainer(config: any) { + const { ServiceContainerBuilder } = await import('@stock-bot/di'); + + const container = await new ServiceContainerBuilder() + .withConfig(config) + .withOptions({ + enableQuestDB: false, + enableMongoDB: true, + enablePostgres: true, + enableCache: true, + enableQueue: true, + enableBrowser: false, + enableProxy: false, + }) + .build(); + + // Run database migrations + if (container.postgres) { + const { runMigrations } = await import('./migrations/migration-runner'); + await runMigrations(container); + } + + return container; +} + +// Custom server start function with WebSocket support +export async function startServerWithWebSocket() { + let container: IServiceContainer | null = null; + + try { + // Create container + const diContainer = await createContainer(config); + container = diContainer.resolve('serviceContainer'); + + // Create HTTP routes + const routes = createRoutes(container); + + // Create WebSocket route handler + const wsRoute = createWebSocketRoute(container); + + // Start server with WebSocket support + const port = config.service.port || 2003; + + logger.info(`Starting server on port ${port}...`); + + let server; + try { + server = Bun.serve({ + port, + + // Handle HTTP requests + async fetch(req, server) { + const url = new URL(req.url); + + // Check if this is a WebSocket upgrade request + if (url.pathname === '/ws' && req.headers.get('upgrade') === 'websocket') { + return wsRoute(req, server); + } + + // Otherwise handle as normal HTTP request + return routes.fetch(req); + }, + + // WebSocket handlers + websocket: { + open(ws) { + handleWebSocketConnection(ws); + }, + + message(ws, message) { + // Message handling is done in the WebSocket handler + // No need to echo messages back + }, + + close(ws, code, reason) { + logger.info('WebSocket closed', { code, reason }); + }, + + error(ws, error) { + logger.error('WebSocket error', error); + } + }, + + development: config.environment === 'development', + }); + } catch (error) { + if (error instanceof Error && error.message.includes('port')) { + logger.error(`Port ${port} is already in use. Please stop any other servers running on this port.`); + throw new Error(`Port ${port} is already in use`); + } + throw error; + } + + logger.info(`Web API service with WebSocket support started on port ${server.port}`); + logger.info(`WebSocket endpoint available at ws://localhost:${server.port}/ws`); + + // Handle shutdown + process.on('SIGINT', async () => { + logger.info('Shutting down server...'); + server.stop(); + process.exit(0); + }); + + } catch (error) { + logger.fatal('Failed to start web API service', { error }); + process.exit(1); + } +} + +// Export the function - don't start automatically \ No newline at end of file diff --git a/apps/stock/web-api/src/services/backtest-v2.service.ts b/apps/stock/web-api/src/services/backtest-v2.service.ts new file mode 100644 index 0000000..a5bd60f --- /dev/null +++ b/apps/stock/web-api/src/services/backtest-v2.service.ts @@ -0,0 +1,569 @@ +import { v4 as uuidv4 } from 'uuid'; +import { getLogger } from '@stock-bot/logger'; +import type { IServiceContainer } from '@stock-bot/handlers'; +import type { WebSocketHandler } from '../websocket/run-updates'; + +const logger = getLogger('backtest-v2-service'); + +// Use environment variable or default +const ORCHESTRATOR_URL = process.env.ORCHESTRATOR_URL || 'http://localhost:2004'; + +export interface BacktestRequest { + name: string; + strategy: string; + symbols: string[]; + startDate: string; + endDate: string; + initialCapital: number; + config?: Record; +} + +export interface Backtest { + id: string; + name: string; + strategy: string; + symbols: string[]; + startDate: Date; + endDate: Date; + initialCapital: number; + config: Record; + createdAt: Date; + updatedAt: Date; +} + +export interface Run { + id: string; + backtestId: string; + runNumber: number; + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused'; + speedMultiplier: number; + error?: string; + startedAt?: Date; + completedAt?: Date; + pausedAt?: Date; + progress: number; + currentDate?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface RunRequest { + backtestId: string; + speedMultiplier?: number | null; +} + +export class BacktestServiceV2 { + private container: IServiceContainer; + private activeRuns: Map = new Map(); // Track active WebSocket connections + private wsHandler: WebSocketHandler | null = null; + + constructor(container: IServiceContainer) { + this.container = container; + logger.info('BacktestServiceV2 initialized'); + } + + // Backtest CRUD operations + async createBacktest(request: BacktestRequest): Promise { + const backtestId = uuidv4(); + + const result = await this.container.postgres.query( + `INSERT INTO backtests + (id, name, strategy, symbols, start_date, end_date, initial_capital, config) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + backtestId, + request.name, + request.strategy, + JSON.stringify(request.symbols), + request.startDate, + request.endDate, + request.initialCapital, + JSON.stringify(request.config || {}) + ] + ); + + const backtest = this.mapBacktest(result.rows[0]); + + // Automatically create and start a run for the new backtest + try { + await this.createRun({ + backtestId: backtest.id, + speedMultiplier: null // Max speed (instant) + }); + } catch (error) { + logger.error('Failed to auto-create run for new backtest', { backtestId: backtest.id, error }); + // Don't fail the backtest creation if run creation fails + } + + return backtest; + } + + async getBacktest(id: string): Promise { + const result = await this.container.postgres.query( + 'SELECT * FROM backtests WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return this.mapBacktest(result.rows[0]); + } + + async updateBacktest(id: string, request: Partial): Promise { + const updates: string[] = []; + const values: any[] = []; + let paramCount = 1; + + if (request.name !== undefined) { + updates.push(`name = $${paramCount++}`); + values.push(request.name); + } + if (request.strategy !== undefined) { + updates.push(`strategy = $${paramCount++}`); + values.push(request.strategy); + } + if (request.symbols !== undefined) { + updates.push(`symbols = $${paramCount++}`); + values.push(JSON.stringify(request.symbols)); + } + if (request.startDate !== undefined) { + updates.push(`start_date = $${paramCount++}`); + values.push(request.startDate); + } + if (request.endDate !== undefined) { + updates.push(`end_date = $${paramCount++}`); + values.push(request.endDate); + } + if (request.initialCapital !== undefined) { + updates.push(`initial_capital = $${paramCount++}`); + values.push(request.initialCapital); + } + if (request.config !== undefined) { + updates.push(`config = $${paramCount++}`); + values.push(JSON.stringify(request.config)); + } + + if (updates.length === 0) { + return this.getBacktest(id); + } + + values.push(id); + const result = await this.container.postgres.query( + `UPDATE backtests + SET ${updates.join(', ')}, updated_at = NOW() + WHERE id = $${paramCount} + RETURNING *`, + values + ); + + if (result.rows.length === 0) { + return null; + } + + return this.mapBacktest(result.rows[0]); + } + + async listBacktests(params: { limit: number; offset: number }): Promise { + const result = await this.container.postgres.query( + `SELECT * FROM backtests + ORDER BY created_at DESC + LIMIT $1 OFFSET $2`, + [params.limit, params.offset] + ); + + return result.rows.map(row => this.mapBacktest(row)); + } + + async deleteBacktest(id: string): Promise { + await this.container.postgres.query( + 'DELETE FROM backtests WHERE id = $1', + [id] + ); + } + + // Run operations + async createRun(request: RunRequest): Promise { + const runId = uuidv4(); + + // Get the next run number for this backtest + const runNumberResult = await this.container.postgres.query( + 'SELECT COALESCE(MAX(run_number), 0) + 1 as next_run_number FROM runs WHERE backtest_id = $1', + [request.backtestId] + ); + const runNumber = runNumberResult.rows[0].next_run_number; + + const result = await this.container.postgres.query( + `INSERT INTO runs + (id, backtest_id, run_number, speed_multiplier, status) + VALUES ($1, $2, $3, $4, 'pending') + RETURNING *`, + [ + runId, + request.backtestId, + runNumber, + request.speedMultiplier ?? 1.0 + ] + ); + + const run = this.mapRun(result.rows[0]); + + // Start the run immediately + this.startRun(run); + + return run; + } + + async getRun(id: string): Promise { + const result = await this.container.postgres.query( + 'SELECT * FROM runs WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return this.mapRun(result.rows[0]); + } + + async listRuns(backtestId: string): Promise { + const result = await this.container.postgres.query( + `SELECT * FROM runs + WHERE backtest_id = $1 + ORDER BY run_number DESC`, + [backtestId] + ); + + return result.rows.map(row => this.mapRun(row)); + } + + async updateRunStatus( + id: string, + status: Run['status'], + updates?: { + error?: string; + progress?: number; + currentDate?: Date; + speedMultiplier?: number; + } + ): Promise { + const setClauses = ['status = $2', 'updated_at = NOW()']; + const values: any[] = [id, status]; + let paramCount = 3; + + if (status === 'running' && !updates?.currentDate) { + setClauses.push(`started_at = NOW()`); + } + if (status === 'completed' || status === 'failed' || status === 'cancelled') { + setClauses.push(`completed_at = NOW()`); + } + if (status === 'paused') { + setClauses.push(`paused_at = NOW()`); + } + + if (updates?.error !== undefined) { + setClauses.push(`error = $${paramCount++}`); + values.push(updates.error); + } + if (updates?.progress !== undefined) { + setClauses.push(`progress = $${paramCount++}`); + values.push(updates.progress); + } + if (updates?.currentDate !== undefined) { + setClauses.push(`current_simulation_date = $${paramCount++}`); + values.push(updates.currentDate); + } + if (updates?.speedMultiplier !== undefined) { + setClauses.push(`speed_multiplier = $${paramCount++}`); + values.push(updates.speedMultiplier); + } + + await this.container.postgres.query( + `UPDATE runs SET ${setClauses.join(', ')} WHERE id = $1`, + values + ); + + // Send WebSocket update if handler is available + if (this.getWebSocketHandler()) { + this.getWebSocketHandler().broadcastRunUpdate(id, { + type: 'run_update', + runId: id, + data: { + status, + ...updates, + timestamp: new Date().toISOString() + } + }); + } + } + + async pauseRun(id: string): Promise { + await this.updateRunStatus(id, 'paused'); + + // Send pause command to orchestrator + try { + const response = await fetch(`${ORCHESTRATOR_URL}/api/backtest/pause`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + logger.warn('Failed to pause backtest in orchestrator'); + } + } catch (error) { + logger.error('Error pausing backtest in orchestrator', error); + } + } + + async resumeRun(id: string): Promise { + const run = await this.getRun(id); + if (!run || run.status !== 'paused') { + throw new Error('Run is not paused'); + } + + await this.updateRunStatus(id, 'running'); + + // Send resume command to orchestrator + try { + const response = await fetch(`${ORCHESTRATOR_URL}/api/backtest/resume`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + logger.warn('Failed to resume backtest in orchestrator'); + } + } catch (error) { + logger.error('Error resuming backtest in orchestrator', error); + } + } + + async cancelRun(id: string): Promise { + await this.updateRunStatus(id, 'cancelled'); + // TODO: Send cancel command to orchestrator + } + + async updateRunSpeed(id: string, speedMultiplier: number | null): Promise { + await this.updateRunStatus(id, 'running', { speedMultiplier }); + + // Send speed update to orchestrator + try { + const response = await fetch(`${ORCHESTRATOR_URL}/api/backtest/speed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ speed: speedMultiplier }) + }); + + if (!response.ok) { + logger.warn('Failed to update backtest speed in orchestrator'); + } + } catch (error) { + logger.error('Error updating backtest speed in orchestrator', error); + } + } + + // Get run results + async getRunResults(runId: string): Promise { + const result = await this.container.postgres.query( + `SELECT + br.*, + r.backtest_id, + b.strategy, + b.symbols, + b.start_date, + b.end_date, + b.initial_capital, + b.config as backtest_config + FROM backtest_results br + JOIN runs r ON r.id = br.run_id + JOIN backtests b ON b.id = r.backtest_id + WHERE br.run_id = $1`, + [runId] + ); + + if (result.rows.length === 0) { + return null; + } + + const row = result.rows[0]; + + return { + runId: row.run_id, + backtestId: row.backtest_id, + status: 'completed', + completedAt: row.completed_at.toISOString(), + config: { + name: row.backtest_config?.name || 'Backtest', + strategy: row.strategy, + symbols: row.symbols, + startDate: row.start_date.toISOString(), + endDate: row.end_date.toISOString(), + initialCapital: parseFloat(row.initial_capital), + commission: row.backtest_config?.commission ?? 0.001, + slippage: row.backtest_config?.slippage ?? 0.0001, + dataFrequency: row.backtest_config?.dataFrequency || '1d', + }, + metrics: row.metrics, + equity: row.equity_curve, + ohlcData: row.ohlc_data, + trades: row.trades, + positions: row.positions, + analytics: row.analytics, + executionTime: row.execution_time, + }; + } + + // Private methods + private async startRun(run: Run): Promise { + const backtest = await this.getBacktest(run.backtestId); + if (!backtest) { + throw new Error('Backtest not found'); + } + + await this.updateRunStatus(run.id, 'running', { progress: 0 }); + + // Send initial WebSocket update + if (this.getWebSocketHandler()) { + this.getWebSocketHandler().sendProgressUpdate(run.id, 0); + } + + try { + // Call orchestrator to run backtest + const response = await fetch(`${ORCHESTRATOR_URL}/api/backtest/run`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + runId: run.id, + mode: 'backtest', + startDate: new Date(backtest.startDate).toISOString(), + endDate: new Date(backtest.endDate).toISOString(), + symbols: backtest.symbols, + strategy: backtest.strategy, + initialCapital: backtest.initialCapital, + dataFrequency: backtest.config?.dataFrequency || '1d', + commission: backtest.config?.commission ?? 0.001, + slippage: backtest.config?.slippage ?? 0.0001, + speed: run.speedMultiplier === null ? 'max' : + run.speedMultiplier >= 10 ? '10x' : + run.speedMultiplier >= 5 ? '5x' : + run.speedMultiplier >= 2 ? '2x' : 'realtime', + fillModel: { + slippage: 'realistic', + marketImpact: true, + partialFills: true + } + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('Orchestrator request failed', { + status: response.status, + statusText: response.statusText, + error: errorText, + request: { + runId: run.id, + strategy: backtest.strategy, + symbols: backtest.symbols, + startDate: new Date(backtest.startDate).toISOString(), + endDate: new Date(backtest.endDate).toISOString(), + } + }); + throw new Error(`Orchestrator returned ${response.status}: ${errorText}`); + } + + const result = await response.json(); + + if (result.status === 'completed') { + await this.updateRunStatus(run.id, 'completed', { progress: 100 }); + await this.saveRunResults(run.id, result); + + // Send completion via WebSocket + if (this.getWebSocketHandler()) { + this.getWebSocketHandler().sendCompletion(run.id, result); + } + } + } catch (error) { + logger.error('Failed to start run', { runId: run.id, error }); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await this.updateRunStatus(run.id, 'failed', { + error: errorMessage + }); + + // Send error via WebSocket + if (this.getWebSocketHandler()) { + this.getWebSocketHandler().sendError(run.id, errorMessage); + } + } + } + + private async saveRunResults(runId: string, result: any): Promise { + try { + await this.container.postgres.query( + `INSERT INTO backtest_results + (run_id, completed_at, metrics, equity_curve, ohlc_data, trades, positions, analytics, execution_time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + runId, + result.completedAt || new Date(), + JSON.stringify(result.metrics || {}), + JSON.stringify(result.equity || result.equityCurve || []), + JSON.stringify(result.ohlcData || {}), + JSON.stringify(result.trades || []), + JSON.stringify(result.positions || result.finalPositions || {}), + JSON.stringify(result.analytics || {}), + result.executionTime || 0 + ] + ); + } catch (error) { + logger.error('Failed to save run results', { runId, error }); + throw error; + } + } + + private mapBacktest(row: any): Backtest { + return { + id: row.id, + name: row.name, + strategy: row.strategy, + symbols: row.symbols, + startDate: row.start_date, + endDate: row.end_date, + initialCapital: parseFloat(row.initial_capital), + config: row.config, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + private getWebSocketHandler(): WebSocketHandler | null { + // Try to get from global if not already set + if (!this.wsHandler && (global as any).wsHandler) { + this.wsHandler = (global as any).wsHandler; + } + return this.wsHandler; + } + + private mapRun(row: any): Run { + return { + id: row.id, + backtestId: row.backtest_id, + runNumber: row.run_number, + status: row.status, + speedMultiplier: parseFloat(row.speed_multiplier), + error: row.error, + startedAt: row.started_at, + completedAt: row.completed_at, + pausedAt: row.paused_at, + progress: parseFloat(row.progress), + currentDate: row.current_simulation_date, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} \ No newline at end of file diff --git a/apps/stock/web-api/src/websocket/run-updates.ts b/apps/stock/web-api/src/websocket/run-updates.ts new file mode 100644 index 0000000..c926de8 --- /dev/null +++ b/apps/stock/web-api/src/websocket/run-updates.ts @@ -0,0 +1,160 @@ +import { getLogger } from '@stock-bot/logger'; +import type { IServiceContainer } from '@stock-bot/handlers'; + +const logger = getLogger('websocket-run-updates'); + +// Store active WebSocket connections per run +// Using 'any' type for Bun WebSocket compatibility +const runConnections = new Map>(); + +export interface RunUpdateMessage { + type: 'run_update' | 'progress' | 'error' | 'completed'; + runId: string; + data: any; +} + +export function createWebSocketHandler(container: IServiceContainer) { + return { + // Handle new WebSocket connection (ws is Bun's WebSocket type) + handleConnection(ws: any, runId: string) { + logger.info(`WebSocket connected for run: ${runId}`); + + // Add connection to the run's connection set + if (!runConnections.has(runId)) { + runConnections.set(runId, new Set()); + } + runConnections.get(runId)!.add(ws); + + // Send initial connection confirmation + ws.send(JSON.stringify({ + type: 'connected', + runId, + timestamp: new Date().toISOString() + })); + + // In Bun, WebSocket handlers are set directly as properties + ws.onmessage = (event: MessageEvent) => { + try { + const message = JSON.parse(event.data.toString()); + logger.debug('Received WebSocket message:', message); + + // Handle different message types + switch (message.type) { + case 'ping': + ws.send(JSON.stringify({ type: 'pong' })); + break; + case 'subscribe': + // Already subscribed to this run + break; + default: + logger.warn('Unknown message type:', message.type); + } + } catch (error) { + logger.error('Error handling WebSocket message:', error); + } + }; + + // Handle connection close + ws.onclose = (event: any) => { + logger.info(`WebSocket disconnected for run: ${runId}`, { code: event.code, reason: event.reason }); + const connections = runConnections.get(runId); + if (connections) { + connections.delete(ws); + if (connections.size === 0) { + runConnections.delete(runId); + } + } + }; + + // Handle errors + ws.onerror = (error: Event) => { + logger.error(`WebSocket error for run ${runId}:`, error); + }; + }, + + // Broadcast update to all connected clients for a run + broadcastRunUpdate(runId: string, update: Partial) { + const connections = runConnections.get(runId); + if (!connections || connections.size === 0) { + return; + } + + const message: RunUpdateMessage = { + type: 'run_update', + runId, + data: update.data || update, + ...update + }; + + const messageStr = JSON.stringify(message); + + connections.forEach(ws => { + try { + // Bun WebSocket - just try to send, it will throw if not open + ws.send(messageStr); + } catch (error) { + // Connection is closed, ignore + } + }); + }, + + // Send progress update + sendProgressUpdate(runId: string, progress: number, currentDate?: string) { + this.broadcastRunUpdate(runId, { + type: 'progress', + data: { + progress, + currentDate, + timestamp: new Date().toISOString() + } + }); + }, + + // Send error + sendError(runId: string, error: string) { + this.broadcastRunUpdate(runId, { + type: 'error', + data: { + error, + timestamp: new Date().toISOString() + } + }); + }, + + // Send completion + sendCompletion(runId: string, results?: any) { + this.broadcastRunUpdate(runId, { + type: 'completed', + data: { + results, + timestamp: new Date().toISOString() + } + }); + + // Clean up connections after completion + setTimeout(() => { + const connections = runConnections.get(runId); + if (connections) { + connections.forEach(ws => ws.close()); + runConnections.delete(runId); + } + }, 5000); + }, + + // Get active connections count + getConnectionsCount(runId: string): number { + return runConnections.get(runId)?.size || 0; + }, + + // Clean up all connections + cleanup() { + runConnections.forEach((connections, runId) => { + connections.forEach(ws => ws.close()); + }); + runConnections.clear(); + } + }; +} + +// Export the WebSocket handler type +export type WebSocketHandler = ReturnType; \ No newline at end of file diff --git a/apps/stock/web-app/src/app/App.tsx b/apps/stock/web-app/src/app/App.tsx index 553be54..c63f8d6 100644 --- a/apps/stock/web-app/src/app/App.tsx +++ b/apps/stock/web-app/src/app/App.tsx @@ -3,7 +3,7 @@ import { DashboardPage } from '@/features/dashboard'; import { ExchangesPage } from '@/features/exchanges'; import { MonitoringPage } from '@/features/monitoring'; import { PipelinePage } from '@/features/pipeline'; -import { BacktestListPage, BacktestDetailPage } from '@/features/backtest'; +import { BacktestListPageV2, BacktestDetailPageV2 } from '@/features/backtest'; import { SymbolsPage, SymbolDetailPage } from '@/features/symbols'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; @@ -32,8 +32,11 @@ export function App() { element={
Analytics Page - Coming Soon
} /> - } /> - } /> + } /> + + } /> + } /> + Settings Page - Coming Soon} /> } /> diff --git a/apps/stock/web-app/src/components/charts/Chart.tsx b/apps/stock/web-app/src/components/charts/Chart.tsx index 24fa3af..7617198 100644 --- a/apps/stock/web-app/src/components/charts/Chart.tsx +++ b/apps/stock/web-app/src/components/charts/Chart.tsx @@ -335,8 +335,13 @@ export function Chart({ // Cleanup return () => { window.removeEventListener('resize', handleResize); - if (chart) { - chart.remove(); + // Clear all refs before removing the chart + mainSeriesRef.current = null; + volumeSeriesRef.current = null; + overlaySeriesRef.current.clear(); + if (chartRef.current) { + chartRef.current.remove(); + chartRef.current = null; } }; }, [data, height, type, showVolume, theme, overlayData, tradeMarkers]); diff --git a/apps/stock/web-app/src/features/backtest/BacktestDetailPageV2.tsx b/apps/stock/web-app/src/features/backtest/BacktestDetailPageV2.tsx new file mode 100644 index 0000000..6fe9551 --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/BacktestDetailPageV2.tsx @@ -0,0 +1,343 @@ +import { useWebSocket } from '@/hooks/useWebSocket'; +import { ArrowLeftIcon, PlusIcon } from '@heroicons/react/24/outline'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { BacktestConfiguration } from './components/BacktestConfiguration'; +import { BacktestMetrics } from './components/BacktestMetrics'; +import { BacktestPlayback } from './components/BacktestPlayback'; +import { BacktestTrades } from './components/BacktestTrades'; +import { RunControlsCompact } from './components/RunControlsCompact'; +import { RunsList } from './components/RunsList'; +import { useBacktestV2 } from './hooks/useBacktestV2'; +import type { BacktestConfig } from './types/backtest.types'; + +const baseTabs = [ + { id: 'runs', name: 'Runs' }, + { id: 'settings', name: 'Settings' }, +]; + +const runTabs = [ + { id: 'playback', name: 'Playback' }, + { id: 'metrics', name: 'Performance' }, + { id: 'trades', name: 'Trades' }, +]; + +export function BacktestDetailPageV2() { + const { id, runId } = useParams<{ id: string; runId?: string }>(); + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState('playback'); + const [showNewRun, setShowNewRun] = useState(false); + + const { + backtest, + runs, + currentRun, + runResults, + isLoading, + error, + loadBacktest, + updateBacktest, + createRun, + pauseRun, + resumeRun, + cancelRun, + updateRunSpeed, + selectRun, + } = useBacktestV2(); + + // WebSocket connection for real-time updates + const { isConnected } = useWebSocket({ + runId: currentRun?.id || null, + onProgress: (progress, currentDate) => { + // Update the run progress in the UI + if (currentRun) { + // This will trigger a re-render with updated progress + console.log('Progress update:', progress, currentDate); + } + }, + onError: (error) => { + console.error('Run error:', error); + // Reload runs to get updated status + if (id) { + loadBacktest(id); + } + }, + onCompleted: (results) => { + console.log('Run completed:', results); + // Don't reload the entire backtest, just update the current run status + // The results are already available from the WebSocket message + } + }); + + // Load backtest on mount + useEffect(() => { + if (id) { + loadBacktest(id); + } + }, [id, loadBacktest]); + + // Select run based on URL parameter + useEffect(() => { + if (runId && runs.length > 0) { + const run = runs.find(r => r.id === runId); + if (run && run.id !== currentRun?.id) { + selectRun(run.id); + // Show playback tab by default when a run is selected + if (activeTab === 'runs') { + setActiveTab('playback'); + } + } + } else if (!runId && currentRun) { + // Clear run selection when navigating away from run URL + selectRun(undefined); + setActiveTab('runs'); + } + }, [runId, runs, selectRun]); + + // Handle configuration save + const handleConfigSubmit = useCallback(async (config: BacktestConfig) => { + if (!id) return; + + await updateBacktest(id, { + name: config.name, + strategy: config.strategy, + symbols: config.symbols, + startDate: config.startDate.toISOString().split('T')[0], + endDate: config.endDate.toISOString().split('T')[0], + initialCapital: config.initialCapital, + config: { + commission: config.commission, + slippage: config.slippage, + speedMultiplier: config.speedMultiplier, + }, + }); + }, [id, updateBacktest]); + + // Handle new run creation + const handleCreateRun = useCallback(async (speedMultiplier: number) => { + const newRun = await createRun(speedMultiplier); + setShowNewRun(false); + // Navigate to the new run + if (newRun && id) { + navigate(`/backtests/${id}/run/${newRun.id}`); + } + }, [createRun, navigate, id]); + + // Handle rerun + const handleRerun = useCallback(async (speedMultiplier: number | null) => { + // Pass null for max speed (no limit) + const newRun = await createRun(speedMultiplier ?? undefined); + // Navigate to the new run's URL + if (newRun && id) { + navigate(`/backtests/${id}/run/${newRun.id}`); + } + }, [createRun, navigate, id]); + + // Convert backtest to config format for the form + const backtestConfig: BacktestConfig | undefined = backtest ? { + name: backtest.name, + startDate: new Date(backtest.startDate), + endDate: new Date(backtest.endDate), + initialCapital: backtest.initialCapital, + symbols: backtest.symbols, + strategy: backtest.strategy, + speedMultiplier: 1, + commission: backtest.config?.commission ?? 0.001, + slippage: backtest.config?.slippage ?? 0.0001, + } : undefined; + + const renderTabContent = () => { + // Show message if trying to view run-specific tabs without a run selected + if (!currentRun && ['playback', 'metrics', 'trades'].includes(activeTab)) { + return ( +
+
+

Please select a run to view {activeTab}

+ +
+
+ ); + } + + switch (activeTab) { + case 'runs': + return ( +
+
+

Run History

+ +
+ + {showNewRun && ( +
+

Start New Run

+

+ This will start a new backtest run with the current configuration. +

+
+ + +
+
+ )} + + +
+ ); + case 'settings': + return ( +
+ +
+ ); + case 'metrics': + return ( +
+ +
+ ); + case 'playback': + return ( + + ); + case 'trades': + return ( +
+ +
+ ); + default: + return null; + } + }; + + if (!backtest && !isLoading) { + return ( +
+
+

Backtest not found

+ +
+
+ ); + } + + return ( +
+ {/* Header with Tabs */} +
+
+
+ +
+

+ {backtest?.name || 'Loading...'} + {currentRun && ( + + - Run #{currentRun.runNumber} + + )} +

+

+ {backtest?.strategy || ''} • {backtest?.symbols.join(', ') || ''} +

+
+ + {/* Tabs */} + +
+ + {/* Run Controls */} + {currentRun && ( +
+ +
+ )} +
+
+ + {/* Tab Content */} +
+ {error && ( +
+

{error}

+
+ )} + {renderTabContent()} +
+
+ ); +} \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/BacktestListPageV2.tsx b/apps/stock/web-app/src/features/backtest/BacktestListPageV2.tsx new file mode 100644 index 0000000..8cebad0 --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/BacktestListPageV2.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { DataTable } from '@/components/ui/DataTable/DataTable'; +import type { ColumnDef } from '@tanstack/react-table'; +import { PlusIcon } from '@heroicons/react/24/solid'; +import { backtestApiV2, type Backtest } from './services/backtestApiV2'; +import { CreateBacktestDialog } from './components/CreateBacktestDialog'; + +export function BacktestListPageV2() { + const navigate = useNavigate(); + const [backtests, setBacktests] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [showCreateDialog, setShowCreateDialog] = useState(false); + + const loadBacktests = async () => { + setIsLoading(true); + setError(null); + try { + const data = await backtestApiV2.listBacktests(); + setBacktests(data); + } catch (err) { + setError('Failed to load backtests'); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadBacktests(); + }, []); + + const handleCreateBacktest = async (config: Omit) => { + try { + const newBacktest = await backtestApiV2.createBacktest(config); + await loadBacktests(); + setShowCreateDialog(false); + navigate(`/backtests/${newBacktest.id}`); + } catch (err) { + setError('Failed to create backtest'); + console.error(err); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + size: 200, + cell: ({ row }) => ( +
+ {row.original.name} +
+ ), + }, + { + accessorKey: 'strategy', + header: 'Strategy', + size: 150, + cell: ({ row }) => ( + + {row.original.strategy} + + ), + }, + { + accessorKey: 'symbols', + header: 'Symbols', + size: 200, + cell: ({ row }) => ( +
+ {row.original.symbols.join(', ')} +
+ ), + }, + { + accessorKey: 'dateRange', + header: 'Date Range', + size: 200, + cell: ({ row }) => ( +
+ {new Date(row.original.startDate).toLocaleDateString()} - {new Date(row.original.endDate).toLocaleDateString()} +
+ ), + }, + { + accessorKey: 'initialCapital', + header: 'Initial Capital', + size: 150, + cell: ({ row }) => ( +
+ ${row.original.initialCapital.toLocaleString()} +
+ ), + }, + { + accessorKey: 'createdAt', + header: 'Created', + size: 180, + cell: ({ row }) => ( +
+ {new Date(row.original.createdAt).toLocaleString()} +
+ ), + }, + ]; + + return ( +
+
+
+

Backtests

+

+ Create and manage backtest configurations +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ navigate(`/backtests/${row.id}`)} + height={600} + /> +
+ + {showCreateDialog && ( + setShowCreateDialog(false)} + onCreate={handleCreateBacktest} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestChart.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestChart.tsx index 87036f1..89cef04 100644 --- a/apps/stock/web-app/src/features/backtest/components/BacktestChart.tsx +++ b/apps/stock/web-app/src/features/backtest/components/BacktestChart.tsx @@ -1,5 +1,5 @@ import type { BacktestResult } from '../types/backtest.types'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, memo } from 'react'; import { Chart } from '../../../components/charts'; interface BacktestChartProps { @@ -7,7 +7,8 @@ interface BacktestChartProps { isLoading: boolean; } -export function BacktestChart({ result, isLoading }: BacktestChartProps) { +// Memoize the component to prevent unnecessary re-renders +export const BacktestChart = memo(function BacktestChart({ result, isLoading }: BacktestChartProps) { const [selectedSymbol, setSelectedSymbol] = useState(''); const chartData = useMemo(() => { @@ -15,32 +16,58 @@ export function BacktestChart({ result, isLoading }: BacktestChartProps) { const symbols = Object.keys(result.ohlcData); const symbol = selectedSymbol || symbols[0] || ''; - const ohlcData = result.ohlcData[symbol] || []; - const equityData = result.equity.map(e => ({ - time: new Date(e.date).getTime() / 1000, - value: e.value - })); + + // Remove excessive logging in production + // Log only on significant changes + if (process.env.NODE_ENV === 'development' && ohlcData.length > 0) { + // Use a simple hash to detect actual data changes + const dataHash = `${symbols.length}-${result.equity?.length}-${ohlcData.length}`; + if ((window as any).__lastDataHash !== dataHash) { + (window as any).__lastDataHash = dataHash; + console.log('BacktestChart data updated:', { + symbols, + selectedSymbol, + symbol, + ohlcDataKeys: Object.keys(result.ohlcData), + equityLength: result.equity?.length, + tradesLength: result.trades?.length + }); + } + } + + const equityData = (result.equity || []) + .filter(e => e && e.date && e.value != null) + .map(e => ({ + time: new Date(e.date).getTime() / 1000, + value: e.value + })); // Find trades for this symbol const symbolTrades = result.trades?.filter(t => t.symbol === symbol) || []; - const tradeMarkers = symbolTrades.map(trade => ({ - time: new Date(trade.entryDate).getTime() / 1000, - position: 'belowBar' as const, - color: trade.side === 'buy' ? '#10b981' : '#ef4444', - shape: trade.side === 'buy' ? 'arrowUp' : 'arrowDown' as const, - text: `${trade.side === 'buy' ? 'Buy' : 'Sell'} @ $${trade.entryPrice.toFixed(2)}` - })); + const tradeMarkers = symbolTrades + .filter(trade => trade.entryPrice != null && trade.entryDate != null) + .map(trade => ({ + time: new Date(trade.entryDate).getTime() / 1000, + position: 'belowBar' as const, + color: trade.side === 'buy' ? '#10b981' : '#ef4444', + shape: trade.side === 'buy' ? 'arrowUp' : 'arrowDown' as const, + text: `${trade.side === 'buy' ? 'Buy' : 'Sell'} @ $${trade.entryPrice.toFixed(2)}` + })); - return { - ohlcData: ohlcData.map(d => ({ - time: d.time / 1000, + const processedOhlcData = ohlcData + .filter(d => d && d.timestamp != null && d.open != null && d.high != null && d.low != null && d.close != null) + .map(d => ({ + time: d.timestamp / 1000, // timestamp is already in milliseconds open: d.open, high: d.high, low: d.low, close: d.close, - volume: d.volume - })), + volume: d.volume || 0 + })); + + return { + ohlcData: processedOhlcData, equityData, tradeMarkers, symbols @@ -90,4 +117,4 @@ export function BacktestChart({ result, isLoading }: BacktestChartProps) { ); -} \ No newline at end of file +}); \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestMetrics.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestMetrics.tsx index 6049c43..ce4d50c 100644 --- a/apps/stock/web-app/src/features/backtest/components/BacktestMetrics.tsx +++ b/apps/stock/web-app/src/features/backtest/components/BacktestMetrics.tsx @@ -35,32 +35,32 @@ export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
= 0 ? 'success' : 'error'} + value={`${((metrics.totalReturn ?? 0) * 100).toFixed(2)}%`} + color={(metrics.totalReturn ?? 0) >= 0 ? 'success' : 'error'} /> 1 ? 'success' : metrics.sharpeRatio > 0 ? 'warning' : 'error'} + value={(metrics.sharpeRatio ?? 0).toFixed(2)} + color={(metrics.sharpeRatio ?? 0) > 1 ? 'success' : (metrics.sharpeRatio ?? 0) > 0 ? 'warning' : 'error'} /> -0.2 ? 'warning' : 'error'} + value={`${((metrics.maxDrawdown ?? 0) * 100).toFixed(2)}%`} + color={(metrics.maxDrawdown ?? 0) > -0.2 ? 'warning' : 'error'} /> 0.5 ? 'success' : 'warning'} + value={`${((metrics.winRate ?? 0) * 100).toFixed(1)}%`} + color={(metrics.winRate ?? 0) > 0.5 ? 'success' : 'warning'} /> 1.5 ? 'success' : metrics.profitFactor > 1 ? 'warning' : 'error'} + value={(metrics.profitFactor ?? 0).toFixed(2)} + color={(metrics.profitFactor ?? 0) > 1.5 ? 'success' : (metrics.profitFactor ?? 0) > 1 ? 'warning' : 'error'} />
@@ -70,20 +70,20 @@ export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
Profitable Trades - {metrics.profitableTrades} + {metrics.profitableTrades ?? 0}
Average Win - ${metrics.avgWin.toFixed(2)} + ${(metrics.avgWin ?? 0).toFixed(2)}
Average Loss - ${Math.abs(metrics.avgLoss).toFixed(2)} + ${Math.abs(metrics.avgLoss ?? 0).toFixed(2)}
Expectancy - = 0 ? 'text-success' : 'text-error'}`}> - ${metrics.expectancy.toFixed(2)} + = 0 ? 'text-success' : 'text-error'}`}> + ${(metrics.expectancy ?? 0).toFixed(2)}
@@ -94,11 +94,11 @@ export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
Calmar Ratio - {metrics.calmarRatio.toFixed(2)} + {(metrics.calmarRatio ?? 0).toFixed(2)}
Sortino Ratio - {metrics.sortinoRatio.toFixed(2)} + {(metrics.sortinoRatio ?? 0).toFixed(2)}
Exposure Time diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestPlayback.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestPlayback.tsx new file mode 100644 index 0000000..35511dc --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/components/BacktestPlayback.tsx @@ -0,0 +1,136 @@ +import { useState, memo } from 'react'; +import { BacktestChart } from './BacktestChart'; + +interface BacktestPlaybackProps { + result: any | null; + isLoading: boolean; +} + +export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoading }: BacktestPlaybackProps) { + const [showPositions, setShowPositions] = useState(true); + + if (isLoading) { + return ( +
+
Loading playback data...
+
+ ); + } + + if (!result) { + return ( +
+
No data available
+
+ ); + } + + // Get open positions from the result + // Positions can be an object with symbols as keys or an array + let openPositions: any[] = []; + + if (result.positions) { + if (Array.isArray(result.positions)) { + openPositions = result.positions.filter((p: any) => p.quantity > 0); + } else if (typeof result.positions === 'object') { + // Convert positions object to array + openPositions = Object.entries(result.positions) + .filter(([_, position]: [string, any]) => position.quantity > 0) + .map(([symbol, position]: [string, any]) => ({ + symbol, + ...position + })); + } + } + + return ( +
+ {/* Chart Section */} +
+ +
+ + {/* Open Positions Section */} + {openPositions.length > 0 && ( +
+
+
+

+ Open Positions ({openPositions.length}) +

+ +
+ + {showPositions && ( +
+
+
Symbol
+
Side
+
Quantity
+
Entry Price
+
Current Price
+
P&L
+
+ + {openPositions.map((position, index) => { + const quantity = position.quantity || 0; + const avgPrice = position.averagePrice || position.avgPrice || 0; + const currentPrice = position.currentPrice || position.lastPrice || avgPrice; + const side = quantity > 0 ? 'buy' : 'sell'; + const absQuantity = Math.abs(quantity); + const pnl = (currentPrice - avgPrice) * quantity; + const pnlPercent = avgPrice > 0 ? (pnl / (avgPrice * absQuantity)) * 100 : 0; + + return ( +
+
{position.symbol}
+
+ {side.toUpperCase()} +
+
{absQuantity}
+
+ ${avgPrice.toFixed(2)} +
+
+ ${currentPrice.toFixed(2)} +
+
= 0 ? 'text-success' : 'text-error'}`}> + ${pnl.toFixed(2)} ({pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%) +
+
+ ); + })} + +
+
+
Total P&L:
+
{ + const quantity = p.quantity || 0; + const avgPrice = p.averagePrice || p.avgPrice || 0; + const currentPrice = p.currentPrice || p.lastPrice || avgPrice; + return sum + ((currentPrice - avgPrice) * quantity); + }, 0) >= 0 ? 'text-success' : 'text-error' + }`}> + ${openPositions.reduce((sum, p) => { + const quantity = p.quantity || 0; + const avgPrice = p.averagePrice || p.avgPrice || 0; + const currentPrice = p.currentPrice || p.lastPrice || avgPrice; + return sum + ((currentPrice - avgPrice) * quantity); + }, 0).toFixed(2)} +
+
+
+
+ )} +
+
+ )} +
+ ); +}); \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestTrades.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestTrades.tsx index 0105d1b..1ab6d2f 100644 --- a/apps/stock/web-app/src/features/backtest/components/BacktestTrades.tsx +++ b/apps/stock/web-app/src/features/backtest/components/BacktestTrades.tsx @@ -1,5 +1,6 @@ import type { BacktestResult } from '../types/backtest.types'; -import { TradeLog } from './TradeLog'; +import { DataTable } from '@/components/ui/DataTable'; +import type { ColumnDef } from '@tanstack/react-table'; interface BacktestTradesProps { result: BacktestResult | null; @@ -27,6 +28,124 @@ export function BacktestTrades({ result, isLoading }: BacktestTradesProps) { ); } + const columns: ColumnDef[] = [ + { + accessorKey: 'symbol', + header: 'Symbol', + size: 100, + cell: ({ getValue }) => ( + {getValue() as string} + ), + }, + { + accessorKey: 'side', + header: 'Side', + size: 80, + cell: ({ getValue }) => { + const side = getValue() as string || 'unknown'; + return ( + + {side.toUpperCase()} + + ); + }, + }, + { + accessorKey: 'entryDate', + header: 'Entry Date', + size: 180, + cell: ({ getValue }) => { + const date = getValue() as string; + return ( + + {date ? new Date(date).toLocaleString() : '-'} + + ); + }, + }, + { + accessorKey: 'entryPrice', + header: 'Entry Price', + size: 100, + cell: ({ getValue }) => { + const price = getValue() as number; + return ( + + ${price != null ? price.toFixed(2) : '0.00'} + + ); + }, + }, + { + accessorKey: 'exitDate', + header: 'Exit Date', + size: 180, + cell: ({ getValue }) => { + const date = getValue() as string | null; + return ( + + {date ? new Date(date).toLocaleString() : '-'} + + ); + }, + }, + { + accessorKey: 'exitPrice', + header: 'Exit Price', + size: 100, + cell: ({ getValue }) => { + const price = getValue() as number; + return ( + + ${price != null ? price.toFixed(2) : '0.00'} + + ); + }, + }, + { + accessorKey: 'quantity', + header: 'Quantity', + size: 80, + cell: ({ getValue }) => ( + {getValue() as number || 0} + ), + }, + { + accessorKey: 'pnl', + header: 'P&L', + size: 100, + cell: ({ getValue }) => { + const pnl = getValue() as number || 0; + return ( + = 0 ? 'text-success' : 'text-error' + }`}> + ${pnl != null ? pnl.toFixed(2) : '0.00'} + + ); + }, + }, + { + accessorKey: 'pnlPercent', + header: 'P&L %', + size: 100, + cell: ({ getValue }) => { + const pnlPercent = getValue() as number || 0; + return ( + = 0 ? 'text-success' : 'text-error' + }`}> + {pnlPercent != null ? pnlPercent.toFixed(2) : '0.00'}% + + ); + }, + }, + ]; + return (
@@ -36,108 +155,78 @@ export function BacktestTrades({ result, isLoading }: BacktestTradesProps) {

-
- - - - - - - - - - - - - - - - {result.trades.map((trade) => ( - - - - - - - - - - - - ))} - -
- Symbol - - Side - - Entry Date - - Entry Price - - Exit Date - - Exit Price - - Quantity - - P&L - - P&L % -
- {trade.symbol} - - - {trade.side.toUpperCase()} - - - {new Date(trade.entryDate).toLocaleString()} - - ${trade.entryPrice.toFixed(2)} - - {trade.exitDate ? new Date(trade.exitDate).toLocaleString() : '-'} - - ${trade.exitPrice.toFixed(2)} - - {trade.quantity} - = 0 ? 'text-success' : 'text-error' - }`}> - ${trade.pnl.toFixed(2)} - = 0 ? 'text-success' : 'text-error' - }`}> - {trade.pnlPercent.toFixed(2)}% -
-
+ - {result.positions && Object.keys(result.positions).length > 0 && ( -
-

Open Positions

-
- {Object.entries(result.positions).map(([symbol, position]) => ( -
- {symbol} -
- - Qty: {position.quantity} - - - Avg: ${position.averagePrice.toFixed(2)} - - = 0 ? 'text-success' : 'text-error'}> - P&L: ${position.unrealizedPnl.toFixed(2)} - -
-
- ))} + {result.positions && Object.keys(result.positions).length > 0 && (() => { + const positionsArray = Object.entries(result.positions).map(([symbol, position]) => ({ + symbol, + ...position + })); + + const positionColumns: ColumnDef[] = [ + { + accessorKey: 'symbol', + header: 'Symbol', + size: 100, + cell: ({ getValue }) => ( + {getValue() as string} + ), + }, + { + accessorKey: 'quantity', + header: 'Quantity', + size: 100, + cell: ({ getValue }) => ( + {getValue() as number || 0} + ), + }, + { + accessorKey: 'averagePrice', + header: 'Avg Price', + size: 120, + cell: ({ getValue }) => { + const price = getValue() as number; + return ( + + ${price != null ? price.toFixed(2) : '0.00'} + + ); + }, + }, + { + accessorKey: 'unrealizedPnl', + header: 'Unrealized P&L', + size: 150, + cell: ({ getValue }) => { + const pnl = getValue() as number || 0; + return ( + = 0 ? 'text-success' : 'text-error' + }`}> + ${pnl != null ? pnl.toFixed(2) : '0.00'} + + ); + }, + }, + ]; + + return ( +
+

Open Positions

+
-
- )} + ); + })()}
); } \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/components/CreateBacktestDialog.tsx b/apps/stock/web-app/src/features/backtest/components/CreateBacktestDialog.tsx new file mode 100644 index 0000000..bb6dffd --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/components/CreateBacktestDialog.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react'; +import { XMarkIcon } from '@heroicons/react/24/solid'; +import type { Backtest } from '../services/backtestApiV2'; + +interface CreateBacktestDialogProps { + onClose: () => void; + onCreate: (config: Omit) => void; +} + +export function CreateBacktestDialog({ onClose, onCreate }: CreateBacktestDialogProps) { + const [formData, setFormData] = useState({ + name: '', + strategy: 'moving-average', + symbols: ['AAPL'], + startDate: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + endDate: new Date().toISOString().split('T')[0], + initialCapital: 100000, + commission: 0, + slippage: 0, + }); + + const [symbolInput, setSymbolInput] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.name.trim()) { + alert('Please enter a backtest name'); + return; + } + + onCreate({ + ...formData, + metadata: {}, + }); + }; + + const addSymbol = () => { + const symbol = symbolInput.trim().toUpperCase(); + if (symbol && !formData.symbols.includes(symbol)) { + setFormData({ ...formData, symbols: [...formData.symbols, symbol] }); + setSymbolInput(''); + } + }; + + const removeSymbol = (symbol: string) => { + setFormData({ + ...formData, + symbols: formData.symbols.filter(s => s !== symbol), + }); + }; + + return ( +
+
+
+

Create New Backtest

+ +
+ +
+
+ + setFormData({ ...formData, name: e.target.value })} + className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500" + placeholder="My Strategy Backtest" + required + /> +
+ +
+ + +
+ +
+ +
+ setSymbolInput(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addSymbol())} + className="flex-1 px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500" + placeholder="Enter symbol (e.g., AAPL)" + /> + +
+
+ {formData.symbols.map(symbol => ( + + {symbol} + + + ))} +
+
+ +
+
+ + setFormData({ ...formData, startDate: e.target.value })} + className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500" + required + /> +
+
+ + setFormData({ ...formData, endDate: e.target.value })} + className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500" + required + /> +
+
+ +
+ + setFormData({ ...formData, initialCapital: parseFloat(e.target.value) || 0 })} + className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500" + min="0" + step="1000" + required + /> +
+ +
+
+ + setFormData({ ...formData, commission: parseFloat(e.target.value) || 0 })} + className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500" + min="0" + step="0.01" + /> +
+
+ + setFormData({ ...formData, slippage: parseFloat(e.target.value) || 0 })} + className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500" + min="0" + step="0.01" + /> +
+
+ +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/components/RunControls.tsx b/apps/stock/web-app/src/features/backtest/components/RunControls.tsx new file mode 100644 index 0000000..dc174e6 --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/components/RunControls.tsx @@ -0,0 +1,201 @@ +import { useState } from 'react'; +import type { Run } from '../services/backtestApiV2'; +import { + PlayIcon, + PauseIcon, + StopIcon, + ForwardIcon, + BackwardIcon, +} from '@heroicons/react/24/solid'; + +interface RunControlsProps { + run: Run; + onPause: () => void; + onResume: () => void; + onCancel: () => void; + onSpeedChange: (speed: number) => void; +} + +export function RunControls({ + run, + onPause, + onResume, + onCancel, + onSpeedChange, +}: RunControlsProps) { + const [speedMultiplier, setSpeedMultiplier] = useState(run.speedMultiplier); + + const handleSpeedChange = (speed: number) => { + setSpeedMultiplier(speed); + onSpeedChange(speed); + }; + + const speedOptions = [ + { value: 0.1, label: '0.1x' }, + { value: 0.5, label: '0.5x' }, + { value: 1, label: '1x' }, + { value: 2, label: '2x' }, + { value: 5, label: '5x' }, + { value: 10, label: '10x' }, + { value: 50, label: '50x' }, + { value: 100, label: '100x' }, + { value: 1000, label: 'Max' }, + ]; + + const formatDate = (date?: string) => { + if (!date) return '-'; + return new Date(date).toLocaleString(); + }; + + const formatDuration = () => { + if (!run.startedAt) return '00:00'; + const start = new Date(run.startedAt).getTime(); + const current = Date.now(); + const duration = current - start; + + const seconds = Math.floor(duration / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours.toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`; + } else { + return `${minutes.toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`; + } + }; + + return ( +
+

Run Control

+ +
+ {/* Progress Bar */} +
+
+ Progress + {run.progress.toFixed(1)}% +
+
+
+
+
+
+
+ + {/* Control Buttons */} +
+ {run.status === 'running' ? ( + + ) : run.status === 'paused' ? ( + + ) : null} + + {(run.status === 'running' || run.status === 'paused') && ( + + )} +
+ + {/* Speed Control */} + {run.status === 'running' && ( +
+ +
+ + + + + +
+
+ )} + + {/* Run Info */} +
+
+ Status: + + {run.status.charAt(0).toUpperCase() + run.status.slice(1)} + +
+
+ Duration: + + {formatDuration()} + +
+ {run.currentSimulationDate && ( +
+ Current Date: + + {formatDate(run.currentSimulationDate)} + +
+ )} + {run.error && ( +
+ Error: + + {run.error} + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/components/RunControlsCompact.tsx b/apps/stock/web-app/src/features/backtest/components/RunControlsCompact.tsx new file mode 100644 index 0000000..a5b74eb --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/components/RunControlsCompact.tsx @@ -0,0 +1,100 @@ +import type { Run } from '../services/backtestApiV2'; +import { + ArrowPathIcon, +} from '@heroicons/react/24/solid'; + +interface RunControlsCompactProps { + run: Run; + onPause: () => void; + onResume: () => void; + onRerun: (speed: number | null) => void; + onNext?: () => void; + onSpeedChange: (speed: number | null) => void; +} + +export function RunControlsCompact({ + run, + onPause, + onResume, + onRerun, + onNext, +}: RunControlsCompactProps) { + + const isRunning = run.status === 'running'; + const isPaused = run.status === 'paused'; + const isCompleted = run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled'; + + return ( +
+ {/* Progress */} +
+ Progress: +
+
+
+
+
+ {run.progress.toFixed(0)}% +
+ + + {/* Control Buttons */} +
+ {isRunning ? ( + + ) : isPaused ? ( + + ) : null} + + {onNext && ( + + )} + + +
+ + {/* Status Indicator */} + {run.status !== 'running' && run.status !== 'paused' && ( + <> +
+ + {run.status.charAt(0).toUpperCase() + run.status.slice(1)} + + + )} +
+ ); +} \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/components/RunsList.tsx b/apps/stock/web-app/src/features/backtest/components/RunsList.tsx new file mode 100644 index 0000000..a634610 --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/components/RunsList.tsx @@ -0,0 +1,177 @@ +import { DataTable } from '@/components/ui/DataTable'; +import type { ColumnDef } from '@tanstack/react-table'; +import type { Run } from '../services/backtestApiV2'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + CheckCircleIcon, + XCircleIcon, + PauseIcon, + PlayIcon, + ClockIcon, +} from '@heroicons/react/24/outline'; + +interface RunsListProps { + runs: Run[]; + currentRunId?: string; + onSelectRun: (runId: string) => void; +} + +export function RunsList({ runs, currentRunId, onSelectRun }: RunsListProps) { + const navigate = useNavigate(); + const { id: backtestId } = useParams<{ id: string }>(); + + const getStatusIcon = (status: Run['status']) => { + switch (status) { + case 'completed': + return ; + case 'failed': + return ; + case 'cancelled': + return ; + case 'running': + return ; + case 'paused': + return ; + case 'pending': + return ; + } + }; + + const getStatusLabel = (status: Run['status']) => { + return status.charAt(0).toUpperCase() + status.slice(1); + }; + + const formatDuration = (startedAt?: string, completedAt?: string) => { + if (!startedAt) return '-'; + const start = new Date(startedAt).getTime(); + const end = completedAt ? new Date(completedAt).getTime() : Date.now(); + const duration = end - start; + + const seconds = Math.floor(duration / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: 'runNumber', + header: 'Run #', + size: 80, + cell: ({ getValue, row }) => ( + + #{getValue() as number} + + ), + }, + { + accessorKey: 'status', + header: 'Status', + size: 120, + cell: ({ getValue }) => { + const status = getValue() as Run['status']; + return ( +
+ {getStatusIcon(status)} + {getStatusLabel(status)} +
+ ); + }, + }, + { + accessorKey: 'progress', + header: 'Progress', + size: 150, + cell: ({ row }) => { + const progress = row.original.progress; + const status = row.original.status; + + if (status === 'pending') return -; + + return ( +
+
+
+
+ {progress.toFixed(0)}% +
+ ); + }, + }, + { + accessorKey: 'speedMultiplier', + header: 'Speed', + size: 80, + cell: ({ getValue }) => ( + {getValue() as number}x + ), + }, + { + accessorKey: 'startedAt', + header: 'Started', + size: 180, + cell: ({ getValue }) => { + const date = getValue() as string | undefined; + return ( + + {date ? new Date(date).toLocaleString() : '-'} + + ); + }, + }, + { + id: 'duration', + header: 'Duration', + size: 100, + cell: ({ row }) => ( + + {formatDuration(row.original.startedAt, row.original.completedAt)} + + ), + }, + { + accessorKey: 'error', + header: 'Error', + size: 200, + cell: ({ getValue }) => { + const error = getValue() as string | undefined; + return error ? ( + {error} + ) : ( + - + ); + }, + }, + ]; + + if (runs.length === 0) { + return ( +
+

No runs yet. Create a new run to get started.

+
+ ); + } + + return ( + { + navigate(`/backtests/${backtestId}/run/${run.id}`); + onSelectRun(run.id); + }} + className="bg-surface-secondary rounded-lg border border-border" + height={400} + /> + ); +} \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/hooks/useBacktestV2.ts b/apps/stock/web-app/src/features/backtest/hooks/useBacktestV2.ts new file mode 100644 index 0000000..eb7b8ef --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/hooks/useBacktestV2.ts @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { backtestApiV2 } from '../services/backtestApiV2'; +import type { Backtest, Run, RunResult, CreateBacktestRequest } from '../services/backtestApiV2'; + +interface UseBacktestV2Return { + // State + backtest: Backtest | null; + runs: Run[]; + currentRun: Run | null; + runResults: RunResult | null; + isLoading: boolean; + error: string | null; + + // Actions + loadBacktest: (id: string) => Promise; + createBacktest: (request: CreateBacktestRequest) => Promise; + updateBacktest: (id: string, updates: Partial) => Promise; + deleteBacktest: (id: string) => Promise; + + createRun: (speedMultiplier?: number | null) => Promise; + pauseRun: () => Promise; + resumeRun: () => Promise; + cancelRun: () => Promise; + updateRunSpeed: (speedMultiplier: number | null) => Promise; + selectRun: (runId: string | undefined) => Promise; +} + +export function useBacktestV2(): UseBacktestV2Return { + const [backtest, setBacktest] = useState(null); + const [runs, setRuns] = useState([]); + const [currentRun, setCurrentRun] = useState(null); + const [runResults, setRunResults] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const pollingIntervalRef = useRef(null); + + // Load a specific backtest and its runs + const loadBacktest = useCallback(async (id: string) => { + setIsLoading(true); + setError(null); + + try { + const [loadedBacktest, loadedRuns] = await Promise.all([ + backtestApiV2.getBacktest(id), + backtestApiV2.listRuns(id) + ]); + + setBacktest(loadedBacktest); + setRuns(loadedRuns); + + // If there are runs, select the most recent one + if (loadedRuns.length > 0) { + const latestRun = loadedRuns[0]; + setCurrentRun(latestRun); + + if (latestRun.status === 'completed') { + const results = await backtestApiV2.getRunResults(latestRun.id); + setRunResults(results); + } else if (latestRun.status === 'running' || latestRun.status === 'paused') { + // Start monitoring the run + startMonitoringRun(latestRun.id); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load backtest'); + } finally { + setIsLoading(false); + } + }, []); + + // Create a new backtest + const createBacktest = useCallback(async (request: CreateBacktestRequest): Promise => { + setIsLoading(true); + setError(null); + + try { + const newBacktest = await backtestApiV2.createBacktest(request); + setBacktest(newBacktest); + setRuns([]); + setCurrentRun(null); + setRunResults(null); + return newBacktest; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create backtest'); + throw err; + } finally { + setIsLoading(false); + } + }, []); + + // Update backtest configuration + const updateBacktest = useCallback(async (id: string, updates: Partial) => { + setIsLoading(true); + setError(null); + + try { + const updatedBacktest = await backtestApiV2.updateBacktest(id, updates); + setBacktest(updatedBacktest); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update backtest'); + throw err; + } finally { + setIsLoading(false); + } + }, []); + + // Delete backtest + const deleteBacktest = useCallback(async (id: string) => { + setIsLoading(true); + setError(null); + + try { + await backtestApiV2.deleteBacktest(id); + setBacktest(null); + setRuns([]); + setCurrentRun(null); + setRunResults(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete backtest'); + throw err; + } finally { + setIsLoading(false); + } + }, []); + + // Create a new run + const createRun = useCallback(async (speedMultiplier?: number | null) => { + if (!backtest) return; + + setIsLoading(true); + setError(null); + setRunResults(null); + + try { + // Pass speedMultiplier as-is, null means max speed + const newRun = await backtestApiV2.createRun(backtest.id, { + speedMultiplier: speedMultiplier === undefined ? null : speedMultiplier + }); + setRuns(prevRuns => [newRun, ...prevRuns]); + setCurrentRun(newRun); + + // Start monitoring the run + startMonitoringRun(newRun.id); + + return newRun; // Return the created run + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create run'); + throw err; // Re-throw so caller knows it failed + } finally { + setIsLoading(false); + } + }, [backtest]); + + // Run control actions + const pauseRun = useCallback(async () => { + if (!currentRun || currentRun.status !== 'running') return; + + try { + await backtestApiV2.pauseRun(currentRun.id); + setCurrentRun(prev => prev ? { ...prev, status: 'paused' } : null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to pause run'); + } + }, [currentRun]); + + const resumeRun = useCallback(async () => { + if (!currentRun || currentRun.status !== 'paused') return; + + try { + await backtestApiV2.resumeRun(currentRun.id); + setCurrentRun(prev => prev ? { ...prev, status: 'running' } : null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to resume run'); + } + }, [currentRun]); + + const cancelRun = useCallback(async () => { + if (!currentRun || (currentRun.status !== 'running' && currentRun.status !== 'paused')) return; + + try { + await backtestApiV2.cancelRun(currentRun.id); + setCurrentRun(prev => prev ? { ...prev, status: 'cancelled' } : null); + stopMonitoringRun(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to cancel run'); + } + }, [currentRun]); + + const updateRunSpeed = useCallback(async (speedMultiplier: number | null) => { + if (!currentRun) return; + + try { + // Keep null as null for max speed (no limit) + const apiSpeed = speedMultiplier; + + // Only call API if run is active + if (currentRun.status === 'running' || currentRun.status === 'paused') { + await backtestApiV2.updateRunSpeed(currentRun.id, apiSpeed); + } + + // Always update local state so UI reflects the selection + setCurrentRun(prev => prev ? { ...prev, speedMultiplier: apiSpeed } : null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update run speed'); + } + }, [currentRun]); + + // Select a specific run + const selectRun = useCallback(async (runId: string | undefined) => { + if (!runId) { + setCurrentRun(null); + setRunResults(null); + stopMonitoringRun(); + return; + } + + const run = runs.find(r => r.id === runId); + if (!run) return; + + setCurrentRun(run); + setRunResults(null); + + if (run.status === 'completed') { + try { + const results = await backtestApiV2.getRunResults(run.id); + setRunResults(results); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load run results'); + } + } else if (run.status === 'running' || run.status === 'paused') { + startMonitoringRun(run.id); + } + }, [runs]); + + // Monitor run progress + const startMonitoringRun = (runId: string) => { + // Stop any existing monitoring + stopMonitoringRun(); + + // WebSocket updates are handled by the useWebSocket hook in BacktestDetailPageV2 + // So we don't need polling here + console.log('Run monitoring handled by WebSocket, skipping polling'); + }; + + const startPollingRun = (runId: string) => { + pollingIntervalRef.current = setInterval(async () => { + try { + const updatedRun = await backtestApiV2.getRun(runId); + handleRunUpdate(updatedRun); + + if (updatedRun.status === 'completed') { + const results = await backtestApiV2.getRunResults(runId); + setRunResults(results); + stopMonitoringRun(); + } else if (updatedRun.status === 'failed' || updatedRun.status === 'cancelled') { + stopMonitoringRun(); + } + } catch (err) { + console.error('Failed to poll run status:', err); + } + }, 1000); // Poll every second + }; + + const stopMonitoringRun = () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + + const handleRunUpdate = (update: Run) => { + setCurrentRun(update); + setRuns(prevRuns => + prevRuns.map(run => run.id === update.id ? update : run) + ); + }; + + // Cleanup on unmount + useEffect(() => { + return () => { + stopMonitoringRun(); + }; + }, []); + + return { + backtest, + runs, + currentRun, + runResults, + isLoading, + error, + loadBacktest, + createBacktest, + updateBacktest, + deleteBacktest, + createRun, + pauseRun, + resumeRun, + cancelRun, + updateRunSpeed, + selectRun, + }; +} \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/index.ts b/apps/stock/web-app/src/features/backtest/index.ts index deeabc1..cd88c81 100644 --- a/apps/stock/web-app/src/features/backtest/index.ts +++ b/apps/stock/web-app/src/features/backtest/index.ts @@ -1,4 +1,6 @@ export { BacktestPage } from './BacktestPage'; export { BacktestListPage } from './BacktestListPage'; export { BacktestDetailPage } from './BacktestDetailPage'; +export { BacktestListPageV2 } from './BacktestListPageV2'; +export { BacktestDetailPageV2 } from './BacktestDetailPageV2'; export * from './types'; \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/services/backtestApiV2.ts b/apps/stock/web-app/src/features/backtest/services/backtestApiV2.ts new file mode 100644 index 0000000..1764190 --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/services/backtestApiV2.ts @@ -0,0 +1,230 @@ +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:2003'; + +export interface Backtest { + id: string; + name: string; + strategy: string; + symbols: string[]; + startDate: string; + endDate: string; + initialCapital: number; + config: Record; + createdAt: string; + updatedAt: string; +} + +export interface Run { + id: string; + backtestId: string; + runNumber: number; + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused'; + speedMultiplier: number; + error?: string; + startedAt?: string; + completedAt?: string; + pausedAt?: string; + progress: number; + currentSimulationDate?: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateBacktestRequest { + name: string; + strategy: string; + symbols: string[]; + startDate: string; + endDate: string; + initialCapital: number; + config?: Record; +} + +export interface CreateRunRequest { + speedMultiplier?: number | null; +} + +export interface RunResult { + runId: string; + backtestId: string; + status: 'completed'; + completedAt: string; + config: { + name: string; + strategy: string; + symbols: string[]; + startDate: string; + endDate: string; + initialCapital: number; + commission: number; + slippage: number; + dataFrequency: string; + }; + metrics: any; + equity: any[]; + ohlcData: Record; + trades: any[]; + positions: any; + analytics: any; + executionTime: number; +} + +export const backtestApiV2 = { + // Backtest operations + async createBacktest(request: CreateBacktestRequest): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/backtests`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new Error(`Failed to create backtest: ${response.statusText}`); + } + + return response.json(); + }, + + async getBacktest(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/backtests/${id}`); + + if (!response.ok) { + throw new Error(`Failed to get backtest: ${response.statusText}`); + } + + return response.json(); + }, + + async listBacktests(limit = 50, offset = 0): Promise { + const response = await fetch( + `${API_BASE_URL}/api/v2/backtests?limit=${limit}&offset=${offset}` + ); + + if (!response.ok) { + throw new Error(`Failed to list backtests: ${response.statusText}`); + } + + return response.json(); + }, + + async updateBacktest(id: string, updates: Partial): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/backtests/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updates), + }); + + if (!response.ok) { + throw new Error(`Failed to update backtest: ${response.statusText}`); + } + + return response.json(); + }, + + async deleteBacktest(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/backtests/${id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`Failed to delete backtest: ${response.statusText}`); + } + }, + + // Run operations + async createRun(backtestId: string, request?: CreateRunRequest): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/backtests/${backtestId}/runs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request || {}), + }); + + if (!response.ok) { + throw new Error(`Failed to create run: ${response.statusText}`); + } + + return response.json(); + }, + + async listRuns(backtestId: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/backtests/${backtestId}/runs`); + + if (!response.ok) { + throw new Error(`Failed to list runs: ${response.statusText}`); + } + + return response.json(); + }, + + async getRun(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/runs/${id}`); + + if (!response.ok) { + throw new Error(`Failed to get run: ${response.statusText}`); + } + + return response.json(); + }, + + async getRunResults(runId: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/runs/${runId}/results`); + + if (!response.ok) { + throw new Error(`Failed to get run results: ${response.statusText}`); + } + + return response.json(); + }, + + async pauseRun(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/runs/${id}/pause`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error(`Failed to pause run: ${response.statusText}`); + } + }, + + async resumeRun(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/runs/${id}/resume`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error(`Failed to resume run: ${response.statusText}`); + } + }, + + async cancelRun(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/runs/${id}/cancel`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error(`Failed to cancel run: ${response.statusText}`); + } + }, + + async updateRunSpeed(id: string, speedMultiplier: number): Promise { + const response = await fetch(`${API_BASE_URL}/api/v2/runs/${id}/speed`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ speedMultiplier }), + }); + + if (!response.ok) { + throw new Error(`Failed to update run speed: ${response.statusText}`); + } + }, + + // Note: WebSocket connections are handled by the useWebSocket hook + // which connects to ws://localhost:2003/ws?runId={runId} +}; \ No newline at end of file diff --git a/apps/stock/web-app/src/hooks/useWebSocket.ts b/apps/stock/web-app/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..7f9854c --- /dev/null +++ b/apps/stock/web-app/src/hooks/useWebSocket.ts @@ -0,0 +1,195 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; + +export interface WebSocketMessage { + type: 'connected' | 'run_update' | 'progress' | 'error' | 'completed' | 'pong'; + runId?: string; + data?: any; + timestamp?: string; +} + +interface UseWebSocketOptions { + runId: string | null; + onMessage?: (message: WebSocketMessage) => void; + onProgress?: (progress: number, currentDate?: string) => void; + onError?: (error: string) => void; + onCompleted?: (results?: any) => void; +} + +export function useWebSocket({ + runId, + onMessage, + onProgress, + onError, + onCompleted +}: UseWebSocketOptions) { + const [isConnected, setIsConnected] = useState(false); + const [lastMessage, setLastMessage] = useState(null); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const pingIntervalRef = useRef(null); + const isIntentionalDisconnect = useRef(false); + + const connect = useCallback(() => { + if (!runId) { + console.log('useWebSocket: No runId provided, skipping connection'); + return; + } + + // Check if already connected or connecting + if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { + console.log('useWebSocket: Already connected or connecting to runId:', runId); + return; + } + + // Reset intentional disconnect flag + isIntentionalDisconnect.current = false; + + // Clear any pending reconnect timeout + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + const wsUrl = `ws://localhost:2003/ws?runId=${runId}`; + console.log('Connecting to WebSocket:', wsUrl); + + try { + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + console.log('WebSocket connected'); + setIsConnected(true); + + // Start ping interval + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } + }, 30000); // Ping every 30 seconds + }; + + ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + console.log('WebSocket message:', message); + setLastMessage(message); + + // Call appropriate callbacks + if (onMessage) { + onMessage(message); + } + + switch (message.type) { + case 'progress': + if (onProgress && message.data) { + onProgress(message.data.progress, message.data.currentDate); + } + break; + case 'error': + if (onError && message.data?.error) { + onError(message.data.error); + console.error('Run Error:', message.data.error); + } + break; + case 'completed': + if (onCompleted) { + onCompleted(message.data?.results); + } + console.log('Run Completed: The backtest run has completed successfully.'); + break; + } + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + setIsConnected(false); + }; + + ws.onclose = () => { + console.log('WebSocket disconnected'); + setIsConnected(false); + wsRef.current = null; + + // Clear ping interval + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + + // Attempt to reconnect after 3 seconds + // Only reconnect if not intentionally disconnected and still the same WebSocket + if (runId && wsRef.current === ws && !isIntentionalDisconnect.current) { + reconnectTimeoutRef.current = setTimeout(() => { + console.log('Attempting to reconnect...'); + connect(); + }, 3000); + } + }; + } catch (error) { + console.error('Error creating WebSocket:', error); + setIsConnected(false); + } + }, [runId, onMessage, onProgress, onError, onCompleted]); + + const disconnect = useCallback(() => { + console.log('Disconnecting WebSocket...'); + + // Set flag to prevent automatic reconnection + isIntentionalDisconnect.current = true; + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + + if (wsRef.current) { + // Set a flag to prevent reconnection + const ws = wsRef.current; + wsRef.current = null; + ws.close(); + } + + setIsConnected(false); + }, []); + + const sendMessage = useCallback((message: any) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(message)); + } else { + console.warn('WebSocket is not connected'); + } + }, []); + + useEffect(() => { + // Small delay to prevent rapid reconnections during React's render cycles + const timeoutId = setTimeout(() => { + if (runId) { + connect(); + } else { + disconnect(); + } + }, 100); + + return () => { + clearTimeout(timeoutId); + disconnect(); + }; + }, [runId]); // Only depend on runId, not the functions + + return { + isConnected, + lastMessage, + sendMessage, + disconnect, + connect + }; +} \ No newline at end of file diff --git a/database/postgres/init/08-create-runs-table.sql b/database/postgres/init/08-create-runs-table.sql new file mode 100644 index 0000000..13f6277 --- /dev/null +++ b/database/postgres/init/08-create-runs-table.sql @@ -0,0 +1,58 @@ +-- Create enum for run status +DO $$ BEGIN + CREATE TYPE run_status AS ENUM ('pending', 'running', 'completed', 'failed', 'cancelled', 'paused'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Update backtests table to remove status and error columns (these belong to runs) +ALTER TABLE backtests DROP COLUMN IF EXISTS status; +ALTER TABLE backtests DROP COLUMN IF EXISTS error; + +-- Create runs table +CREATE TABLE IF NOT EXISTS runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + backtest_id UUID NOT NULL REFERENCES backtests(id) ON DELETE CASCADE, + run_number INTEGER NOT NULL, + status run_status NOT NULL DEFAULT 'pending', + speed_multiplier NUMERIC(10, 2) DEFAULT 1.0, + error TEXT, + started_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + paused_at TIMESTAMP WITH TIME ZONE, + progress NUMERIC(5, 2) DEFAULT 0, -- Progress percentage (0-100) + current_simulation_date TIMESTAMP WITH TIME ZONE, -- Current simulation date + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE(backtest_id, run_number) +); + +-- Move backtest_results to reference runs instead of backtests +ALTER TABLE backtest_results DROP CONSTRAINT IF EXISTS backtest_results_backtest_id_fkey; +ALTER TABLE backtest_results RENAME COLUMN backtest_id TO run_id; +ALTER TABLE backtest_results ADD CONSTRAINT backtest_results_run_id_fkey + FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE; + +-- Create indexes for better query performance +CREATE INDEX idx_runs_backtest_id ON runs(backtest_id); +CREATE INDEX idx_runs_status ON runs(status); +CREATE INDEX idx_runs_created_at ON runs(created_at DESC); + +-- Create updated_at trigger for runs +DROP TRIGGER IF EXISTS update_runs_updated_at ON runs; +CREATE TRIGGER update_runs_updated_at BEFORE UPDATE + ON runs FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Create a view for easy querying of latest run per backtest +CREATE OR REPLACE VIEW latest_runs AS +SELECT DISTINCT ON (backtest_id) + r.*, + b.name as backtest_name, + b.strategy, + b.symbols, + b.start_date, + b.end_date, + b.initial_capital +FROM runs r +JOIN backtests b ON b.id = r.backtest_id +ORDER BY backtest_id, created_at DESC; \ No newline at end of file diff --git a/libs/data/mongodb/src/client.ts b/libs/data/mongodb/src/client.ts index 1b63adf..40eeafa 100644 --- a/libs/data/mongodb/src/client.ts +++ b/libs/data/mongodb/src/client.ts @@ -1,4 +1,4 @@ -import type { Logger } from '@stock-bot/engine/logger'; +import type { Logger } from '@stock-bot/logger'; import type { OptionalUnlessRequiredId } from 'mongodb'; import { Collection, Db, MongoClient } from 'mongodb'; import type {