diff --git a/apps/stock/engine/index.node b/apps/stock/engine/index.node index 55c1023..976e7bc 100755 Binary files a/apps/stock/engine/index.node and b/apps/stock/engine/index.node differ diff --git a/apps/stock/web-api/src/index.ts b/apps/stock/web-api/src/index.ts index 4e2461a..baa3029 100644 --- a/apps/stock/web-api/src/index.ts +++ b/apps/stock/web-api/src/index.ts @@ -71,6 +71,12 @@ async function createContainer(config: any) { }) .build(); // This automatically initializes services + // Run database migrations + if (container.postgres) { + const { runMigrations } = await import('./migrations/migration-runner'); + await runMigrations(container); + } + return container; } diff --git a/apps/stock/web-api/src/migrations/migration-runner.ts b/apps/stock/web-api/src/migrations/migration-runner.ts new file mode 100644 index 0000000..3271dde --- /dev/null +++ b/apps/stock/web-api/src/migrations/migration-runner.ts @@ -0,0 +1,70 @@ +import { readFileSync, readdirSync } from 'fs'; +import { join } from 'path'; +import type { IServiceContainer } from '@stock-bot/handlers'; +import { getLogger } from '@stock-bot/logger'; + +const logger = getLogger('migration-runner'); + +export async function runMigrations(container: IServiceContainer): Promise { + logger.info('Migration runner called'); + logger.info('Container type:', typeof container); + logger.info('Container postgres available:', !!container.postgres); + + if (!container.postgres) { + logger.warn('PostgreSQL not available, skipping migrations'); + logger.info('Container keys:', Object.keys(container)); + return; + } + + try { + // Create migrations table if it doesn't exist + await container.postgres.query(` + CREATE TABLE IF NOT EXISTS migrations ( + id SERIAL PRIMARY KEY, + filename VARCHAR(255) NOT NULL UNIQUE, + executed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ) + `); + + // Get list of migration files from database/postgres/init + const migrationsDir = join(process.cwd(), 'database', 'postgres', 'init'); + logger.info('Looking for migrations in:', migrationsDir); + + const files = readdirSync(migrationsDir) + .filter(f => f.endsWith('.sql') && f.startsWith('001_')) // Only run our backtest migration for now + .sort(); + + logger.info('Found migration files:', files); + + for (const file of files) { + // Check if migration has already been run + const result = await container.postgres.query( + 'SELECT 1 FROM migrations WHERE filename = $1', + [file] + ); + + if (result.rows.length === 0) { + logger.info(`Running migration: ${file}`); + + // Read and execute migration + const sql = readFileSync(join(migrationsDir, file), 'utf8'); + await container.postgres.query(sql); + + // Record migration as executed + await container.postgres.query( + 'INSERT INTO migrations (filename) VALUES ($1)', + [file] + ); + + logger.info(`Migration completed: ${file}`); + } else { + logger.debug(`Migration already executed: ${file}`); + } + } + + logger.info('All migrations completed successfully'); + } catch (error) { + logger.error('Migration failed', { error }); + throw error; + } +} \ No newline at end of file diff --git a/apps/stock/web-api/src/routes/backtest.routes.ts b/apps/stock/web-api/src/routes/backtest.routes.ts index 16b077e..3bd35aa 100644 --- a/apps/stock/web-api/src/routes/backtest.routes.ts +++ b/apps/stock/web-api/src/routes/backtest.routes.ts @@ -7,7 +7,7 @@ const logger = getLogger('backtest-routes'); export function createBacktestRoutes(container: IServiceContainer) { const backtestRoutes = new Hono(); - const backtestService = new BacktestService(); + const backtestService = new BacktestService(container); // Create a new backtest backtestRoutes.post('/api/backtests', async (c) => { diff --git a/apps/stock/web-api/src/services/backtest.service.ts b/apps/stock/web-api/src/services/backtest.service.ts index e95fddd..3f90b88 100644 --- a/apps/stock/web-api/src/services/backtest.service.ts +++ b/apps/stock/web-api/src/services/backtest.service.ts @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; import { getLogger } from '@stock-bot/logger'; +import type { IServiceContainer } from '@stock-bot/handlers'; const logger = getLogger('backtest-service'); @@ -29,29 +30,49 @@ export interface BacktestJob { error?: string; } -// In-memory storage for demo (replace with database) -const backtestStore = new Map(); -const backtestResults = new Map(); - export class BacktestService { + private container: IServiceContainer; + + constructor(container: IServiceContainer) { + this.container = container; + logger.info('BacktestService initialized', { + hasPostgres: !!container.postgres, + containerKeys: Object.keys(container) + }); + } async createBacktest(request: BacktestRequest): Promise { const backtestId = uuidv4(); - // Store in memory (replace with database) - const backtest: BacktestJob = { - id: backtestId, - status: 'pending', - strategy: request.strategy, - symbols: request.symbols, - startDate: new Date(request.startDate), - endDate: new Date(request.endDate), - initialCapital: request.initialCapital, - config: request.config || {}, - createdAt: new Date(), - updatedAt: new Date(), - }; + // Insert into PostgreSQL + const result = await this.container.postgres.query( + `INSERT INTO backtests + (id, name, strategy, symbols, start_date, end_date, initial_capital, config, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending') + RETURNING *`, + [ + backtestId, + request.config?.name || `Backtest ${new Date().toISOString()}`, + request.strategy, + JSON.stringify(request.symbols), + request.startDate, + request.endDate, + request.initialCapital, + JSON.stringify(request.config || {}) + ] + ); - backtestStore.set(backtestId, backtest); + const backtest: BacktestJob = { + id: result.rows[0].id, + status: result.rows[0].status, + strategy: result.rows[0].strategy, + symbols: result.rows[0].symbols, + startDate: result.rows[0].start_date, + endDate: result.rows[0].end_date, + initialCapital: parseFloat(result.rows[0].initial_capital), + config: result.rows[0].config, + createdAt: result.rows[0].created_at, + updatedAt: result.rows[0].updated_at, + }; // Call orchestrator to run backtest try { @@ -87,10 +108,14 @@ export class BacktestService { // Store result directly without transformation if (result.status === 'completed') { - backtest.status = 'completed'; - backtest.updatedAt = new Date(); - backtestStore.set(backtestId, backtest); - backtestResults.set(backtestId, result); + // Update backtest status + await this.container.postgres.query( + 'UPDATE backtests SET status = $1, updated_at = NOW() WHERE id = $2', + ['completed', backtestId] + ); + + // Store results in backtest_results table + await this.saveBacktestResults(backtestId, result); logger.info('Backtest completed', { backtestId, @@ -99,14 +124,19 @@ export class BacktestService { }); } else { // Update status to running if not completed - backtest.status = 'running'; - backtest.updatedAt = new Date(); - backtestStore.set(backtestId, backtest); + await this.container.postgres.query( + 'UPDATE backtests SET status = $1, updated_at = NOW() WHERE id = $2', + ['running', backtestId] + ); } logger.info('Backtest started in orchestrator', { backtestId, result }); } catch (error) { logger.error('Failed to start backtest in orchestrator', { backtestId, error }); + await this.container.postgres.query( + 'UPDATE backtests SET status = $1, error = $2, updated_at = NOW() WHERE id = $3', + ['failed', error.message, backtestId] + ); backtest.status = 'failed'; backtest.error = error.message; } @@ -115,30 +145,113 @@ export class BacktestService { } async getBacktest(id: string): Promise { - return backtestStore.get(id) || null; + const result = await this.container.postgres.query( + 'SELECT * FROM backtests WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + const row = result.rows[0]; + return { + id: row.id, + status: row.status, + 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, + error: row.error, + }; } async getBacktestResults(id: string): Promise { - // Return results directly without any transformation - return backtestResults.get(id) || null; + const result = await this.container.postgres.query( + `SELECT + br.*, + b.strategy, + b.symbols, + b.start_date, + b.end_date, + b.initial_capital, + b.config as backtest_config + FROM backtest_results br + JOIN backtests b ON b.id = br.backtest_id + WHERE br.backtest_id = $1`, + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + const row = result.rows[0]; + + // Reconstruct the result format expected by frontend + return { + 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, + }; } async listBacktests(params: { limit: number; offset: number }): Promise { - const all = Array.from(backtestStore.values()); - return all - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) - .slice(params.offset, params.offset + params.limit); + 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 => ({ + id: row.id, + status: row.status, + 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, + error: row.error, + })); } async updateBacktestStatus(id: string, status: BacktestJob['status'], error?: string): Promise { - const backtest = backtestStore.get(id); - if (backtest) { - backtest.status = status; - backtest.updatedAt = new Date(); - if (error) { - backtest.error = error; - } - backtestStore.set(id, backtest); + if (error) { + await this.container.postgres.query( + 'UPDATE backtests SET status = $1, error = $2, updated_at = NOW() WHERE id = $3', + [status, error, id] + ); + } else { + await this.container.postgres.query( + 'UPDATE backtests SET status = $1, updated_at = NOW() WHERE id = $2', + [status, id] + ); } } @@ -154,4 +267,28 @@ export class BacktestService { logger.error('Failed to stop backtest in orchestrator', { backtestId: id, error }); } } + + private async saveBacktestResults(backtestId: string, result: any): Promise { + try { + await this.container.postgres.query( + `INSERT INTO backtest_results + (backtest_id, completed_at, metrics, equity_curve, ohlc_data, trades, positions, analytics, execution_time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + backtestId, + 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 backtest results', { backtestId, error }); + throw error; + } + } } \ No newline at end of file diff --git a/apps/stock/web-app/src/app/App.tsx b/apps/stock/web-app/src/app/App.tsx index e95f970..553be54 100644 --- a/apps/stock/web-app/src/app/App.tsx +++ b/apps/stock/web-app/src/app/App.tsx @@ -3,7 +3,7 @@ import { DashboardPage } from '@/features/dashboard'; import { ExchangesPage } from '@/features/exchanges'; import { MonitoringPage } from '@/features/monitoring'; import { PipelinePage } from '@/features/pipeline'; -import { BacktestPage } from '@/features/backtest'; +import { BacktestListPage, BacktestDetailPage } from '@/features/backtest'; import { SymbolsPage, SymbolDetailPage } from '@/features/symbols'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; @@ -31,7 +31,10 @@ export function App() { path="analytics" element={
Analytics Page - Coming Soon
} /> - } /> + + } /> + } /> + Settings Page - Coming Soon} /> } /> } /> diff --git a/apps/stock/web-app/src/features/backtest/BacktestDetailPage.tsx b/apps/stock/web-app/src/features/backtest/BacktestDetailPage.tsx new file mode 100644 index 0000000..6ba6a59 --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/BacktestDetailPage.tsx @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { BacktestConfiguration } from './components/BacktestConfiguration'; +import { BacktestMetrics } from './components/BacktestMetrics'; +import { BacktestChart } from './components/BacktestChart'; +import { BacktestTrades } from './components/BacktestTrades'; +import { useBacktest } from './hooks/useBacktest'; +import type { BacktestConfig, BacktestResult as LocalBacktestResult } from './types/backtest.types'; +import { ArrowLeftIcon } from '@heroicons/react/24/outline'; + +const tabs = [ + { id: 'settings', name: 'Settings' }, + { id: 'metrics', name: 'Performance Metrics' }, + { id: 'chart', name: 'Chart' }, + { id: 'trades', name: 'Trades' }, +]; + +export function BacktestDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState('settings'); + const { + backtest, + results, + isLoading, + isPolling, + error, + loadBacktest, + createBacktest, + updateBacktest, + cancelBacktest, + } = useBacktest(); + + // Local state to bridge between the API format and the existing UI components + const [config, setConfig] = useState(null); + const [adaptedResults, setAdaptedResults] = useState(null); + + // Load the specific backtest on mount + useEffect(() => { + if (id) { + loadBacktest(id); + } + }, [id, loadBacktest]); + + // Adapt the backtest data to config format when loaded + useEffect(() => { + if (backtest && !config) { + const backtestConfig: BacktestConfig = { + name: backtest.config?.name || '', + startDate: new Date(backtest.startDate), + endDate: new Date(backtest.endDate), + initialCapital: backtest.initialCapital, + symbols: backtest.symbols, + strategy: backtest.strategy, + speedMultiplier: backtest.config?.speedMultiplier || 1, + commission: backtest.config?.commission || 0.001, + slippage: backtest.config?.slippage || 0.0001, + }; + setConfig(backtestConfig); + } + }, [backtest, config]); + + // Adapt the backtest status from API format to local format + const status = backtest ? + (backtest.status === 'pending' ? 'configured' : + backtest.status === 'running' ? 'running' : + backtest.status === 'completed' ? 'completed' : + backtest.status === 'failed' ? 'error' : + backtest.status === 'cancelled' ? 'stopped' : 'idle') : 'idle'; + + // Current time is not available in the new API, so we'll estimate it based on progress + const currentTime = null; + + // No adaptation needed - results are already in the correct format + useEffect(() => { + setAdaptedResults(results); + }, [results]); + + const handleRunBacktest = useCallback(async () => { + if (!config) return; + + const backtestRequest = { + strategy: config.strategy, + symbols: config.symbols, + startDate: config.startDate.toISOString().split('T')[0], + endDate: config.endDate.toISOString().split('T')[0], + initialCapital: config.initialCapital, + config: { + name: config.name, + commission: config.commission, + slippage: config.slippage, + speedMultiplier: config.speedMultiplier, + useTypeScriptImplementation: true, + }, + }; + + await createBacktest(backtestRequest); + }, [config, createBacktest]); + + const handleConfigSubmit = useCallback(async (newConfig: BacktestConfig) => { + setConfig(newConfig); + setAdaptedResults(null); + + const backtestRequest = { + strategy: newConfig.strategy, + symbols: newConfig.symbols, + startDate: newConfig.startDate.toISOString().split('T')[0], + endDate: newConfig.endDate.toISOString().split('T')[0], + initialCapital: newConfig.initialCapital, + config: { + name: newConfig.name, + commission: newConfig.commission, + slippage: newConfig.slippage, + speedMultiplier: newConfig.speedMultiplier, + useTypeScriptImplementation: true, // Enable TypeScript strategy execution + }, + }; + + // If we have an existing backtest ID, update it, otherwise create new + if (id) { + await updateBacktest(id, backtestRequest); + } else { + await createBacktest(backtestRequest); + } + }, [id, createBacktest, updateBacktest]); + + const handleStop = useCallback(async () => { + await cancelBacktest(); + }, [cancelBacktest]); + + const renderTabContent = () => { + switch (activeTab) { + case 'settings': + return ( +
+ +
+ ); + case 'metrics': + return ( +
+ +
+ ); + case 'chart': + return ( + + ); + case 'trades': + return ( +
+ +
+ ); + default: + return null; + } + }; + + return ( +
+ {/* Header with Tabs */} +
+
+
+ +
+

+ {backtest?.config?.name || config?.name || 'Backtest Detail'} + {id && ( + + ID: {id} + + )} +

+

+ {backtest?.strategy || config?.strategy || 'No strategy selected'} +

+
+ + {/* Tabs */} + +
+ + {/* Run/Stop button */} +
+ {status === 'running' ? ( + + ) : status !== 'running' && config && config.symbols.length > 0 ? ( + + ) : null} +
+
+
+ + {/* Tab Content */} +
+ {error && ( +
+

{error}

+
+ )} + {renderTabContent()} +
+
+ ); +} \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/BacktestListPage.tsx b/apps/stock/web-app/src/features/backtest/BacktestListPage.tsx index 5a8a4e1..8c1be67 100644 --- a/apps/stock/web-app/src/features/backtest/BacktestListPage.tsx +++ b/apps/stock/web-app/src/features/backtest/BacktestListPage.tsx @@ -1,14 +1,56 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useBacktestList } from './hooks/useBacktest'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; +import { + PlusIcon, + PlayIcon, + CheckCircleIcon, + XCircleIcon, + ClockIcon, + ExclamationTriangleIcon +} from '@heroicons/react/24/solid'; export function BacktestListPage() { const { backtests, isLoading, error, loadBacktests } = useBacktestList(); + const navigate = useNavigate(); + const [refreshInterval, setRefreshInterval] = useState(null); useEffect(() => { loadBacktests(); + + // Refresh every 5 seconds if there are running backtests + const interval = setInterval(() => { + if (backtests.some(b => b.status === 'running' || b.status === 'pending')) { + loadBacktests(); + } + }, 5000); + + setRefreshInterval(interval); + + return () => { + if (refreshInterval) { + clearInterval(refreshInterval); + } + }; }, [loadBacktests]); + const getStatusIcon = (status: string) => { + switch (status) { + case 'completed': + return ; + case 'running': + return ; + case 'pending': + return ; + case 'failed': + return ; + case 'cancelled': + return ; + default: + return ; + } + }; + const getStatusColor = (status: string) => { switch (status) { case 'completed': @@ -37,12 +79,13 @@ export function BacktestListPage() { View and manage your backtest runs

- navigate('/backtests/new')} + 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" > - New Backtest - + + New Backtest + {error && ( @@ -58,12 +101,13 @@ export function BacktestListPage() { ) : backtests.length === 0 ? (

No backtests found

- navigate('/backtests/new')} + className="inline-flex items-center space-x-2 px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors" > - Create Your First Backtest - + + Create Your First Backtest +
) : (
@@ -94,8 +138,13 @@ export function BacktestListPage() { {new Date(backtest.startDate).toLocaleDateString()} - {new Date(backtest.endDate).toLocaleDateString()} - - {backtest.status} + +
+ {getStatusIcon(backtest.status)} + + {backtest.status} + +
{formatDate(backtest.createdAt)} diff --git a/apps/stock/web-app/src/features/backtest/BacktestPage.tsx b/apps/stock/web-app/src/features/backtest/BacktestPage.tsx index 092393c..3e9ade3 100644 --- a/apps/stock/web-app/src/features/backtest/BacktestPage.tsx +++ b/apps/stock/web-app/src/features/backtest/BacktestPage.tsx @@ -48,6 +48,7 @@ export function BacktestPage() { endDate: newConfig.endDate.toISOString().split('T')[0], initialCapital: newConfig.initialCapital, config: { + name: newConfig.name, commission: newConfig.commission, slippage: newConfig.slippage, speedMultiplier: newConfig.speedMultiplier, diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestChart.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestChart.tsx new file mode 100644 index 0000000..87036f1 --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/components/BacktestChart.tsx @@ -0,0 +1,93 @@ +import type { BacktestResult } from '../types/backtest.types'; +import { useState, useMemo } from 'react'; +import { Chart } from '../../../components/charts'; + +interface BacktestChartProps { + result: BacktestResult | null; + isLoading: boolean; +} + +export function BacktestChart({ result, isLoading }: BacktestChartProps) { + const [selectedSymbol, setSelectedSymbol] = useState(''); + + const chartData = useMemo(() => { + if (!result?.equity || !result?.ohlcData) return null; + + const symbols = Object.keys(result.ohlcData); + const symbol = selectedSymbol || symbols[0] || ''; + + const ohlcData = result.ohlcData[symbol] || []; + const equityData = result.equity.map(e => ({ + time: new Date(e.date).getTime() / 1000, + value: e.value + })); + + // Find trades for this symbol + const symbolTrades = result.trades?.filter(t => t.symbol === symbol) || []; + const tradeMarkers = symbolTrades.map(trade => ({ + time: new Date(trade.entryDate).getTime() / 1000, + position: 'belowBar' as const, + color: trade.side === 'buy' ? '#10b981' : '#ef4444', + shape: trade.side === 'buy' ? 'arrowUp' : 'arrowDown' as const, + text: `${trade.side === 'buy' ? 'Buy' : 'Sell'} @ $${trade.entryPrice.toFixed(2)}` + })); + + return { + ohlcData: ohlcData.map(d => ({ + time: d.time / 1000, + open: d.open, + high: d.high, + low: d.low, + close: d.close, + volume: d.volume + })), + equityData, + tradeMarkers, + symbols + }; + }, [result, selectedSymbol]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!result || !chartData) { + return ( +
+

Run a backtest to see the equity curve and price chart.

+
+ ); + } + + return ( +
+ {chartData.symbols.length > 1 && ( +
+ + +
+ )} + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestConfiguration.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestConfiguration.tsx index 70cf6f2..e63ec65 100644 --- a/apps/stock/web-app/src/features/backtest/components/BacktestConfiguration.tsx +++ b/apps/stock/web-app/src/features/backtest/components/BacktestConfiguration.tsx @@ -1,13 +1,14 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import type { BacktestConfig } from '../types'; interface BacktestConfigurationProps { onSubmit: (config: BacktestConfig) => void; disabled?: boolean; + initialConfig?: BacktestConfig; } -export function BacktestConfiguration({ onSubmit, disabled }: BacktestConfigurationProps) { - const [formData, setFormData] = useState({ +export function BacktestConfiguration({ onSubmit, disabled, initialConfig }: BacktestConfigurationProps) { + const [formData, setFormData] = useState(initialConfig || { name: '', startDate: new Date(new Date().setMonth(new Date().getMonth() - 6)), // 6 months ago endDate: new Date(), @@ -21,6 +22,12 @@ export function BacktestConfiguration({ onSubmit, disabled }: BacktestConfigurat const [symbolInput, setSymbolInput] = useState(''); + useEffect(() => { + if (initialConfig) { + setFormData(initialConfig); + } + }, [initialConfig]); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (formData.symbols.length === 0) { @@ -62,10 +69,7 @@ export function BacktestConfiguration({ onSubmit, disabled }: BacktestConfigurat }; return ( -
-

Configuration

- -
+
); } \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestMetrics.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestMetrics.tsx new file mode 100644 index 0000000..6049c43 --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/components/BacktestMetrics.tsx @@ -0,0 +1,114 @@ +import type { BacktestResult } from '../types/backtest.types'; +import { MetricsCard } from './MetricsCard'; + +interface BacktestMetricsProps { + result: BacktestResult | null; + isLoading: boolean; +} + +export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) { + if (isLoading) { + return ( +
+ {[...Array(6)].map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + if (!result) { + return ( +
+

Run a backtest to see performance metrics.

+
+ ); + } + + const { metrics } = result; + + return ( +
+
+ = 0 ? 'success' : 'error'} + /> + 1 ? 'success' : metrics.sharpeRatio > 0 ? 'warning' : 'error'} + /> + -0.2 ? 'warning' : 'error'} + /> + 0.5 ? 'success' : 'warning'} + /> + + 1.5 ? 'success' : metrics.profitFactor > 1 ? 'warning' : 'error'} + /> +
+ +
+
+

Trade Statistics

+
+
+ Profitable Trades + {metrics.profitableTrades} +
+
+ Average Win + ${metrics.avgWin.toFixed(2)} +
+
+ Average Loss + ${Math.abs(metrics.avgLoss).toFixed(2)} +
+
+ Expectancy + = 0 ? 'text-success' : 'text-error'}`}> + ${metrics.expectancy.toFixed(2)} + +
+
+
+ +
+

Risk Metrics

+
+
+ Calmar Ratio + {metrics.calmarRatio.toFixed(2)} +
+
+ Sortino Ratio + {metrics.sortinoRatio.toFixed(2)} +
+
+ Exposure Time + + {result.analytics?.exposureTime ? `${(result.analytics.exposureTime * 100).toFixed(1)}%` : 'N/A'} + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestTrades.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestTrades.tsx new file mode 100644 index 0000000..0105d1b --- /dev/null +++ b/apps/stock/web-app/src/features/backtest/components/BacktestTrades.tsx @@ -0,0 +1,143 @@ +import type { BacktestResult } from '../types/backtest.types'; +import { TradeLog } from './TradeLog'; + +interface BacktestTradesProps { + result: BacktestResult | null; + isLoading: boolean; +} + +export function BacktestTrades({ result, isLoading }: BacktestTradesProps) { + if (isLoading) { + return ( +
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (!result || !result.trades || result.trades.length === 0) { + return ( +
+

No trades executed in this backtest.

+
+ ); + } + + return ( +
+
+

Trade History

+

+ Total: {result.trades.length} trades +

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

Open Positions

+
+ {Object.entries(result.positions).map(([symbol, position]) => ( +
+ {symbol} +
+ + Qty: {position.quantity} + + + Avg: ${position.averagePrice.toFixed(2)} + + = 0 ? 'text-success' : 'text-error'}> + P&L: ${position.unrealizedPnl.toFixed(2)} + +
+
+ ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/stock/web-app/src/features/backtest/hooks/useBacktest.ts b/apps/stock/web-app/src/features/backtest/hooks/useBacktest.ts index 7b0802e..63715af 100644 --- a/apps/stock/web-app/src/features/backtest/hooks/useBacktest.ts +++ b/apps/stock/web-app/src/features/backtest/hooks/useBacktest.ts @@ -11,7 +11,9 @@ interface UseBacktestReturn { error: string | null; // Actions + loadBacktest: (id: string) => Promise; createBacktest: (request: BacktestRequest) => Promise; + updateBacktest: (id: string, request: BacktestRequest) => Promise; cancelBacktest: () => Promise; reset: () => void; } @@ -61,6 +63,35 @@ export function useBacktest(): UseBacktestReturn { } }, []); + // Load a specific backtest by ID + const loadBacktest = useCallback(async (id: string) => { + setIsLoading(true); + setError(null); + + try { + const loadedBacktest = await backtestApi.getBacktest(id); + setBacktest(loadedBacktest); + + // If completed, also load results + if (loadedBacktest.status === 'completed') { + const backtestResults = await backtestApi.getBacktestResults(id); + setResults(backtestResults); + } + + // If running, start polling + if (loadedBacktest.status === 'running') { + setIsPolling(true); + pollingIntervalRef.current = setInterval(() => { + pollStatus(id); + }, 2000); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load backtest'); + } finally { + setIsLoading(false); + } + }, [pollStatus]); + // Create a new backtest const createBacktest = useCallback(async (request: BacktestRequest) => { setIsLoading(true); @@ -75,7 +106,7 @@ export function useBacktest(): UseBacktestReturn { setIsPolling(true); pollingIntervalRef.current = setInterval(() => { pollStatus(newBacktest.id); - }, 200); // Poll every 2 seconds + }, 2000); // Poll every 2 seconds } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create backtest'); @@ -84,6 +115,29 @@ export function useBacktest(): UseBacktestReturn { } }, [pollStatus]); + // Update an existing backtest + const updateBacktest = useCallback(async (id: string, request: BacktestRequest) => { + setIsLoading(true); + setError(null); + + try { + // For now, we'll delete and recreate since update isn't implemented in the API + await backtestApi.deleteBacktest(id); + const newBacktest = await backtestApi.createBacktest(request); + setBacktest(newBacktest); + + // Start polling for updates + setIsPolling(true); + pollingIntervalRef.current = setInterval(() => { + pollStatus(newBacktest.id); + }, 2000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update backtest'); + } finally { + setIsLoading(false); + } + }, [pollStatus]); + // Cancel running backtest const cancelBacktest = useCallback(async () => { if (!backtest || backtest.status !== 'running') return; @@ -133,7 +187,9 @@ export function useBacktest(): UseBacktestReturn { isLoading, isPolling, error, + loadBacktest, createBacktest, + updateBacktest, cancelBacktest, reset, }; diff --git a/apps/stock/web-app/src/features/backtest/index.ts b/apps/stock/web-app/src/features/backtest/index.ts index caceadc..deeabc1 100644 --- a/apps/stock/web-app/src/features/backtest/index.ts +++ b/apps/stock/web-app/src/features/backtest/index.ts @@ -1,2 +1,4 @@ export { BacktestPage } from './BacktestPage'; +export { BacktestListPage } from './BacktestListPage'; +export { BacktestDetailPage } from './BacktestDetailPage'; export * from './types'; \ No newline at end of file diff --git a/apps/stock/web-app/src/lib/constants.ts b/apps/stock/web-app/src/lib/constants.ts index 03dcc99..8eb685f 100644 --- a/apps/stock/web-app/src/lib/constants.ts +++ b/apps/stock/web-app/src/lib/constants.ts @@ -32,7 +32,7 @@ export const navigation: NavigationItem[] = [ { name: 'Portfolio', href: '/portfolio', icon: ChartBarIcon }, { name: 'Strategies', href: '/strategies', icon: DocumentTextIcon }, { name: 'Analytics', href: '/analytics', icon: PresentationChartLineIcon }, - { name: 'Backtest', href: '/backtest', icon: BeakerIcon }, + { name: 'Backtests', href: '/backtests', icon: BeakerIcon }, { name: 'System', icon: ServerStackIcon, diff --git a/database/postgres/init/06-create-backtest-tables.sql b/database/postgres/init/06-create-backtest-tables.sql new file mode 100644 index 0000000..58ca811 --- /dev/null +++ b/database/postgres/init/06-create-backtest-tables.sql @@ -0,0 +1,56 @@ +-- Create enum for backtest status (if not exists) +DO $$ BEGIN + CREATE TYPE backtest_status AS ENUM ('pending', 'running', 'completed', 'failed', 'cancelled'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create backtests table +CREATE TABLE IF NOT EXISTS backtests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + strategy TEXT NOT NULL, + symbols JSONB NOT NULL, + start_date TIMESTAMP WITH TIME ZONE NOT NULL, + end_date TIMESTAMP WITH TIME ZONE NOT NULL, + initial_capital NUMERIC(20, 2) NOT NULL, + config JSONB DEFAULT '{}', + status backtest_status NOT NULL DEFAULT 'pending', + error TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Create backtest_results table +CREATE TABLE IF NOT EXISTS backtest_results ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + backtest_id UUID NOT NULL REFERENCES backtests(id) ON DELETE CASCADE, + completed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + metrics JSONB NOT NULL, + equity_curve JSONB NOT NULL, + ohlc_data JSONB NOT NULL, + trades JSONB NOT NULL, + positions JSONB NOT NULL, + analytics JSONB NOT NULL, + execution_time INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Create indexes for better query performance +CREATE INDEX idx_backtests_status ON backtests(status); +CREATE INDEX idx_backtests_created_at ON backtests(created_at DESC); +CREATE INDEX idx_backtests_strategy ON backtests(strategy); +CREATE INDEX idx_backtest_results_backtest_id ON backtest_results(backtest_id); + +-- Create updated_at trigger +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS update_backtests_updated_at ON backtests; +CREATE TRIGGER update_backtests_updated_at BEFORE UPDATE + ON backtests FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/database/postgres/init/07-fix-execution-time-type.sql b/database/postgres/init/07-fix-execution-time-type.sql new file mode 100644 index 0000000..d69c29f --- /dev/null +++ b/database/postgres/init/07-fix-execution-time-type.sql @@ -0,0 +1,4 @@ +-- Fix execution_time column type to handle larger values +-- The execution_time might be a timestamp or a large millisecond value +ALTER TABLE backtest_results +ALTER COLUMN execution_time TYPE BIGINT; \ No newline at end of file diff --git a/docs/data-sources.md b/docs/data-sources.md index 0346174..658c492 100644 --- a/docs/data-sources.md +++ b/docs/data-sources.md @@ -12,7 +12,7 @@ - Pricing: https://www.tiingo.com/about/pricing - API Docs: https://api.tiingo.com/ -### Alpha Vantage (Kinda ok) +### Alpha Vantage (Kinda ok) (4M6P2HUGJGI73UWG) - Pricing: https://www.alphavantage.co/premium/ - API Docs: https://www.alphavantage.co/documentation/