socket reruns

This commit is contained in:
Boki 2025-07-04 17:04:47 -04:00
parent a876f3c35b
commit 11c6c19628
29 changed files with 3921 additions and 233 deletions

View file

@ -87,16 +87,72 @@ export function createBacktestRoutes(container: IServiceContainer): Hono {
} }
}); });
// 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 // Get backtest progress
app.get('/progress', async (c) => { app.get('/progress', async (c) => {
try { try {
// In real implementation, would track progress const status = backtestEngine.getStatus();
return c.json({ return c.json({
status: 'running', status: status.isPaused ? 'paused' : (status.isRunning ? 'running' : 'idle'),
progress: 0.5, progress: status.progress,
processed: 10000, currentTime: new Date(status.currentTime).toISOString(),
total: 20000, isRunning: status.isRunning,
currentTime: new Date().toISOString() isPaused: status.isPaused
}); });
} catch (error) { } catch (error) {
container.logger.error('Error getting backtest progress:', error); container.logger.error('Error getting backtest progress:', error);

View file

@ -93,11 +93,17 @@ export class BacktestEngine extends EventEmitter {
private currentTime: number = 0; private currentTime: number = 0;
private equityCurve: { timestamp: number; value: number }[] = []; private equityCurve: { timestamp: number; value: number }[] = [];
private isRunning = false; private isRunning = false;
private isPaused = false;
private speedMultiplier: number | null = 1; // null means unlimited speed
private lastProcessTime = 0;
private dataManager: DataManager; private dataManager: DataManager;
private marketSimulator: MarketSimulator; private marketSimulator: MarketSimulator;
private performanceAnalyzer: PerformanceAnalyzer; private performanceAnalyzer: PerformanceAnalyzer;
private microstructures: Map<string, MarketMicrostructure> = new Map(); private microstructures: Map<string, MarketMicrostructure> = new Map();
private container: IServiceContainer; private container: IServiceContainer;
private runId: string | null = null;
private progressUpdateInterval = 100; // Update progress every 100 events
private totalEvents = 0;
private initialCapital: number = 100000; private initialCapital: number = 100000;
private commission: number = 0.001; // Default 0.1% private commission: number = 0.001; // Default 0.1%
private slippage: number = 0.0001; // Default 0.01% 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`); 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()}`; 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 { try {
// Load historical data with multi-resolution support // Load historical data with multi-resolution support
@ -486,10 +517,46 @@ export class BacktestEngine extends EventEmitter {
let lastEquityUpdate = 0; let lastEquityUpdate = 0;
const equityUpdateInterval = 60000; // Update equity every minute 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) { 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()!; 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 // Advance time
this.currentTime = event.timestamp; this.currentTime = event.timestamp;
@ -518,18 +585,27 @@ export class BacktestEngine extends EventEmitter {
lastEquityUpdate = this.currentTime; lastEquityUpdate = this.currentTime;
} }
// Emit progress // Send progress update via WebSocket (unless already sent in speed control)
if (this.eventQueue.length % 1000 === 0) { 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', { this.emit('progress', {
processed: this.eventQueue.length, processed: processedCount,
total: this.totalEvents,
remaining: this.eventQueue.length, remaining: this.eventQueue.length,
currentTime: new Date(this.currentTime) currentTime: new Date(this.currentTime),
progress
}); });
} }
} }
// Final equity update // Final equity update
await this.updateEquityCurve(); await this.updateEquityCurve();
// Send 100% progress
this.sendProgressUpdate(100, new Date(this.currentTime).toISOString());
} }
private async processMarketData(data: MarketData): Promise<void> { private async processMarketData(data: MarketData): Promise<void> {
@ -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 }[] { private calculateDrawdown(): { timestamp: number; value: number }[] {
const drawdowns: { timestamp: number; value: number }[] = []; const drawdowns: { timestamp: number; value: number }[] = [];
let peak = this.equityCurve[0]?.value || 0; let peak = this.equityCurve[0]?.value || 0;
@ -913,6 +1008,9 @@ export class BacktestEngine extends EventEmitter {
this.equityCurve = []; this.equityCurve = [];
this.pendingOrders.clear(); this.pendingOrders.clear();
this.ordersListenerSetup = false; this.ordersListenerSetup = false;
this.isPaused = false;
this.speedMultiplier = 1;
this.lastProcessTime = 0;
this.marketSimulator.reset(); this.marketSimulator.reset();
} }
@ -1195,4 +1293,37 @@ export class BacktestEngine extends EventEmitter {
} }
} }
// Playback control methods
async pauseBacktest(): Promise<void> {
this.isPaused = true;
this.container.logger.info('Backtest paused');
}
async resumeBacktest(): Promise<void> {
this.isPaused = false;
this.container.logger.info('Backtest resumed');
}
async stopBacktest(): Promise<void> {
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
};
}
} }

View file

@ -298,25 +298,41 @@ export class StorageService {
const msPerInterval = this.getIntervalMilliseconds(interval); const msPerInterval = this.getIntervalMilliseconds(interval);
let currentTime = new Date(startTime); let currentTime = new Date(startTime);
// Starting price based on symbol // Use current timestamp to seed randomness for different data each run
let basePrice = symbol === 'AAPL' ? 150 : const seed = Date.now();
symbol === 'MSFT' ? 300 : const seedMultiplier = (seed % 1000) / 1000; // 0-1 based on milliseconds
symbol === 'GOOGL' ? 120 : 100;
// 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) { 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 // Random walk with trend - increased volatility for testing
const trend = 0.0002; // Slight upward trend const trend = 0.0002; // Slight upward trend
const volatility = 0.01; // 1% volatility (increased from 0.2%) const volatility = 0.01; // 1% volatility
const change = (Math.random() - 0.5 + trend) * volatility; const change = (walkSeed - 0.5 + trend) * volatility;
basePrice *= (1 + change); basePrice *= (1 + change);
// Generate OHLC data with more realistic volatility // 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 close = basePrice;
const high = Math.max(open, close) * (1 + Math.random() * 0.008); const high = Math.max(open, close) * (1 + highSeed * 0.008);
const low = Math.min(open, close) * (1 - Math.random() * 0.008); const low = Math.min(open, close) * (1 - lowSeed * 0.008);
const volume = 1000000 + Math.random() * 500000; const volume = 1000000 + volumeSeed * 500000;
bars.push({ bars.push({
symbol, symbol,
@ -332,6 +348,8 @@ export class StorageService {
currentTime = new Date(currentTime.getTime() + msPerInterval); currentTime = new Date(currentTime.getTime() + msPerInterval);
} }
this.container.logger.info(`Generated ${bars.length} mock bars for ${symbol} with seed ${seed}`);
return bars; return bars;
} }

View file

@ -1,13 +1,10 @@
/** /**
* Stock Bot Web API * 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 { getLogger } from '@stock-bot/logger';
import { initializeStockConfig } from '@stock-bot/stock-config'; import { initializeStockConfig } from '@stock-bot/stock-config';
// Local imports
import { createRoutes } from './routes/create-routes';
// Initialize configuration with service-specific overrides // Initialize configuration with service-specific overrides
const config = initializeStockConfig('webApi'); const config = initializeStockConfig('webApi');
@ -23,66 +20,10 @@ if (config.queue) {
const logger = getLogger('web-api'); const logger = getLogger('web-api');
logger.info('Service configuration:', config); logger.info('Service configuration:', config);
// Create service application // Import and start WebSocket-enabled server
const app = new ServiceApplication( import('./server-with-websocket').then(({ startServerWithWebSocket }) => {
config, startServerWithWebSocket().catch(error => {
{ logger.fatal('Failed to start web API service with WebSocket', { error });
serviceName: 'web-api', process.exit(1);
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);
}); });

View file

@ -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;
}

View file

@ -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;
}

View file

@ -4,22 +4,33 @@
*/ */
import { Hono } from 'hono'; import { Hono } from 'hono';
import { cors } from 'hono/cors';
import type { IServiceContainer } from '@stock-bot/handlers'; import type { IServiceContainer } from '@stock-bot/handlers';
import { createExchangeRoutes } from './exchange.routes'; import { createExchangeRoutes } from './exchange.routes';
import { createHealthRoutes } from './health.routes'; import { createHealthRoutes } from './health.routes';
import { createMonitoringRoutes } from './monitoring.routes'; import { createMonitoringRoutes } from './monitoring.routes';
import { createPipelineRoutes } from './pipeline.routes'; import { createPipelineRoutes } from './pipeline.routes';
import { createBacktestRoutes } from './backtest.routes'; import { createBacktestRoutes } from './backtest.routes';
import { createBacktestV2Routes } from './backtests-v2.routes';
export function createRoutes(container: IServiceContainer): Hono { export function createRoutes(container: IServiceContainer): Hono {
const app = new 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 // Create routes with container
const healthRoutes = createHealthRoutes(container); const healthRoutes = createHealthRoutes(container);
const exchangeRoutes = createExchangeRoutes(container); const exchangeRoutes = createExchangeRoutes(container);
const monitoringRoutes = createMonitoringRoutes(container); const monitoringRoutes = createMonitoringRoutes(container);
const pipelineRoutes = createPipelineRoutes(container); const pipelineRoutes = createPipelineRoutes(container);
const backtestRoutes = createBacktestRoutes(container); const backtestRoutes = createBacktestRoutes(container);
const backtestV2Routes = createBacktestV2Routes(container);
// Mount routes // Mount routes
app.route('/health', healthRoutes); app.route('/health', healthRoutes);
@ -27,6 +38,7 @@ export function createRoutes(container: IServiceContainer): Hono {
app.route('/api/system/monitoring', monitoringRoutes); app.route('/api/system/monitoring', monitoringRoutes);
app.route('/api/pipeline', pipelineRoutes); app.route('/api/pipeline', pipelineRoutes);
app.route('/', backtestRoutes); // Mounted at root since routes already have /api prefix app.route('/', backtestRoutes); // Mounted at root since routes already have /api prefix
app.route('/', backtestV2Routes); // V2 routes also mounted at root
return app; return app;
} }

View file

@ -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);
}
}

View file

@ -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

View file

@ -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<string, any>;
}
export interface Backtest {
id: string;
name: string;
strategy: string;
symbols: string[];
startDate: Date;
endDate: Date;
initialCapital: number;
config: Record<string, any>;
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<string, any> = 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<Backtest> {
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<Backtest | null> {
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<BacktestRequest>): Promise<Backtest | null> {
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<Backtest[]> {
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<void> {
await this.container.postgres.query(
'DELETE FROM backtests WHERE id = $1',
[id]
);
}
// Run operations
async createRun(request: RunRequest): Promise<Run> {
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<Run | null> {
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<Run[]> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.updateRunStatus(id, 'cancelled');
// TODO: Send cancel command to orchestrator
}
async updateRunSpeed(id: string, speedMultiplier: number | null): Promise<void> {
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<any> {
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<void> {
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<void> {
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,
};
}
}

View file

@ -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<string, Set<any>>();
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<RunUpdateMessage>) {
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<typeof createWebSocketHandler>;

View file

@ -3,7 +3,7 @@ import { DashboardPage } from '@/features/dashboard';
import { ExchangesPage } from '@/features/exchanges'; import { ExchangesPage } from '@/features/exchanges';
import { MonitoringPage } from '@/features/monitoring'; import { MonitoringPage } from '@/features/monitoring';
import { PipelinePage } from '@/features/pipeline'; import { PipelinePage } from '@/features/pipeline';
import { BacktestListPage, BacktestDetailPage } from '@/features/backtest'; import { BacktestListPageV2, BacktestDetailPageV2 } from '@/features/backtest';
import { SymbolsPage, SymbolDetailPage } from '@/features/symbols'; import { SymbolsPage, SymbolDetailPage } from '@/features/symbols';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
@ -32,8 +32,11 @@ export function App() {
element={<div className="p-4">Analytics Page - Coming Soon</div>} element={<div className="p-4">Analytics Page - Coming Soon</div>}
/> />
<Route path="backtests"> <Route path="backtests">
<Route index element={<BacktestListPage />} /> <Route index element={<BacktestListPageV2 />} />
<Route path=":id" element={<BacktestDetailPage />} /> <Route path=":id">
<Route index element={<BacktestDetailPageV2 />} />
<Route path="run/:runId" element={<BacktestDetailPageV2 />} />
</Route>
</Route> </Route>
<Route path="settings" element={<div className="p-4">Settings Page - Coming Soon</div>} /> <Route path="settings" element={<div className="p-4">Settings Page - Coming Soon</div>} />
<Route path="system/monitoring" element={<MonitoringPage />} /> <Route path="system/monitoring" element={<MonitoringPage />} />

View file

@ -335,8 +335,13 @@ export function Chart({
// Cleanup // Cleanup
return () => { return () => {
window.removeEventListener('resize', handleResize); window.removeEventListener('resize', handleResize);
if (chart) { // Clear all refs before removing the chart
chart.remove(); 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]); }, [data, height, type, showVolume, theme, overlayData, tradeMarkers]);

View file

@ -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 (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-text-secondary mb-4">Please select a run to view {activeTab}</p>
<button
onClick={() => setActiveTab('runs')}
className="text-primary-500 hover:text-primary-600 font-medium"
>
Go to Runs
</button>
</div>
</div>
);
}
switch (activeTab) {
case 'runs':
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-text-primary">Run History</h3>
<button
onClick={() => setShowNewRun(true)}
className="inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
>
<PlusIcon className="w-4 h-4 mr-2" />
New Run
</button>
</div>
{showNewRun && (
<div className="bg-surface-secondary rounded-lg border border-border p-4 space-y-4">
<h4 className="text-base font-medium text-text-primary">Start New Run</h4>
<p className="text-sm text-text-secondary">
This will start a new backtest run with the current configuration.
</p>
<div className="flex space-x-2">
<button
onClick={() => {
handleCreateRun(1000); // Always use max speed
}}
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
>
Start Run
</button>
<button
onClick={() => setShowNewRun(false)}
className="px-4 py-2 bg-surface-tertiary text-text-primary rounded-md text-sm font-medium hover:bg-surface-secondary transition-colors"
>
Cancel
</button>
</div>
</div>
)}
<RunsList
runs={runs}
currentRunId={currentRun?.id}
onSelectRun={selectRun}
/>
</div>
);
case 'settings':
return (
<div className="max-w-4xl mx-auto">
<BacktestConfiguration
onSubmit={handleConfigSubmit}
disabled={false}
initialConfig={backtestConfig}
/>
</div>
);
case 'metrics':
return (
<div className="h-full overflow-y-auto">
<BacktestMetrics
result={runResults}
isLoading={isLoading}
/>
</div>
);
case 'playback':
return (
<BacktestPlayback
result={runResults}
isLoading={isLoading}
/>
);
case 'trades':
return (
<div className="h-full overflow-y-auto">
<BacktestTrades
result={runResults}
isLoading={isLoading}
/>
</div>
);
default:
return null;
}
};
if (!backtest && !isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<p className="text-text-secondary mb-4">Backtest not found</p>
<button
onClick={() => navigate('/backtests')}
className="text-primary-500 hover:text-primary-600"
>
Back to Backtests
</button>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header with Tabs */}
<div className="flex-shrink-0 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<button
onClick={() => navigate('/backtests')}
className="p-2 hover:bg-surface-tertiary transition-colors"
aria-label="Back to backtests"
>
<ArrowLeftIcon className="h-5 w-5 text-text-secondary" />
</button>
<div className="px-2">
<h1 className="text-sm font-bold text-text-primary flex items-center gap-1">
{backtest?.name || 'Loading...'}
{currentRun && (
<span className="text-text-secondary font-normal">
- Run #{currentRun.runNumber}
</span>
)}
</h1>
<p className="text-xs text-text-secondary">
{backtest?.strategy || ''} {backtest?.symbols.join(', ') || ''}
</p>
</div>
{/* Tabs */}
<nav className="flex ml-4" aria-label="Tabs">
{(currentRun ? runTabs : []).concat(baseTabs).map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
whitespace-nowrap py-2 px-3 border-b-2 font-medium text-sm
${activeTab === tab.id
? 'border-primary-500 text-primary-500'
: 'border-transparent text-text-secondary hover:text-text-primary hover:border-border'
}
`}
>
{tab.name}
</button>
))}
</nav>
</div>
{/* Run Controls */}
{currentRun && (
<div className="px-4">
<RunControlsCompact
run={currentRun}
onPause={pauseRun}
onResume={resumeRun}
onRerun={handleRerun}
onSpeedChange={updateRunSpeed}
/>
</div>
)}
</div>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-auto p-4">
{error && (
<div className="mb-4 p-4 bg-error/10 border border-error/20 rounded-lg">
<p className="text-sm text-error">{error}</p>
</div>
)}
{renderTabContent()}
</div>
</div>
);
}

View file

@ -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<Backtest[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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<Backtest, 'id' | 'createdAt' | 'updatedAt'>) => {
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<Backtest>[] = [
{
accessorKey: 'name',
header: 'Name',
size: 200,
cell: ({ row }) => (
<div className="font-medium text-text-primary">
{row.original.name}
</div>
),
},
{
accessorKey: 'strategy',
header: 'Strategy',
size: 150,
cell: ({ row }) => (
<span className="capitalize text-text-primary">
{row.original.strategy}
</span>
),
},
{
accessorKey: 'symbols',
header: 'Symbols',
size: 200,
cell: ({ row }) => (
<div className="text-sm text-text-secondary">
{row.original.symbols.join(', ')}
</div>
),
},
{
accessorKey: 'dateRange',
header: 'Date Range',
size: 200,
cell: ({ row }) => (
<div className="text-sm text-text-secondary">
{new Date(row.original.startDate).toLocaleDateString()} - {new Date(row.original.endDate).toLocaleDateString()}
</div>
),
},
{
accessorKey: 'initialCapital',
header: 'Initial Capital',
size: 150,
cell: ({ row }) => (
<div className="text-sm text-text-primary font-mono">
${row.original.initialCapital.toLocaleString()}
</div>
),
},
{
accessorKey: 'createdAt',
header: 'Created',
size: 180,
cell: ({ row }) => (
<div className="text-sm text-text-secondary">
{new Date(row.original.createdAt).toLocaleString()}
</div>
),
},
];
return (
<div className="flex flex-col h-full space-y-6">
<div className="flex-shrink-0 flex justify-between items-center">
<div>
<h1 className="text-lg font-bold text-text-primary mb-2">Backtests</h1>
<p className="text-text-secondary text-sm">
Create and manage backtest configurations
</p>
</div>
<button
onClick={() => setShowCreateDialog(true)}
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors flex items-center space-x-2"
>
<PlusIcon className="w-4 h-4" />
<span>New Backtest</span>
</button>
</div>
{error && (
<div className="bg-error/10 border border-error text-error px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="flex-1 min-h-0">
<DataTable
data={backtests}
columns={columns}
loading={isLoading}
onRowClick={(row) => navigate(`/backtests/${row.id}`)}
height={600}
/>
</div>
{showCreateDialog && (
<CreateBacktestDialog
onClose={() => setShowCreateDialog(false)}
onCreate={handleCreateBacktest}
/>
)}
</div>
);
}

View file

@ -1,5 +1,5 @@
import type { BacktestResult } from '../types/backtest.types'; import type { BacktestResult } from '../types/backtest.types';
import { useState, useMemo } from 'react'; import { useState, useMemo, memo } from 'react';
import { Chart } from '../../../components/charts'; import { Chart } from '../../../components/charts';
interface BacktestChartProps { interface BacktestChartProps {
@ -7,7 +7,8 @@ interface BacktestChartProps {
isLoading: boolean; 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<string>(''); const [selectedSymbol, setSelectedSymbol] = useState<string>('');
const chartData = useMemo(() => { const chartData = useMemo(() => {
@ -15,32 +16,58 @@ export function BacktestChart({ result, isLoading }: BacktestChartProps) {
const symbols = Object.keys(result.ohlcData); const symbols = Object.keys(result.ohlcData);
const symbol = selectedSymbol || symbols[0] || ''; const symbol = selectedSymbol || symbols[0] || '';
const ohlcData = result.ohlcData[symbol] || []; const ohlcData = result.ohlcData[symbol] || [];
const equityData = result.equity.map(e => ({
time: new Date(e.date).getTime() / 1000, // Remove excessive logging in production
value: e.value // 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 // Find trades for this symbol
const symbolTrades = result.trades?.filter(t => t.symbol === symbol) || []; const symbolTrades = result.trades?.filter(t => t.symbol === symbol) || [];
const tradeMarkers = symbolTrades.map(trade => ({ const tradeMarkers = symbolTrades
time: new Date(trade.entryDate).getTime() / 1000, .filter(trade => trade.entryPrice != null && trade.entryDate != null)
position: 'belowBar' as const, .map(trade => ({
color: trade.side === 'buy' ? '#10b981' : '#ef4444', time: new Date(trade.entryDate).getTime() / 1000,
shape: trade.side === 'buy' ? 'arrowUp' : 'arrowDown' as const, position: 'belowBar' as const,
text: `${trade.side === 'buy' ? 'Buy' : 'Sell'} @ $${trade.entryPrice.toFixed(2)}` 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 { const processedOhlcData = ohlcData
ohlcData: ohlcData.map(d => ({ .filter(d => d && d.timestamp != null && d.open != null && d.high != null && d.low != null && d.close != null)
time: d.time / 1000, .map(d => ({
time: d.timestamp / 1000, // timestamp is already in milliseconds
open: d.open, open: d.open,
high: d.high, high: d.high,
low: d.low, low: d.low,
close: d.close, close: d.close,
volume: d.volume volume: d.volume || 0
})), }));
return {
ohlcData: processedOhlcData,
equityData, equityData,
tradeMarkers, tradeMarkers,
symbols symbols
@ -90,4 +117,4 @@ export function BacktestChart({ result, isLoading }: BacktestChartProps) {
</div> </div>
</div> </div>
); );
} });

View file

@ -35,32 +35,32 @@ export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<MetricsCard <MetricsCard
title="Total Return" title="Total Return"
value={`${(metrics.totalReturn * 100).toFixed(2)}%`} value={`${((metrics.totalReturn ?? 0) * 100).toFixed(2)}%`}
color={metrics.totalReturn >= 0 ? 'success' : 'error'} color={(metrics.totalReturn ?? 0) >= 0 ? 'success' : 'error'}
/> />
<MetricsCard <MetricsCard
title="Sharpe Ratio" title="Sharpe Ratio"
value={metrics.sharpeRatio.toFixed(2)} value={(metrics.sharpeRatio ?? 0).toFixed(2)}
color={metrics.sharpeRatio > 1 ? 'success' : metrics.sharpeRatio > 0 ? 'warning' : 'error'} color={(metrics.sharpeRatio ?? 0) > 1 ? 'success' : (metrics.sharpeRatio ?? 0) > 0 ? 'warning' : 'error'}
/> />
<MetricsCard <MetricsCard
title="Max Drawdown" title="Max Drawdown"
value={`${(metrics.maxDrawdown * 100).toFixed(2)}%`} value={`${((metrics.maxDrawdown ?? 0) * 100).toFixed(2)}%`}
color={metrics.maxDrawdown > -0.2 ? 'warning' : 'error'} color={(metrics.maxDrawdown ?? 0) > -0.2 ? 'warning' : 'error'}
/> />
<MetricsCard <MetricsCard
title="Win Rate" title="Win Rate"
value={`${(metrics.winRate * 100).toFixed(1)}%`} value={`${((metrics.winRate ?? 0) * 100).toFixed(1)}%`}
color={metrics.winRate > 0.5 ? 'success' : 'warning'} color={(metrics.winRate ?? 0) > 0.5 ? 'success' : 'warning'}
/> />
<MetricsCard <MetricsCard
title="Total Trades" title="Total Trades"
value={metrics.totalTrades.toString()} value={(metrics.totalTrades ?? 0).toString()}
/> />
<MetricsCard <MetricsCard
title="Profit Factor" title="Profit Factor"
value={metrics.profitFactor.toFixed(2)} value={(metrics.profitFactor ?? 0).toFixed(2)}
color={metrics.profitFactor > 1.5 ? 'success' : metrics.profitFactor > 1 ? 'warning' : 'error'} color={(metrics.profitFactor ?? 0) > 1.5 ? 'success' : (metrics.profitFactor ?? 0) > 1 ? 'warning' : 'error'}
/> />
</div> </div>
@ -70,20 +70,20 @@ export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-text-secondary text-sm">Profitable Trades</span> <span className="text-text-secondary text-sm">Profitable Trades</span>
<span className="text-text-primary text-sm font-medium">{metrics.profitableTrades}</span> <span className="text-text-primary text-sm font-medium">{metrics.profitableTrades ?? 0}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-text-secondary text-sm">Average Win</span> <span className="text-text-secondary text-sm">Average Win</span>
<span className="text-success text-sm font-medium">${metrics.avgWin.toFixed(2)}</span> <span className="text-success text-sm font-medium">${(metrics.avgWin ?? 0).toFixed(2)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-text-secondary text-sm">Average Loss</span> <span className="text-text-secondary text-sm">Average Loss</span>
<span className="text-error text-sm font-medium">${Math.abs(metrics.avgLoss).toFixed(2)}</span> <span className="text-error text-sm font-medium">${Math.abs(metrics.avgLoss ?? 0).toFixed(2)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-text-secondary text-sm">Expectancy</span> <span className="text-text-secondary text-sm">Expectancy</span>
<span className={`text-sm font-medium ${metrics.expectancy >= 0 ? 'text-success' : 'text-error'}`}> <span className={`text-sm font-medium ${(metrics.expectancy ?? 0) >= 0 ? 'text-success' : 'text-error'}`}>
${metrics.expectancy.toFixed(2)} ${(metrics.expectancy ?? 0).toFixed(2)}
</span> </span>
</div> </div>
</div> </div>
@ -94,11 +94,11 @@ export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-text-secondary text-sm">Calmar Ratio</span> <span className="text-text-secondary text-sm">Calmar Ratio</span>
<span className="text-text-primary text-sm font-medium">{metrics.calmarRatio.toFixed(2)}</span> <span className="text-text-primary text-sm font-medium">{(metrics.calmarRatio ?? 0).toFixed(2)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-text-secondary text-sm">Sortino Ratio</span> <span className="text-text-secondary text-sm">Sortino Ratio</span>
<span className="text-text-primary text-sm font-medium">{metrics.sortinoRatio.toFixed(2)}</span> <span className="text-text-primary text-sm font-medium">{(metrics.sortinoRatio ?? 0).toFixed(2)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-text-secondary text-sm">Exposure Time</span> <span className="text-text-secondary text-sm">Exposure Time</span>

View file

@ -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 (
<div className="h-full flex items-center justify-center">
<div className="text-text-secondary">Loading playback data...</div>
</div>
);
}
if (!result) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-text-secondary">No data available</div>
</div>
);
}
// 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 (
<div className="h-full flex flex-col space-y-4">
{/* Chart Section */}
<div className="flex-1">
<BacktestChart result={result} isLoading={isLoading} />
</div>
{/* Open Positions Section */}
{openPositions.length > 0 && (
<div className="flex-shrink-0">
<div className="bg-surface-secondary rounded-lg border border-border p-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-medium text-text-primary">
Open Positions ({openPositions.length})
</h3>
<button
onClick={() => setShowPositions(!showPositions)}
className="text-xs text-text-secondary hover:text-text-primary"
>
{showPositions ? 'Hide' : 'Show'}
</button>
</div>
{showPositions && (
<div className="space-y-2">
<div className="grid grid-cols-6 gap-2 text-xs font-medium text-text-secondary pb-2 border-b border-border">
<div>Symbol</div>
<div>Side</div>
<div className="text-right">Quantity</div>
<div className="text-right">Entry Price</div>
<div className="text-right">Current Price</div>
<div className="text-right">P&L</div>
</div>
{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 (
<div key={index} className="grid grid-cols-6 gap-2 text-sm">
<div className="font-medium text-text-primary">{position.symbol}</div>
<div className={side === 'buy' ? 'text-success' : 'text-error'}>
{side.toUpperCase()}
</div>
<div className="text-right text-text-primary">{absQuantity}</div>
<div className="text-right text-text-primary">
${avgPrice.toFixed(2)}
</div>
<div className="text-right text-text-primary">
${currentPrice.toFixed(2)}
</div>
<div className={`text-right font-medium ${pnl >= 0 ? 'text-success' : 'text-error'}`}>
${pnl.toFixed(2)} ({pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%)
</div>
</div>
);
})}
<div className="pt-2 border-t border-border">
<div className="grid grid-cols-6 gap-2 text-sm font-medium">
<div className="col-span-5 text-right text-text-secondary">Total P&L:</div>
<div className={`text-right ${
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) >= 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)}
</div>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
});

View file

@ -1,5 +1,6 @@
import type { BacktestResult } from '../types/backtest.types'; 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 { interface BacktestTradesProps {
result: BacktestResult | null; result: BacktestResult | null;
@ -27,6 +28,124 @@ export function BacktestTrades({ result, isLoading }: BacktestTradesProps) {
); );
} }
const columns: ColumnDef<typeof result.trades[0]>[] = [
{
accessorKey: 'symbol',
header: 'Symbol',
size: 100,
cell: ({ getValue }) => (
<span className="text-sm font-medium text-text-primary">{getValue() as string}</span>
),
},
{
accessorKey: 'side',
header: 'Side',
size: 80,
cell: ({ getValue }) => {
const side = getValue() as string || 'unknown';
return (
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-md ${
side === 'buy'
? 'bg-success/10 text-success'
: 'bg-error/10 text-error'
}`}>
{side.toUpperCase()}
</span>
);
},
},
{
accessorKey: 'entryDate',
header: 'Entry Date',
size: 180,
cell: ({ getValue }) => {
const date = getValue() as string;
return (
<span className="text-sm text-text-secondary">
{date ? new Date(date).toLocaleString() : '-'}
</span>
);
},
},
{
accessorKey: 'entryPrice',
header: 'Entry Price',
size: 100,
cell: ({ getValue }) => {
const price = getValue() as number;
return (
<span className="text-sm text-text-primary">
${price != null ? price.toFixed(2) : '0.00'}
</span>
);
},
},
{
accessorKey: 'exitDate',
header: 'Exit Date',
size: 180,
cell: ({ getValue }) => {
const date = getValue() as string | null;
return (
<span className="text-sm text-text-secondary">
{date ? new Date(date).toLocaleString() : '-'}
</span>
);
},
},
{
accessorKey: 'exitPrice',
header: 'Exit Price',
size: 100,
cell: ({ getValue }) => {
const price = getValue() as number;
return (
<span className="text-sm text-text-primary">
${price != null ? price.toFixed(2) : '0.00'}
</span>
);
},
},
{
accessorKey: 'quantity',
header: 'Quantity',
size: 80,
cell: ({ getValue }) => (
<span className="text-sm text-text-primary">{getValue() as number || 0}</span>
),
},
{
accessorKey: 'pnl',
header: 'P&L',
size: 100,
cell: ({ getValue }) => {
const pnl = getValue() as number || 0;
return (
<span className={`text-sm font-medium ${
pnl >= 0 ? 'text-success' : 'text-error'
}`}>
${pnl != null ? pnl.toFixed(2) : '0.00'}
</span>
);
},
},
{
accessorKey: 'pnlPercent',
header: 'P&L %',
size: 100,
cell: ({ getValue }) => {
const pnlPercent = getValue() as number || 0;
return (
<span className={`text-sm font-medium ${
pnlPercent >= 0 ? 'text-success' : 'text-error'
}`}>
{pnlPercent != null ? pnlPercent.toFixed(2) : '0.00'}%
</span>
);
},
},
];
return ( return (
<div> <div>
<div className="mb-4"> <div className="mb-4">
@ -36,108 +155,78 @@ export function BacktestTrades({ result, isLoading }: BacktestTradesProps) {
</p> </p>
</div> </div>
<div className="overflow-x-auto bg-surface-secondary rounded-lg border border-border"> <DataTable
<table className="w-full"> data={result.trades}
<thead className="bg-background border-b border-border"> columns={columns}
<tr> className="bg-surface-secondary rounded-lg border border-border"
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider"> height={400}
Symbol />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Side
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Entry Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Entry Price
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Exit Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Exit Price
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Quantity
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
P&L
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
P&L %
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{result.trades.map((trade) => (
<tr key={trade.id} className="hover:bg-background/50 transition-colors">
<td className="px-4 py-3 text-sm text-text-primary font-medium">
{trade.symbol}
</td>
<td className="px-4 py-3 text-sm">
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-md ${
trade.side === 'buy'
? 'bg-success/10 text-success'
: 'bg-error/10 text-error'
}`}>
{trade.side.toUpperCase()}
</span>
</td>
<td className="px-4 py-3 text-sm text-text-secondary">
{new Date(trade.entryDate).toLocaleString()}
</td>
<td className="px-4 py-3 text-sm text-text-primary">
${trade.entryPrice.toFixed(2)}
</td>
<td className="px-4 py-3 text-sm text-text-secondary">
{trade.exitDate ? new Date(trade.exitDate).toLocaleString() : '-'}
</td>
<td className="px-4 py-3 text-sm text-text-primary">
${trade.exitPrice.toFixed(2)}
</td>
<td className="px-4 py-3 text-sm text-text-primary">
{trade.quantity}
</td>
<td className={`px-4 py-3 text-sm font-medium ${
trade.pnl >= 0 ? 'text-success' : 'text-error'
}`}>
${trade.pnl.toFixed(2)}
</td>
<td className={`px-4 py-3 text-sm font-medium ${
trade.pnlPercent >= 0 ? 'text-success' : 'text-error'
}`}>
{trade.pnlPercent.toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
{result.positions && Object.keys(result.positions).length > 0 && ( {result.positions && Object.keys(result.positions).length > 0 && (() => {
<div className="mt-4 bg-surface-secondary rounded-lg border border-border p-4"> const positionsArray = Object.entries(result.positions).map(([symbol, position]) => ({
<h4 className="text-sm font-medium text-text-secondary mb-3">Open Positions</h4> symbol,
<div className="space-y-2"> ...position
{Object.entries(result.positions).map(([symbol, position]) => ( }));
<div key={symbol} className="flex justify-between items-center p-2 bg-background rounded">
<span className="text-sm font-medium text-text-primary">{symbol}</span> const positionColumns: ColumnDef<typeof positionsArray[0]>[] = [
<div className="flex space-x-4 text-sm"> {
<span className="text-text-secondary"> accessorKey: 'symbol',
Qty: {position.quantity} header: 'Symbol',
</span> size: 100,
<span className="text-text-secondary"> cell: ({ getValue }) => (
Avg: ${position.averagePrice.toFixed(2)} <span className="text-sm font-medium text-text-primary">{getValue() as string}</span>
</span> ),
<span className={position.unrealizedPnl >= 0 ? 'text-success' : 'text-error'}> },
P&L: ${position.unrealizedPnl.toFixed(2)} {
</span> accessorKey: 'quantity',
</div> header: 'Quantity',
</div> size: 100,
))} cell: ({ getValue }) => (
<span className="text-sm text-text-primary">{getValue() as number || 0}</span>
),
},
{
accessorKey: 'averagePrice',
header: 'Avg Price',
size: 120,
cell: ({ getValue }) => {
const price = getValue() as number;
return (
<span className="text-sm text-text-primary">
${price != null ? price.toFixed(2) : '0.00'}
</span>
);
},
},
{
accessorKey: 'unrealizedPnl',
header: 'Unrealized P&L',
size: 150,
cell: ({ getValue }) => {
const pnl = getValue() as number || 0;
return (
<span className={`text-sm font-medium ${
pnl >= 0 ? 'text-success' : 'text-error'
}`}>
${pnl != null ? pnl.toFixed(2) : '0.00'}
</span>
);
},
},
];
return (
<div className="mt-6">
<h4 className="text-base font-medium text-text-primary mb-3">Open Positions</h4>
<DataTable
data={positionsArray}
columns={positionColumns}
className="bg-surface-secondary rounded-lg border border-border"
height={200}
/>
</div> </div>
</div> );
)} })()}
</div> </div>
); );
} }

View file

@ -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<Backtest, 'id' | 'createdAt' | 'updatedAt'>) => 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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-surface-secondary rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold text-text-primary">Create New Backtest</h2>
<button
onClick={onClose}
className="p-1 hover:bg-surface-tertiary rounded transition-colors"
>
<XMarkIcon className="w-5 h-5 text-text-secondary" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Backtest Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Strategy
</label>
<select
value={formData.strategy}
onChange={(e) => setFormData({ ...formData, strategy: 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"
>
<option value="moving-average">Moving Average</option>
<option value="momentum">Momentum</option>
<option value="mean-reversion">Mean Reversion</option>
<option value="pairs-trading">Pairs Trading</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Symbols
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={symbolInput}
onChange={(e) => 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)"
/>
<button
type="button"
onClick={addSymbol}
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
>
Add
</button>
</div>
<div className="flex flex-wrap gap-2">
{formData.symbols.map(symbol => (
<span
key={symbol}
className="inline-flex items-center gap-1 px-3 py-1 bg-background border border-border rounded-full text-sm text-text-primary"
>
{symbol}
<button
type="button"
onClick={() => removeSymbol(symbol)}
className="hover:text-error transition-colors"
>
<XMarkIcon className="w-4 h-4" />
</button>
</span>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Start Date
</label>
<input
type="date"
value={formData.startDate}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
End Date
</label>
<input
type="date"
value={formData.endDate}
onChange={(e) => 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
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Initial Capital
</label>
<input
type="number"
value={formData.initialCapital}
onChange={(e) => 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
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Commission (per trade)
</label>
<input
type="number"
value={formData.commission}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Slippage (%)
</label>
<input
type="number"
value={formData.slippage}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-border text-text-secondary rounded-md text-sm font-medium hover:bg-surface-tertiary transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
>
Create Backtest
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -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 (
<div className="bg-surface-secondary rounded-lg border border-border p-4">
<h3 className="text-base font-medium text-text-primary mb-4">Run Control</h3>
<div className="space-y-4">
{/* Progress Bar */}
<div>
<div className="flex justify-between text-sm text-text-secondary mb-1">
<span>Progress</span>
<span>{run.progress.toFixed(1)}%</span>
</div>
<div className="w-full bg-background rounded-full h-3 overflow-hidden">
<div
className="bg-primary-500 h-3 transition-all duration-300 relative"
style={{ width: `${run.progress}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-primary-500 to-primary-400 opacity-50"></div>
</div>
</div>
</div>
{/* Control Buttons */}
<div className="flex items-center space-x-2">
{run.status === 'running' ? (
<button
onClick={onPause}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-warning text-white rounded-md text-sm font-medium hover:bg-warning/90 transition-colors"
>
<PauseIcon className="w-4 h-4" />
Pause
</button>
) : run.status === 'paused' ? (
<button
onClick={onResume}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-success text-white rounded-md text-sm font-medium hover:bg-success/90 transition-colors"
>
<PlayIcon className="w-4 h-4" />
Resume
</button>
) : null}
{(run.status === 'running' || run.status === 'paused') && (
<button
onClick={onCancel}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-error text-white rounded-md text-sm font-medium hover:bg-error/90 transition-colors"
>
<StopIcon className="w-4 h-4" />
Cancel
</button>
)}
</div>
{/* Speed Control */}
{run.status === 'running' && (
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Playback Speed
</label>
<div className="flex items-center space-x-2">
<button
onClick={() => {
const currentIndex = speedOptions.findIndex(s => s.value === speedMultiplier);
if (currentIndex > 0) {
handleSpeedChange(speedOptions[currentIndex - 1].value);
}
}}
className="p-1 rounded hover:bg-surface-tertiary transition-colors"
disabled={speedMultiplier <= 0.1}
>
<BackwardIcon className="w-4 h-4 text-text-secondary" />
</button>
<select
value={speedMultiplier}
onChange={(e) => handleSpeedChange(parseFloat(e.target.value))}
className="flex-1 px-3 py-1.5 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
>
{speedOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button
onClick={() => {
const currentIndex = speedOptions.findIndex(s => s.value === speedMultiplier);
if (currentIndex < speedOptions.length - 1) {
handleSpeedChange(speedOptions[currentIndex + 1].value);
}
}}
className="p-1 rounded hover:bg-surface-tertiary transition-colors"
disabled={speedMultiplier >= 1000}
>
<ForwardIcon className="w-4 h-4 text-text-secondary" />
</button>
</div>
</div>
)}
{/* Run Info */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-text-secondary">Status:</span>
<span className="ml-2 text-text-primary font-medium">
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
</span>
</div>
<div>
<span className="text-text-secondary">Duration:</span>
<span className="ml-2 text-text-primary font-medium">
{formatDuration()}
</span>
</div>
{run.currentSimulationDate && (
<div className="col-span-2">
<span className="text-text-secondary">Current Date:</span>
<span className="ml-2 text-text-primary font-medium">
{formatDate(run.currentSimulationDate)}
</span>
</div>
)}
{run.error && (
<div className="col-span-2">
<span className="text-text-secondary">Error:</span>
<span className="ml-2 text-error text-xs">
{run.error}
</span>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="flex items-center space-x-3">
{/* Progress */}
<div className="flex items-center space-x-2">
<span className="text-xs text-text-secondary">Progress:</span>
<div className="w-24 bg-background rounded-full h-2 overflow-hidden">
<div
className="bg-primary-500 h-2 transition-all duration-300 relative"
style={{ width: `${run.progress}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-primary-500 to-primary-400 opacity-50"></div>
</div>
</div>
<span className="text-xs text-text-primary font-medium">{run.progress.toFixed(0)}%</span>
</div>
{/* Control Buttons */}
<div className="flex items-center space-x-1">
{isRunning ? (
<button
onClick={onPause}
className="p-1 rounded hover:bg-surface-tertiary transition-colors"
title="Pause"
>
<PauseIcon className="w-4 h-4 text-text-primary" />
</button>
) : isPaused ? (
<button
onClick={onResume}
className="p-1 rounded hover:bg-surface-tertiary transition-colors"
title="Resume"
>
<PlayIcon className="w-4 h-4 text-text-primary" />
</button>
) : null}
{onNext && (
<button
onClick={onNext}
className="p-1 rounded hover:bg-surface-tertiary transition-colors disabled:opacity-50"
disabled={!isRunning && !isPaused}
title="Next"
>
<ForwardIcon className="w-4 h-4 text-text-primary" />
</button>
)}
<button
onClick={() => onRerun(null)} // Always use max speed
className="p-1 rounded hover:bg-surface-tertiary transition-colors"
title="Rerun"
>
<ArrowPathIcon className="w-4 h-4 text-text-primary" />
</button>
</div>
{/* Status Indicator */}
{run.status !== 'running' && run.status !== 'paused' && (
<>
<div className="h-6 w-px bg-border"></div>
<span className={`text-xs font-medium ${
run.status === 'completed' ? 'text-success' :
run.status === 'failed' ? 'text-error' :
run.status === 'cancelled' ? 'text-text-secondary' :
'text-text-primary'
}`}>
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
</span>
</>
)}
</div>
);
}

View file

@ -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 <CheckCircleIcon className="w-4 h-4 text-success" />;
case 'failed':
return <XCircleIcon className="w-4 h-4 text-error" />;
case 'cancelled':
return <XCircleIcon className="w-4 h-4 text-text-secondary" />;
case 'running':
return <PlayIcon className="w-4 h-4 text-primary-500" />;
case 'paused':
return <PauseIcon className="w-4 h-4 text-warning" />;
case 'pending':
return <ClockIcon className="w-4 h-4 text-text-secondary" />;
}
};
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<Run>[] = [
{
accessorKey: 'runNumber',
header: 'Run #',
size: 80,
cell: ({ getValue, row }) => (
<span className={`text-sm font-medium ${row.original.id === currentRunId ? 'text-primary-500' : 'text-text-primary'}`}>
#{getValue() as number}
</span>
),
},
{
accessorKey: 'status',
header: 'Status',
size: 120,
cell: ({ getValue }) => {
const status = getValue() as Run['status'];
return (
<div className="flex items-center space-x-2">
{getStatusIcon(status)}
<span className="text-sm text-text-primary">{getStatusLabel(status)}</span>
</div>
);
},
},
{
accessorKey: 'progress',
header: 'Progress',
size: 150,
cell: ({ row }) => {
const progress = row.original.progress;
const status = row.original.status;
if (status === 'pending') return <span className="text-sm text-text-secondary">-</span>;
return (
<div className="flex items-center space-x-2">
<div className="flex-1 bg-surface-tertiary rounded-full h-2 overflow-hidden">
<div
className="bg-primary-500 h-2 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-text-secondary">{progress.toFixed(0)}%</span>
</div>
);
},
},
{
accessorKey: 'speedMultiplier',
header: 'Speed',
size: 80,
cell: ({ getValue }) => (
<span className="text-sm text-text-primary">{getValue() as number}x</span>
),
},
{
accessorKey: 'startedAt',
header: 'Started',
size: 180,
cell: ({ getValue }) => {
const date = getValue() as string | undefined;
return (
<span className="text-sm text-text-secondary">
{date ? new Date(date).toLocaleString() : '-'}
</span>
);
},
},
{
id: 'duration',
header: 'Duration',
size: 100,
cell: ({ row }) => (
<span className="text-sm text-text-secondary">
{formatDuration(row.original.startedAt, row.original.completedAt)}
</span>
),
},
{
accessorKey: 'error',
header: 'Error',
size: 200,
cell: ({ getValue }) => {
const error = getValue() as string | undefined;
return error ? (
<span className="text-sm text-error truncate" title={error}>{error}</span>
) : (
<span className="text-sm text-text-secondary">-</span>
);
},
},
];
if (runs.length === 0) {
return (
<div className="bg-surface-secondary rounded-lg border border-border p-8 text-center">
<p className="text-text-secondary">No runs yet. Create a new run to get started.</p>
</div>
);
}
return (
<DataTable
data={runs}
columns={columns}
onRowClick={(run) => {
navigate(`/backtests/${backtestId}/run/${run.id}`);
onSelectRun(run.id);
}}
className="bg-surface-secondary rounded-lg border border-border"
height={400}
/>
);
}

View file

@ -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<void>;
createBacktest: (request: CreateBacktestRequest) => Promise<Backtest>;
updateBacktest: (id: string, updates: Partial<CreateBacktestRequest>) => Promise<void>;
deleteBacktest: (id: string) => Promise<void>;
createRun: (speedMultiplier?: number | null) => Promise<Run | undefined>;
pauseRun: () => Promise<void>;
resumeRun: () => Promise<void>;
cancelRun: () => Promise<void>;
updateRunSpeed: (speedMultiplier: number | null) => Promise<void>;
selectRun: (runId: string | undefined) => Promise<void>;
}
export function useBacktestV2(): UseBacktestV2Return {
const [backtest, setBacktest] = useState<Backtest | null>(null);
const [runs, setRuns] = useState<Run[]>([]);
const [currentRun, setCurrentRun] = useState<Run | null>(null);
const [runResults, setRunResults] = useState<RunResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(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<Backtest> => {
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<CreateBacktestRequest>) => {
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,
};
}

View file

@ -1,4 +1,6 @@
export { BacktestPage } from './BacktestPage'; export { BacktestPage } from './BacktestPage';
export { BacktestListPage } from './BacktestListPage'; export { BacktestListPage } from './BacktestListPage';
export { BacktestDetailPage } from './BacktestDetailPage'; export { BacktestDetailPage } from './BacktestDetailPage';
export { BacktestListPageV2 } from './BacktestListPageV2';
export { BacktestDetailPageV2 } from './BacktestDetailPageV2';
export * from './types'; export * from './types';

View file

@ -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<string, any>;
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<string, any>;
}
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<string, any[]>;
trades: any[];
positions: any;
analytics: any;
executionTime: number;
}
export const backtestApiV2 = {
// Backtest operations
async createBacktest(request: CreateBacktestRequest): Promise<Backtest> {
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<Backtest> {
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<Backtest[]> {
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<CreateBacktestRequest>): Promise<Backtest> {
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<void> {
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<Run> {
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<Run[]> {
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<Run> {
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<RunResult> {
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<void> {
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<void> {
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<void> {
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<void> {
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}
};

View file

@ -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<WebSocketMessage | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(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
};
}

View file

@ -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;

View file

@ -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 type { OptionalUnlessRequiredId } from 'mongodb';
import { Collection, Db, MongoClient } from 'mongodb'; import { Collection, Db, MongoClient } from 'mongodb';
import type { import type {