adding backtest table / pages

This commit is contained in:
Boki 2025-07-04 14:27:34 -04:00
parent 38a6e73ad5
commit a876f3c35b
19 changed files with 1058 additions and 69 deletions

Binary file not shown.

View file

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

View file

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

View file

@ -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) => {

View file

@ -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<string, BacktestJob>();
const backtestResults = new Map<string, any>();
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<BacktestJob> {
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<BacktestJob | null> {
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<any> {
// 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<BacktestJob[]> {
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<void> {
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<void> {
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;
}
}
}

View file

@ -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={<div className="p-4">Analytics Page - Coming Soon</div>}
/>
<Route path="backtest" element={<BacktestPage />} />
<Route path="backtests">
<Route index element={<BacktestListPage />} />
<Route path=":id" element={<BacktestDetailPage />} />
</Route>
<Route path="settings" element={<div className="p-4">Settings Page - Coming Soon</div>} />
<Route path="system/monitoring" element={<MonitoringPage />} />
<Route path="system/pipeline" element={<PipelinePage />} />

View file

@ -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<BacktestConfig | null>(null);
const [adaptedResults, setAdaptedResults] = useState<LocalBacktestResult | null>(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 (
<div className="max-w-4xl mx-auto">
<BacktestConfiguration
onSubmit={handleConfigSubmit}
disabled={status === 'running'}
initialConfig={config}
/>
</div>
);
case 'metrics':
return (
<div className="h-full overflow-y-auto">
<BacktestMetrics
result={adaptedResults}
isLoading={isLoading || isPolling}
/>
</div>
);
case 'chart':
return (
<BacktestChart
result={adaptedResults}
isLoading={isLoading || isPolling}
/>
);
case 'trades':
return (
<div className="h-full overflow-y-auto">
<BacktestTrades
result={adaptedResults}
isLoading={isLoading || isPolling}
/>
</div>
);
default:
return null;
}
};
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">
<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?.config?.name || config?.name || 'Backtest Detail'}
{id && (
<span className="text-xs font-normal text-text-secondary">
ID: {id}
</span>
)}
</h1>
<p className="text-xs text-text-secondary">
{backtest?.strategy || config?.strategy || 'No strategy selected'}
</p>
</div>
{/* Tabs */}
<nav className="flex ml-4" aria-label="Tabs">
{tabs.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/Stop button */}
<div className="pr-4">
{status === 'running' ? (
<button
onClick={handleStop}
className="px-4 py-1.5 bg-error/10 text-error rounded-md text-sm font-medium hover:bg-error/20 transition-colors"
>
Stop
</button>
) : status !== 'running' && config && config.symbols.length > 0 ? (
<button
onClick={handleRunBacktest}
className="px-4 py-1.5 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
>
Run Backtest
</button>
) : null}
</div>
</div>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-auto p-4">
{error && (
<div className="mb-4 p-4 bg-error/10 border border-error/20 rounded-lg">
<p className="text-sm text-error">{error}</p>
</div>
)}
{renderTabContent()}
</div>
</div>
);
}

View file

@ -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<NodeJS.Timeout | null>(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 <CheckCircleIcon className="w-4 h-4 text-success" />;
case 'running':
return <PlayIcon className="w-4 h-4 text-primary-400 animate-pulse" />;
case 'pending':
return <ClockIcon className="w-4 h-4 text-text-secondary" />;
case 'failed':
return <XCircleIcon className="w-4 h-4 text-error" />;
case 'cancelled':
return <ExclamationTriangleIcon className="w-4 h-4 text-text-muted" />;
default:
return <ClockIcon className="w-4 h-4 text-text-secondary" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
@ -37,12 +79,13 @@ export function BacktestListPage() {
View and manage your backtest runs
</p>
</div>
<Link
to="/backtests/new"
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
<button
onClick={() => 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
</Link>
<PlusIcon className="w-4 h-4" />
<span>New Backtest</span>
</button>
</div>
{error && (
@ -58,12 +101,13 @@ export function BacktestListPage() {
) : backtests.length === 0 ? (
<div className="bg-surface-secondary p-8 rounded-lg border border-border text-center">
<p className="text-text-secondary mb-4">No backtests found</p>
<Link
to="/backtests/new"
className="inline-flex px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
<button
onClick={() => 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
</Link>
<PlusIcon className="w-4 h-4" />
<span>Create Your First Backtest</span>
</button>
</div>
) : (
<div className="bg-surface-secondary rounded-lg border border-border overflow-hidden">
@ -94,8 +138,13 @@ export function BacktestListPage() {
<td className="px-4 py-3 text-sm text-text-primary">
{new Date(backtest.startDate).toLocaleDateString()} - {new Date(backtest.endDate).toLocaleDateString()}
</td>
<td className={`px-4 py-3 text-sm font-medium capitalize ${getStatusColor(backtest.status)}`}>
{backtest.status}
<td className="px-4 py-3">
<div className="flex items-center space-x-2">
{getStatusIcon(backtest.status)}
<span className={`text-sm font-medium capitalize ${getStatusColor(backtest.status)}`}>
{backtest.status}
</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-text-secondary">
{formatDate(backtest.createdAt)}

View file

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

View file

@ -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<string>('');
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 (
<div className="bg-surface-secondary rounded-lg border border-border p-4 h-full animate-pulse">
<div className="h-full bg-surface rounded"></div>
</div>
);
}
if (!result || !chartData) {
return (
<div className="bg-surface-secondary rounded-lg border border-border p-8 text-center h-full flex items-center justify-center">
<p className="text-text-secondary">Run a backtest to see the equity curve and price chart.</p>
</div>
);
}
return (
<div className="h-full flex flex-col">
{chartData.symbols.length > 1 && (
<div className="flex space-x-2 mb-4">
<label className="text-sm text-text-secondary">Symbol:</label>
<select
value={selectedSymbol || chartData.symbols[0]}
onChange={(e) => setSelectedSymbol(e.target.value)}
className="px-2 py-1 bg-background border border-border rounded text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
>
{chartData.symbols.map(symbol => (
<option key={symbol} value={symbol}>{symbol}</option>
))}
</select>
</div>
)}
<div className="flex-1">
<Chart
data={chartData.ohlcData}
equityData={chartData.equityData}
markers={chartData.tradeMarkers}
height={500}
/>
</div>
</div>
);
}

View file

@ -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<BacktestConfig>({
export function BacktestConfiguration({ onSubmit, disabled, initialConfig }: BacktestConfigurationProps) {
const [formData, setFormData] = useState<BacktestConfig>(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 (
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
<h2 className="text-base font-medium text-text-primary mb-4">Configuration</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Backtest Name
@ -257,9 +261,8 @@ export function BacktestConfiguration({ onSubmit, disabled }: BacktestConfigurat
className="w-full px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={disabled}
>
Configure Backtest
Save
</button>
</form>
</div>
);
}

View file

@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="bg-surface-secondary rounded-lg border border-border p-4 h-24 animate-pulse">
<div className="h-4 bg-surface rounded w-1/2 mb-2"></div>
<div className="h-6 bg-surface rounded w-3/4"></div>
</div>
))}
</div>
);
}
if (!result) {
return (
<div className="bg-surface-secondary rounded-lg border border-border p-8 text-center">
<p className="text-text-secondary">Run a backtest to see performance metrics.</p>
</div>
);
}
const { metrics } = result;
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<MetricsCard
title="Total Return"
value={`${(metrics.totalReturn * 100).toFixed(2)}%`}
color={metrics.totalReturn >= 0 ? 'success' : 'error'}
/>
<MetricsCard
title="Sharpe Ratio"
value={metrics.sharpeRatio.toFixed(2)}
color={metrics.sharpeRatio > 1 ? 'success' : metrics.sharpeRatio > 0 ? 'warning' : 'error'}
/>
<MetricsCard
title="Max Drawdown"
value={`${(metrics.maxDrawdown * 100).toFixed(2)}%`}
color={metrics.maxDrawdown > -0.2 ? 'warning' : 'error'}
/>
<MetricsCard
title="Win Rate"
value={`${(metrics.winRate * 100).toFixed(1)}%`}
color={metrics.winRate > 0.5 ? 'success' : 'warning'}
/>
<MetricsCard
title="Total Trades"
value={metrics.totalTrades.toString()}
/>
<MetricsCard
title="Profit Factor"
value={metrics.profitFactor.toFixed(2)}
color={metrics.profitFactor > 1.5 ? 'success' : metrics.profitFactor > 1 ? 'warning' : 'error'}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-surface-secondary rounded-lg border border-border p-4">
<h4 className="text-sm font-medium text-text-secondary mb-3">Trade Statistics</h4>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-text-secondary text-sm">Profitable Trades</span>
<span className="text-text-primary text-sm font-medium">{metrics.profitableTrades}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary text-sm">Average Win</span>
<span className="text-success text-sm font-medium">${metrics.avgWin.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary text-sm">Average Loss</span>
<span className="text-error text-sm font-medium">${Math.abs(metrics.avgLoss).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary text-sm">Expectancy</span>
<span className={`text-sm font-medium ${metrics.expectancy >= 0 ? 'text-success' : 'text-error'}`}>
${metrics.expectancy.toFixed(2)}
</span>
</div>
</div>
</div>
<div className="bg-surface-secondary rounded-lg border border-border p-4">
<h4 className="text-sm font-medium text-text-secondary mb-3">Risk Metrics</h4>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-text-secondary text-sm">Calmar Ratio</span>
<span className="text-text-primary text-sm font-medium">{metrics.calmarRatio.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary text-sm">Sortino Ratio</span>
<span className="text-text-primary text-sm font-medium">{metrics.sortinoRatio.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary text-sm">Exposure Time</span>
<span className="text-text-primary text-sm font-medium">
{result.analytics?.exposureTime ? `${(result.analytics.exposureTime * 100).toFixed(1)}%` : 'N/A'}
</span>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="bg-surface-secondary rounded-lg border border-border p-4 animate-pulse">
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-16 bg-surface rounded"></div>
))}
</div>
</div>
);
}
if (!result || !result.trades || result.trades.length === 0) {
return (
<div className="bg-surface-secondary rounded-lg border border-border p-8 text-center">
<p className="text-text-secondary">No trades executed in this backtest.</p>
</div>
);
}
return (
<div>
<div className="mb-4">
<h3 className="text-base font-medium text-text-primary">Trade History</h3>
<p className="text-sm text-text-secondary mt-1">
Total: {result.trades.length} trades
</p>
</div>
<div className="overflow-x-auto bg-surface-secondary rounded-lg border border-border">
<table className="w-full">
<thead className="bg-background border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Symbol
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Side
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Entry Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Entry Price
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Exit Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Exit Price
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Quantity
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
P&L
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
P&L %
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{result.trades.map((trade) => (
<tr key={trade.id} className="hover:bg-background/50 transition-colors">
<td className="px-4 py-3 text-sm text-text-primary font-medium">
{trade.symbol}
</td>
<td className="px-4 py-3 text-sm">
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-md ${
trade.side === 'buy'
? 'bg-success/10 text-success'
: 'bg-error/10 text-error'
}`}>
{trade.side.toUpperCase()}
</span>
</td>
<td className="px-4 py-3 text-sm text-text-secondary">
{new Date(trade.entryDate).toLocaleString()}
</td>
<td className="px-4 py-3 text-sm text-text-primary">
${trade.entryPrice.toFixed(2)}
</td>
<td className="px-4 py-3 text-sm text-text-secondary">
{trade.exitDate ? new Date(trade.exitDate).toLocaleString() : '-'}
</td>
<td className="px-4 py-3 text-sm text-text-primary">
${trade.exitPrice.toFixed(2)}
</td>
<td className="px-4 py-3 text-sm text-text-primary">
{trade.quantity}
</td>
<td className={`px-4 py-3 text-sm font-medium ${
trade.pnl >= 0 ? 'text-success' : 'text-error'
}`}>
${trade.pnl.toFixed(2)}
</td>
<td className={`px-4 py-3 text-sm font-medium ${
trade.pnlPercent >= 0 ? 'text-success' : 'text-error'
}`}>
{trade.pnlPercent.toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
{result.positions && Object.keys(result.positions).length > 0 && (
<div className="mt-4 bg-surface-secondary rounded-lg border border-border p-4">
<h4 className="text-sm font-medium text-text-secondary mb-3">Open Positions</h4>
<div className="space-y-2">
{Object.entries(result.positions).map(([symbol, position]) => (
<div key={symbol} className="flex justify-between items-center p-2 bg-background rounded">
<span className="text-sm font-medium text-text-primary">{symbol}</span>
<div className="flex space-x-4 text-sm">
<span className="text-text-secondary">
Qty: {position.quantity}
</span>
<span className="text-text-secondary">
Avg: ${position.averagePrice.toFixed(2)}
</span>
<span className={position.unrealizedPnl >= 0 ? 'text-success' : 'text-error'}>
P&L: ${position.unrealizedPnl.toFixed(2)}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -11,7 +11,9 @@ interface UseBacktestReturn {
error: string | null;
// Actions
loadBacktest: (id: string) => Promise<void>;
createBacktest: (request: BacktestRequest) => Promise<void>;
updateBacktest: (id: string, request: BacktestRequest) => Promise<void>;
cancelBacktest: () => Promise<void>;
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,
};

View file

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

View file

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

View file

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

View file

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

View file

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