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