socket reruns
This commit is contained in:
parent
a876f3c35b
commit
11c6c19628
29 changed files with 3921 additions and 233 deletions
|
|
@ -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
|
||||
app.get('/progress', async (c) => {
|
||||
try {
|
||||
// In real implementation, would track progress
|
||||
const status = backtestEngine.getStatus();
|
||||
|
||||
return c.json({
|
||||
status: 'running',
|
||||
progress: 0.5,
|
||||
processed: 10000,
|
||||
total: 20000,
|
||||
currentTime: new Date().toISOString()
|
||||
status: status.isPaused ? 'paused' : (status.isRunning ? 'running' : 'idle'),
|
||||
progress: status.progress,
|
||||
currentTime: new Date(status.currentTime).toISOString(),
|
||||
isRunning: status.isRunning,
|
||||
isPaused: status.isPaused
|
||||
});
|
||||
} catch (error) {
|
||||
container.logger.error('Error getting backtest progress:', error);
|
||||
|
|
|
|||
|
|
@ -93,11 +93,17 @@ export class BacktestEngine extends EventEmitter {
|
|||
private currentTime: number = 0;
|
||||
private equityCurve: { timestamp: number; value: number }[] = [];
|
||||
private isRunning = false;
|
||||
private isPaused = false;
|
||||
private speedMultiplier: number | null = 1; // null means unlimited speed
|
||||
private lastProcessTime = 0;
|
||||
private dataManager: DataManager;
|
||||
private marketSimulator: MarketSimulator;
|
||||
private performanceAnalyzer: PerformanceAnalyzer;
|
||||
private microstructures: Map<string, MarketMicrostructure> = new Map();
|
||||
private container: IServiceContainer;
|
||||
private runId: string | null = null;
|
||||
private progressUpdateInterval = 100; // Update progress every 100 events
|
||||
private totalEvents = 0;
|
||||
private initialCapital: number = 100000;
|
||||
private commission: number = 0.001; // Default 0.1%
|
||||
private slippage: number = 0.0001; // Default 0.01%
|
||||
|
|
@ -155,8 +161,33 @@ export class BacktestEngine extends EventEmitter {
|
|||
|
||||
this.container.logger.info(`[BacktestEngine] Initial capital set to ${this.initialCapital}, equity curve initialized with ${this.equityCurve.length} points`);
|
||||
|
||||
// Generate backtest ID
|
||||
// Generate backtest ID and store runId if provided
|
||||
const backtestId = `backtest_${Date.now()}`;
|
||||
this.runId = config.runId || null;
|
||||
|
||||
// Set speed multiplier from config
|
||||
if (validatedConfig.speed) {
|
||||
switch (validatedConfig.speed) {
|
||||
case 'max':
|
||||
this.speedMultiplier = null; // Unlimited speed
|
||||
break;
|
||||
case 'realtime':
|
||||
this.speedMultiplier = 1;
|
||||
break;
|
||||
case '2x':
|
||||
this.speedMultiplier = 2;
|
||||
break;
|
||||
case '5x':
|
||||
this.speedMultiplier = 5;
|
||||
break;
|
||||
case '10x':
|
||||
this.speedMultiplier = 10;
|
||||
break;
|
||||
default:
|
||||
this.speedMultiplier = null; // Default to max speed
|
||||
}
|
||||
this.container.logger.info(`[BacktestEngine] Speed set to: ${validatedConfig.speed} (multiplier: ${this.speedMultiplier})`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Load historical data with multi-resolution support
|
||||
|
|
@ -486,10 +517,46 @@ export class BacktestEngine extends EventEmitter {
|
|||
let lastEquityUpdate = 0;
|
||||
const equityUpdateInterval = 60000; // Update equity every minute
|
||||
|
||||
this.container.logger.info(`[BacktestEngine] Processing ${this.eventQueue.length} events`);
|
||||
this.totalEvents = this.eventQueue.length;
|
||||
this.container.logger.info(`[BacktestEngine] Processing ${this.totalEvents} events with speed multiplier: ${this.speedMultiplier}`);
|
||||
|
||||
let processedCount = 0;
|
||||
|
||||
while (this.eventQueue.length > 0 && this.isRunning) {
|
||||
// Check if paused
|
||||
if (this.isPaused) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = this.eventQueue.shift()!;
|
||||
processedCount++;
|
||||
|
||||
// Apply speed control if not unlimited
|
||||
if (this.speedMultiplier !== null && this.speedMultiplier > 0) {
|
||||
// Base delay in milliseconds for processing each event
|
||||
// For 1x speed: 1000ms per event (1 event per second)
|
||||
// For 2x speed: 500ms per event (2 events per second)
|
||||
// For 10x speed: 100ms per event (10 events per second)
|
||||
const baseDelay = 1000; // milliseconds
|
||||
const actualDelay = baseDelay / this.speedMultiplier;
|
||||
|
||||
// Add the delay
|
||||
if (actualDelay > 1) {
|
||||
if (processedCount % 50 === 0) { // Log every 50 events to avoid spam
|
||||
this.container.logger.debug(`[BacktestEngine] Applying delay: ${actualDelay}ms (speed: ${this.speedMultiplier}x)`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, actualDelay));
|
||||
}
|
||||
|
||||
// Also send progress updates more frequently at slower speeds
|
||||
if (processedCount % Math.max(1, Math.floor(10 / this.speedMultiplier)) === 0) {
|
||||
const progress = Math.round((processedCount / this.totalEvents) * 100);
|
||||
this.sendProgressUpdate(progress, new Date(event.timestamp).toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
this.lastProcessTime = Date.now();
|
||||
|
||||
// Advance time
|
||||
this.currentTime = event.timestamp;
|
||||
|
|
@ -518,18 +585,27 @@ export class BacktestEngine extends EventEmitter {
|
|||
lastEquityUpdate = this.currentTime;
|
||||
}
|
||||
|
||||
// Emit progress
|
||||
if (this.eventQueue.length % 1000 === 0) {
|
||||
// Send progress update via WebSocket (unless already sent in speed control)
|
||||
if (this.speedMultiplier === null && processedCount % this.progressUpdateInterval === 0) {
|
||||
const progress = Math.round((processedCount / this.totalEvents) * 100);
|
||||
this.sendProgressUpdate(progress, new Date(this.currentTime).toISOString());
|
||||
|
||||
// Also emit local progress event
|
||||
this.emit('progress', {
|
||||
processed: this.eventQueue.length,
|
||||
processed: processedCount,
|
||||
total: this.totalEvents,
|
||||
remaining: this.eventQueue.length,
|
||||
currentTime: new Date(this.currentTime)
|
||||
currentTime: new Date(this.currentTime),
|
||||
progress
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Final equity update
|
||||
await this.updateEquityCurve();
|
||||
|
||||
// Send 100% progress
|
||||
this.sendProgressUpdate(100, new Date(this.currentTime).toISOString());
|
||||
}
|
||||
|
||||
private async processMarketData(data: MarketData): Promise<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 }[] {
|
||||
const drawdowns: { timestamp: number; value: number }[] = [];
|
||||
let peak = this.equityCurve[0]?.value || 0;
|
||||
|
|
@ -913,6 +1008,9 @@ export class BacktestEngine extends EventEmitter {
|
|||
this.equityCurve = [];
|
||||
this.pendingOrders.clear();
|
||||
this.ordersListenerSetup = false;
|
||||
this.isPaused = false;
|
||||
this.speedMultiplier = 1;
|
||||
this.lastProcessTime = 0;
|
||||
this.marketSimulator.reset();
|
||||
}
|
||||
|
||||
|
|
@ -1195,4 +1293,37 @@ export class BacktestEngine extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
// Playback control methods
|
||||
async pauseBacktest(): Promise<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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -298,25 +298,41 @@ export class StorageService {
|
|||
const msPerInterval = this.getIntervalMilliseconds(interval);
|
||||
let currentTime = new Date(startTime);
|
||||
|
||||
// Starting price based on symbol
|
||||
let basePrice = symbol === 'AAPL' ? 150 :
|
||||
symbol === 'MSFT' ? 300 :
|
||||
symbol === 'GOOGL' ? 120 : 100;
|
||||
// Use current timestamp to seed randomness for different data each run
|
||||
const seed = Date.now();
|
||||
const seedMultiplier = (seed % 1000) / 1000; // 0-1 based on milliseconds
|
||||
|
||||
// Starting price based on symbol with some randomness
|
||||
let basePrice = symbol === 'AAPL' ? 150 + (seedMultiplier * 20 - 10) :
|
||||
symbol === 'MSFT' ? 300 + (seedMultiplier * 30 - 15) :
|
||||
symbol === 'GOOGL' ? 120 + (seedMultiplier * 15 - 7.5) :
|
||||
100 + (seedMultiplier * 10 - 5);
|
||||
|
||||
// Random walk seed to make each run different
|
||||
let walkSeed = seedMultiplier;
|
||||
|
||||
while (currentTime <= endTime) {
|
||||
// Use a pseudo-random based on walkSeed for deterministic but different results
|
||||
walkSeed = (walkSeed * 9.73 + 0.27) % 1;
|
||||
|
||||
// Random walk with trend - increased volatility for testing
|
||||
const trend = 0.0002; // Slight upward trend
|
||||
const volatility = 0.01; // 1% volatility (increased from 0.2%)
|
||||
const change = (Math.random() - 0.5 + trend) * volatility;
|
||||
const volatility = 0.01; // 1% volatility
|
||||
const change = (walkSeed - 0.5 + trend) * volatility;
|
||||
|
||||
basePrice *= (1 + change);
|
||||
|
||||
// Generate OHLC data with more realistic volatility
|
||||
const open = basePrice * (1 + (Math.random() - 0.5) * 0.005);
|
||||
const openSeed = (walkSeed * 7.13 + 0.31) % 1;
|
||||
const highSeed = (walkSeed * 5.17 + 0.41) % 1;
|
||||
const lowSeed = (walkSeed * 3.19 + 0.59) % 1;
|
||||
const volumeSeed = (walkSeed * 11.23 + 0.67) % 1;
|
||||
|
||||
const open = basePrice * (1 + (openSeed - 0.5) * 0.005);
|
||||
const close = basePrice;
|
||||
const high = Math.max(open, close) * (1 + Math.random() * 0.008);
|
||||
const low = Math.min(open, close) * (1 - Math.random() * 0.008);
|
||||
const volume = 1000000 + Math.random() * 500000;
|
||||
const high = Math.max(open, close) * (1 + highSeed * 0.008);
|
||||
const low = Math.min(open, close) * (1 - lowSeed * 0.008);
|
||||
const volume = 1000000 + volumeSeed * 500000;
|
||||
|
||||
bars.push({
|
||||
symbol,
|
||||
|
|
@ -332,6 +348,8 @@ export class StorageService {
|
|||
currentTime = new Date(currentTime.getTime() + msPerInterval);
|
||||
}
|
||||
|
||||
this.container.logger.info(`Generated ${bars.length} mock bars for ${symbol} with seed ${seed}`);
|
||||
|
||||
return bars;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
/**
|
||||
* Stock Bot Web API
|
||||
* Simplified entry point using ServiceApplication framework
|
||||
* Entry point with WebSocket support
|
||||
*/
|
||||
|
||||
import { ServiceApplication } from '@stock-bot/di';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { initializeStockConfig } from '@stock-bot/stock-config';
|
||||
// Local imports
|
||||
import { createRoutes } from './routes/create-routes';
|
||||
|
||||
// Initialize configuration with service-specific overrides
|
||||
const config = initializeStockConfig('webApi');
|
||||
|
|
@ -23,66 +20,10 @@ if (config.queue) {
|
|||
const logger = getLogger('web-api');
|
||||
logger.info('Service configuration:', config);
|
||||
|
||||
// Create service application
|
||||
const app = new ServiceApplication(
|
||||
config,
|
||||
{
|
||||
serviceName: 'web-api',
|
||||
enableHandlers: false, // Web API doesn't use handlers
|
||||
enableScheduledJobs: false, // Web API doesn't use scheduled jobs
|
||||
corsConfig: {
|
||||
origin: ['http://localhost:4200', 'http://localhost:3000', 'http://localhost:3002', 'http://localhost:5173', 'http://localhost:5174'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true,
|
||||
},
|
||||
serviceMetadata: {
|
||||
version: '1.0.0',
|
||||
description: 'Stock Bot REST API',
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
exchanges: '/api/exchanges',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Custom lifecycle hooks
|
||||
onStarted: _port => {
|
||||
const logger = getLogger('web-api');
|
||||
logger.info('Web API service startup initiated with ServiceApplication framework');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Container factory function
|
||||
async function createContainer(config: any) {
|
||||
const { ServiceContainerBuilder } = await import('@stock-bot/di');
|
||||
|
||||
const container = await new ServiceContainerBuilder()
|
||||
.withConfig(config)
|
||||
.withOptions({
|
||||
enableQuestDB: false, // Disable QuestDB for now
|
||||
enableMongoDB: true,
|
||||
enablePostgres: true,
|
||||
enableCache: true,
|
||||
enableQueue: true, // Enable for pipeline operations
|
||||
enableBrowser: false, // Web API doesn't need browser
|
||||
enableProxy: false, // Web API doesn't need proxy
|
||||
})
|
||||
.build(); // This automatically initializes services
|
||||
|
||||
// Run database migrations
|
||||
if (container.postgres) {
|
||||
const { runMigrations } = await import('./migrations/migration-runner');
|
||||
await runMigrations(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// Start the service
|
||||
app.start(createContainer, createRoutes).catch(error => {
|
||||
const logger = getLogger('web-api');
|
||||
logger.fatal('Failed to start web API service', { error });
|
||||
process.exit(1);
|
||||
// Import and start WebSocket-enabled server
|
||||
import('./server-with-websocket').then(({ startServerWithWebSocket }) => {
|
||||
startServerWithWebSocket().catch(error => {
|
||||
logger.fatal('Failed to start web API service with WebSocket', { error });
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
178
apps/stock/web-api/src/routes/backtests-v2.router.ts
Normal file
178
apps/stock/web-api/src/routes/backtests-v2.router.ts
Normal 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;
|
||||
}
|
||||
196
apps/stock/web-api/src/routes/backtests-v2.routes.ts
Normal file
196
apps/stock/web-api/src/routes/backtests-v2.routes.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -4,22 +4,33 @@
|
|||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { IServiceContainer } from '@stock-bot/handlers';
|
||||
import { createExchangeRoutes } from './exchange.routes';
|
||||
import { createHealthRoutes } from './health.routes';
|
||||
import { createMonitoringRoutes } from './monitoring.routes';
|
||||
import { createPipelineRoutes } from './pipeline.routes';
|
||||
import { createBacktestRoutes } from './backtest.routes';
|
||||
import { createBacktestV2Routes } from './backtests-v2.routes';
|
||||
|
||||
export function createRoutes(container: IServiceContainer): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
// Add CORS middleware
|
||||
app.use('*', cors({
|
||||
origin: ['http://localhost:4200', 'http://localhost:3000', 'http://localhost:3002', 'http://localhost:5173', 'http://localhost:5174'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Create routes with container
|
||||
const healthRoutes = createHealthRoutes(container);
|
||||
const exchangeRoutes = createExchangeRoutes(container);
|
||||
const monitoringRoutes = createMonitoringRoutes(container);
|
||||
const pipelineRoutes = createPipelineRoutes(container);
|
||||
const backtestRoutes = createBacktestRoutes(container);
|
||||
const backtestV2Routes = createBacktestV2Routes(container);
|
||||
|
||||
// Mount routes
|
||||
app.route('/health', healthRoutes);
|
||||
|
|
@ -27,6 +38,7 @@ export function createRoutes(container: IServiceContainer): Hono {
|
|||
app.route('/api/system/monitoring', monitoringRoutes);
|
||||
app.route('/api/pipeline', pipelineRoutes);
|
||||
app.route('/', backtestRoutes); // Mounted at root since routes already have /api prefix
|
||||
app.route('/', backtestV2Routes); // V2 routes also mounted at root
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
47
apps/stock/web-api/src/routes/websocket.routes.ts
Normal file
47
apps/stock/web-api/src/routes/websocket.routes.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
131
apps/stock/web-api/src/server-with-websocket.ts
Normal file
131
apps/stock/web-api/src/server-with-websocket.ts
Normal 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
|
||||
569
apps/stock/web-api/src/services/backtest-v2.service.ts
Normal file
569
apps/stock/web-api/src/services/backtest-v2.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
160
apps/stock/web-api/src/websocket/run-updates.ts
Normal file
160
apps/stock/web-api/src/websocket/run-updates.ts
Normal 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>;
|
||||
|
|
@ -3,7 +3,7 @@ import { DashboardPage } from '@/features/dashboard';
|
|||
import { ExchangesPage } from '@/features/exchanges';
|
||||
import { MonitoringPage } from '@/features/monitoring';
|
||||
import { PipelinePage } from '@/features/pipeline';
|
||||
import { BacktestListPage, BacktestDetailPage } from '@/features/backtest';
|
||||
import { BacktestListPageV2, BacktestDetailPageV2 } from '@/features/backtest';
|
||||
import { SymbolsPage, SymbolDetailPage } from '@/features/symbols';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
|
|
@ -32,8 +32,11 @@ export function App() {
|
|||
element={<div className="p-4">Analytics Page - Coming Soon</div>}
|
||||
/>
|
||||
<Route path="backtests">
|
||||
<Route index element={<BacktestListPage />} />
|
||||
<Route path=":id" element={<BacktestDetailPage />} />
|
||||
<Route index element={<BacktestListPageV2 />} />
|
||||
<Route path=":id">
|
||||
<Route index element={<BacktestDetailPageV2 />} />
|
||||
<Route path="run/:runId" element={<BacktestDetailPageV2 />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="settings" element={<div className="p-4">Settings Page - Coming Soon</div>} />
|
||||
<Route path="system/monitoring" element={<MonitoringPage />} />
|
||||
|
|
|
|||
|
|
@ -335,8 +335,13 @@ export function Chart({
|
|||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (chart) {
|
||||
chart.remove();
|
||||
// Clear all refs before removing the chart
|
||||
mainSeriesRef.current = null;
|
||||
volumeSeriesRef.current = null;
|
||||
overlaySeriesRef.current.clear();
|
||||
if (chartRef.current) {
|
||||
chartRef.current.remove();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [data, height, type, showVolume, theme, overlayData, tradeMarkers]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
151
apps/stock/web-app/src/features/backtest/BacktestListPageV2.tsx
Normal file
151
apps/stock/web-app/src/features/backtest/BacktestListPageV2.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { BacktestResult } from '../types/backtest.types';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, memo } from 'react';
|
||||
import { Chart } from '../../../components/charts';
|
||||
|
||||
interface BacktestChartProps {
|
||||
|
|
@ -7,7 +7,8 @@ interface BacktestChartProps {
|
|||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function BacktestChart({ result, isLoading }: BacktestChartProps) {
|
||||
// Memoize the component to prevent unnecessary re-renders
|
||||
export const BacktestChart = memo(function BacktestChart({ result, isLoading }: BacktestChartProps) {
|
||||
const [selectedSymbol, setSelectedSymbol] = useState<string>('');
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
|
|
@ -15,32 +16,58 @@ export function BacktestChart({ result, isLoading }: BacktestChartProps) {
|
|||
|
||||
const symbols = Object.keys(result.ohlcData);
|
||||
const symbol = selectedSymbol || symbols[0] || '';
|
||||
|
||||
const ohlcData = result.ohlcData[symbol] || [];
|
||||
const equityData = result.equity.map(e => ({
|
||||
time: new Date(e.date).getTime() / 1000,
|
||||
value: e.value
|
||||
}));
|
||||
|
||||
// Remove excessive logging in production
|
||||
// Log only on significant changes
|
||||
if (process.env.NODE_ENV === 'development' && ohlcData.length > 0) {
|
||||
// Use a simple hash to detect actual data changes
|
||||
const dataHash = `${symbols.length}-${result.equity?.length}-${ohlcData.length}`;
|
||||
if ((window as any).__lastDataHash !== dataHash) {
|
||||
(window as any).__lastDataHash = dataHash;
|
||||
console.log('BacktestChart data updated:', {
|
||||
symbols,
|
||||
selectedSymbol,
|
||||
symbol,
|
||||
ohlcDataKeys: Object.keys(result.ohlcData),
|
||||
equityLength: result.equity?.length,
|
||||
tradesLength: result.trades?.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const equityData = (result.equity || [])
|
||||
.filter(e => e && e.date && e.value != null)
|
||||
.map(e => ({
|
||||
time: new Date(e.date).getTime() / 1000,
|
||||
value: e.value
|
||||
}));
|
||||
|
||||
// Find trades for this symbol
|
||||
const symbolTrades = result.trades?.filter(t => t.symbol === symbol) || [];
|
||||
const tradeMarkers = symbolTrades.map(trade => ({
|
||||
time: new Date(trade.entryDate).getTime() / 1000,
|
||||
position: 'belowBar' as const,
|
||||
color: trade.side === 'buy' ? '#10b981' : '#ef4444',
|
||||
shape: trade.side === 'buy' ? 'arrowUp' : 'arrowDown' as const,
|
||||
text: `${trade.side === 'buy' ? 'Buy' : 'Sell'} @ $${trade.entryPrice.toFixed(2)}`
|
||||
}));
|
||||
const tradeMarkers = symbolTrades
|
||||
.filter(trade => trade.entryPrice != null && trade.entryDate != null)
|
||||
.map(trade => ({
|
||||
time: new Date(trade.entryDate).getTime() / 1000,
|
||||
position: 'belowBar' as const,
|
||||
color: trade.side === 'buy' ? '#10b981' : '#ef4444',
|
||||
shape: trade.side === 'buy' ? 'arrowUp' : 'arrowDown' as const,
|
||||
text: `${trade.side === 'buy' ? 'Buy' : 'Sell'} @ $${trade.entryPrice.toFixed(2)}`
|
||||
}));
|
||||
|
||||
return {
|
||||
ohlcData: ohlcData.map(d => ({
|
||||
time: d.time / 1000,
|
||||
const processedOhlcData = ohlcData
|
||||
.filter(d => d && d.timestamp != null && d.open != null && d.high != null && d.low != null && d.close != null)
|
||||
.map(d => ({
|
||||
time: d.timestamp / 1000, // timestamp is already in milliseconds
|
||||
open: d.open,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
close: d.close,
|
||||
volume: d.volume
|
||||
})),
|
||||
volume: d.volume || 0
|
||||
}));
|
||||
|
||||
return {
|
||||
ohlcData: processedOhlcData,
|
||||
equityData,
|
||||
tradeMarkers,
|
||||
symbols
|
||||
|
|
@ -90,4 +117,4 @@ export function BacktestChart({ result, isLoading }: BacktestChartProps) {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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">
|
||||
<MetricsCard
|
||||
title="Total Return"
|
||||
value={`${(metrics.totalReturn * 100).toFixed(2)}%`}
|
||||
color={metrics.totalReturn >= 0 ? 'success' : 'error'}
|
||||
value={`${((metrics.totalReturn ?? 0) * 100).toFixed(2)}%`}
|
||||
color={(metrics.totalReturn ?? 0) >= 0 ? 'success' : 'error'}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Sharpe Ratio"
|
||||
value={metrics.sharpeRatio.toFixed(2)}
|
||||
color={metrics.sharpeRatio > 1 ? 'success' : metrics.sharpeRatio > 0 ? 'warning' : 'error'}
|
||||
value={(metrics.sharpeRatio ?? 0).toFixed(2)}
|
||||
color={(metrics.sharpeRatio ?? 0) > 1 ? 'success' : (metrics.sharpeRatio ?? 0) > 0 ? 'warning' : 'error'}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Max Drawdown"
|
||||
value={`${(metrics.maxDrawdown * 100).toFixed(2)}%`}
|
||||
color={metrics.maxDrawdown > -0.2 ? 'warning' : 'error'}
|
||||
value={`${((metrics.maxDrawdown ?? 0) * 100).toFixed(2)}%`}
|
||||
color={(metrics.maxDrawdown ?? 0) > -0.2 ? 'warning' : 'error'}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Win Rate"
|
||||
value={`${(metrics.winRate * 100).toFixed(1)}%`}
|
||||
color={metrics.winRate > 0.5 ? 'success' : 'warning'}
|
||||
value={`${((metrics.winRate ?? 0) * 100).toFixed(1)}%`}
|
||||
color={(metrics.winRate ?? 0) > 0.5 ? 'success' : 'warning'}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Total Trades"
|
||||
value={metrics.totalTrades.toString()}
|
||||
value={(metrics.totalTrades ?? 0).toString()}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Profit Factor"
|
||||
value={metrics.profitFactor.toFixed(2)}
|
||||
color={metrics.profitFactor > 1.5 ? 'success' : metrics.profitFactor > 1 ? 'warning' : 'error'}
|
||||
value={(metrics.profitFactor ?? 0).toFixed(2)}
|
||||
color={(metrics.profitFactor ?? 0) > 1.5 ? 'success' : (metrics.profitFactor ?? 0) > 1 ? 'warning' : 'error'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -70,20 +70,20 @@ export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
|
|||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<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 className="flex justify-between">
|
||||
<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 className="flex justify-between">
|
||||
<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 className="flex justify-between">
|
||||
<span className="text-text-secondary text-sm">Expectancy</span>
|
||||
<span className={`text-sm font-medium ${metrics.expectancy >= 0 ? 'text-success' : 'text-error'}`}>
|
||||
${metrics.expectancy.toFixed(2)}
|
||||
<span className={`text-sm font-medium ${(metrics.expectancy ?? 0) >= 0 ? 'text-success' : 'text-error'}`}>
|
||||
${(metrics.expectancy ?? 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -94,11 +94,11 @@ export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
|
|||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<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 className="flex justify-between">
|
||||
<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 className="flex justify-between">
|
||||
<span className="text-text-secondary text-sm">Exposure Time</span>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { BacktestResult } from '../types/backtest.types';
|
||||
import { TradeLog } from './TradeLog';
|
||||
import { DataTable } from '@/components/ui/DataTable';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
interface BacktestTradesProps {
|
||||
result: BacktestResult | null;
|
||||
|
|
@ -27,6 +28,124 @@ export function BacktestTrades({ result, isLoading }: BacktestTradesProps) {
|
|||
);
|
||||
}
|
||||
|
||||
const columns: ColumnDef<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 (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
|
|
@ -36,108 +155,78 @@ export function BacktestTrades({ result, isLoading }: BacktestTradesProps) {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto bg-surface-secondary rounded-lg border border-border">
|
||||
<table className="w-full">
|
||||
<thead className="bg-background border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
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>
|
||||
<DataTable
|
||||
data={result.trades}
|
||||
columns={columns}
|
||||
className="bg-surface-secondary rounded-lg border border-border"
|
||||
height={400}
|
||||
/>
|
||||
|
||||
{result.positions && Object.keys(result.positions).length > 0 && (
|
||||
<div className="mt-4 bg-surface-secondary rounded-lg border border-border p-4">
|
||||
<h4 className="text-sm font-medium text-text-secondary mb-3">Open Positions</h4>
|
||||
<div className="space-y-2">
|
||||
{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>
|
||||
<div className="flex space-x-4 text-sm">
|
||||
<span className="text-text-secondary">
|
||||
Qty: {position.quantity}
|
||||
</span>
|
||||
<span className="text-text-secondary">
|
||||
Avg: ${position.averagePrice.toFixed(2)}
|
||||
</span>
|
||||
<span className={position.unrealizedPnl >= 0 ? 'text-success' : 'text-error'}>
|
||||
P&L: ${position.unrealizedPnl.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{result.positions && Object.keys(result.positions).length > 0 && (() => {
|
||||
const positionsArray = Object.entries(result.positions).map(([symbol, position]) => ({
|
||||
symbol,
|
||||
...position
|
||||
}));
|
||||
|
||||
const positionColumns: ColumnDef<typeof positionsArray[0]>[] = [
|
||||
{
|
||||
accessorKey: 'symbol',
|
||||
header: 'Symbol',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-sm font-medium text-text-primary">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'quantity',
|
||||
header: 'Quantity',
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
177
apps/stock/web-app/src/features/backtest/components/RunsList.tsx
Normal file
177
apps/stock/web-app/src/features/backtest/components/RunsList.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
304
apps/stock/web-app/src/features/backtest/hooks/useBacktestV2.ts
Normal file
304
apps/stock/web-app/src/features/backtest/hooks/useBacktestV2.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export { BacktestPage } from './BacktestPage';
|
||||
export { BacktestListPage } from './BacktestListPage';
|
||||
export { BacktestDetailPage } from './BacktestDetailPage';
|
||||
export { BacktestListPageV2 } from './BacktestListPageV2';
|
||||
export { BacktestDetailPageV2 } from './BacktestDetailPageV2';
|
||||
export * from './types';
|
||||
|
|
@ -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}
|
||||
};
|
||||
195
apps/stock/web-app/src/hooks/useWebSocket.ts
Normal file
195
apps/stock/web-app/src/hooks/useWebSocket.ts
Normal 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
|
||||
};
|
||||
}
|
||||
58
database/postgres/init/08-create-runs-table.sql
Normal file
58
database/postgres/init/08-create-runs-table.sql
Normal 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;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Logger } from '@stock-bot/engine/logger';
|
||||
import type { Logger } from '@stock-bot/logger';
|
||||
import type { OptionalUnlessRequiredId } from 'mongodb';
|
||||
import { Collection, Db, MongoClient } from 'mongodb';
|
||||
import type {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue