socket reruns
This commit is contained in:
parent
a876f3c35b
commit
11c6c19628
29 changed files with 3921 additions and 233 deletions
|
|
@ -3,7 +3,7 @@ import { DashboardPage } from '@/features/dashboard';
|
|||
import { ExchangesPage } from '@/features/exchanges';
|
||||
import { MonitoringPage } from '@/features/monitoring';
|
||||
import { PipelinePage } from '@/features/pipeline';
|
||||
import { BacktestListPage, BacktestDetailPage } from '@/features/backtest';
|
||||
import { BacktestListPageV2, BacktestDetailPageV2 } from '@/features/backtest';
|
||||
import { SymbolsPage, SymbolDetailPage } from '@/features/symbols';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
|
|
@ -32,8 +32,11 @@ export function App() {
|
|||
element={<div className="p-4">Analytics Page - Coming Soon</div>}
|
||||
/>
|
||||
<Route path="backtests">
|
||||
<Route index element={<BacktestListPage />} />
|
||||
<Route path=":id" element={<BacktestDetailPage />} />
|
||||
<Route index element={<BacktestListPageV2 />} />
|
||||
<Route path=":id">
|
||||
<Route index element={<BacktestDetailPageV2 />} />
|
||||
<Route path="run/:runId" element={<BacktestDetailPageV2 />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="settings" element={<div className="p-4">Settings Page - Coming Soon</div>} />
|
||||
<Route path="system/monitoring" element={<MonitoringPage />} />
|
||||
|
|
|
|||
|
|
@ -335,8 +335,13 @@ export function Chart({
|
|||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (chart) {
|
||||
chart.remove();
|
||||
// Clear all refs before removing the chart
|
||||
mainSeriesRef.current = null;
|
||||
volumeSeriesRef.current = null;
|
||||
overlaySeriesRef.current.clear();
|
||||
if (chartRef.current) {
|
||||
chartRef.current.remove();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [data, height, type, showVolume, theme, overlayData, tradeMarkers]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,343 @@
|
|||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { ArrowLeftIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { BacktestConfiguration } from './components/BacktestConfiguration';
|
||||
import { BacktestMetrics } from './components/BacktestMetrics';
|
||||
import { BacktestPlayback } from './components/BacktestPlayback';
|
||||
import { BacktestTrades } from './components/BacktestTrades';
|
||||
import { RunControlsCompact } from './components/RunControlsCompact';
|
||||
import { RunsList } from './components/RunsList';
|
||||
import { useBacktestV2 } from './hooks/useBacktestV2';
|
||||
import type { BacktestConfig } from './types/backtest.types';
|
||||
|
||||
const baseTabs = [
|
||||
{ id: 'runs', name: 'Runs' },
|
||||
{ id: 'settings', name: 'Settings' },
|
||||
];
|
||||
|
||||
const runTabs = [
|
||||
{ id: 'playback', name: 'Playback' },
|
||||
{ id: 'metrics', name: 'Performance' },
|
||||
{ id: 'trades', name: 'Trades' },
|
||||
];
|
||||
|
||||
export function BacktestDetailPageV2() {
|
||||
const { id, runId } = useParams<{ id: string; runId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState('playback');
|
||||
const [showNewRun, setShowNewRun] = useState(false);
|
||||
|
||||
const {
|
||||
backtest,
|
||||
runs,
|
||||
currentRun,
|
||||
runResults,
|
||||
isLoading,
|
||||
error,
|
||||
loadBacktest,
|
||||
updateBacktest,
|
||||
createRun,
|
||||
pauseRun,
|
||||
resumeRun,
|
||||
cancelRun,
|
||||
updateRunSpeed,
|
||||
selectRun,
|
||||
} = useBacktestV2();
|
||||
|
||||
// WebSocket connection for real-time updates
|
||||
const { isConnected } = useWebSocket({
|
||||
runId: currentRun?.id || null,
|
||||
onProgress: (progress, currentDate) => {
|
||||
// Update the run progress in the UI
|
||||
if (currentRun) {
|
||||
// This will trigger a re-render with updated progress
|
||||
console.log('Progress update:', progress, currentDate);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Run error:', error);
|
||||
// Reload runs to get updated status
|
||||
if (id) {
|
||||
loadBacktest(id);
|
||||
}
|
||||
},
|
||||
onCompleted: (results) => {
|
||||
console.log('Run completed:', results);
|
||||
// Don't reload the entire backtest, just update the current run status
|
||||
// The results are already available from the WebSocket message
|
||||
}
|
||||
});
|
||||
|
||||
// Load backtest on mount
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadBacktest(id);
|
||||
}
|
||||
}, [id, loadBacktest]);
|
||||
|
||||
// Select run based on URL parameter
|
||||
useEffect(() => {
|
||||
if (runId && runs.length > 0) {
|
||||
const run = runs.find(r => r.id === runId);
|
||||
if (run && run.id !== currentRun?.id) {
|
||||
selectRun(run.id);
|
||||
// Show playback tab by default when a run is selected
|
||||
if (activeTab === 'runs') {
|
||||
setActiveTab('playback');
|
||||
}
|
||||
}
|
||||
} else if (!runId && currentRun) {
|
||||
// Clear run selection when navigating away from run URL
|
||||
selectRun(undefined);
|
||||
setActiveTab('runs');
|
||||
}
|
||||
}, [runId, runs, selectRun]);
|
||||
|
||||
// Handle configuration save
|
||||
const handleConfigSubmit = useCallback(async (config: BacktestConfig) => {
|
||||
if (!id) return;
|
||||
|
||||
await updateBacktest(id, {
|
||||
name: config.name,
|
||||
strategy: config.strategy,
|
||||
symbols: config.symbols,
|
||||
startDate: config.startDate.toISOString().split('T')[0],
|
||||
endDate: config.endDate.toISOString().split('T')[0],
|
||||
initialCapital: config.initialCapital,
|
||||
config: {
|
||||
commission: config.commission,
|
||||
slippage: config.slippage,
|
||||
speedMultiplier: config.speedMultiplier,
|
||||
},
|
||||
});
|
||||
}, [id, updateBacktest]);
|
||||
|
||||
// Handle new run creation
|
||||
const handleCreateRun = useCallback(async (speedMultiplier: number) => {
|
||||
const newRun = await createRun(speedMultiplier);
|
||||
setShowNewRun(false);
|
||||
// Navigate to the new run
|
||||
if (newRun && id) {
|
||||
navigate(`/backtests/${id}/run/${newRun.id}`);
|
||||
}
|
||||
}, [createRun, navigate, id]);
|
||||
|
||||
// Handle rerun
|
||||
const handleRerun = useCallback(async (speedMultiplier: number | null) => {
|
||||
// Pass null for max speed (no limit)
|
||||
const newRun = await createRun(speedMultiplier ?? undefined);
|
||||
// Navigate to the new run's URL
|
||||
if (newRun && id) {
|
||||
navigate(`/backtests/${id}/run/${newRun.id}`);
|
||||
}
|
||||
}, [createRun, navigate, id]);
|
||||
|
||||
// Convert backtest to config format for the form
|
||||
const backtestConfig: BacktestConfig | undefined = backtest ? {
|
||||
name: backtest.name,
|
||||
startDate: new Date(backtest.startDate),
|
||||
endDate: new Date(backtest.endDate),
|
||||
initialCapital: backtest.initialCapital,
|
||||
symbols: backtest.symbols,
|
||||
strategy: backtest.strategy,
|
||||
speedMultiplier: 1,
|
||||
commission: backtest.config?.commission ?? 0.001,
|
||||
slippage: backtest.config?.slippage ?? 0.0001,
|
||||
} : undefined;
|
||||
|
||||
const renderTabContent = () => {
|
||||
// Show message if trying to view run-specific tabs without a run selected
|
||||
if (!currentRun && ['playback', 'metrics', 'trades'].includes(activeTab)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-text-secondary mb-4">Please select a run to view {activeTab}</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('runs')}
|
||||
className="text-primary-500 hover:text-primary-600 font-medium"
|
||||
>
|
||||
Go to Runs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (activeTab) {
|
||||
case 'runs':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-text-primary">Run History</h3>
|
||||
<button
|
||||
onClick={() => setShowNewRun(true)}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
New Run
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewRun && (
|
||||
<div className="bg-surface-secondary rounded-lg border border-border p-4 space-y-4">
|
||||
<h4 className="text-base font-medium text-text-primary">Start New Run</h4>
|
||||
<p className="text-sm text-text-secondary">
|
||||
This will start a new backtest run with the current configuration.
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleCreateRun(1000); // Always use max speed
|
||||
}}
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
Start Run
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewRun(false)}
|
||||
className="px-4 py-2 bg-surface-tertiary text-text-primary rounded-md text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RunsList
|
||||
runs={runs}
|
||||
currentRunId={currentRun?.id}
|
||||
onSelectRun={selectRun}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'settings':
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<BacktestConfiguration
|
||||
onSubmit={handleConfigSubmit}
|
||||
disabled={false}
|
||||
initialConfig={backtestConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'metrics':
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<BacktestMetrics
|
||||
result={runResults}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'playback':
|
||||
return (
|
||||
<BacktestPlayback
|
||||
result={runResults}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
case 'trades':
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<BacktestTrades
|
||||
result={runResults}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!backtest && !isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-text-secondary mb-4">Backtest not found</p>
|
||||
<button
|
||||
onClick={() => navigate('/backtests')}
|
||||
className="text-primary-500 hover:text-primary-600"
|
||||
>
|
||||
Back to Backtests
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with Tabs */}
|
||||
<div className="flex-shrink-0 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center flex-1">
|
||||
<button
|
||||
onClick={() => navigate('/backtests')}
|
||||
className="p-2 hover:bg-surface-tertiary transition-colors"
|
||||
aria-label="Back to backtests"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5 text-text-secondary" />
|
||||
</button>
|
||||
<div className="px-2">
|
||||
<h1 className="text-sm font-bold text-text-primary flex items-center gap-1">
|
||||
{backtest?.name || 'Loading...'}
|
||||
{currentRun && (
|
||||
<span className="text-text-secondary font-normal">
|
||||
- Run #{currentRun.runNumber}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{backtest?.strategy || ''} • {backtest?.symbols.join(', ') || ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<nav className="flex ml-4" aria-label="Tabs">
|
||||
{(currentRun ? runTabs : []).concat(baseTabs).map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
whitespace-nowrap py-2 px-3 border-b-2 font-medium text-sm
|
||||
${activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-500'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary hover:border-border'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Run Controls */}
|
||||
{currentRun && (
|
||||
<div className="px-4">
|
||||
<RunControlsCompact
|
||||
run={currentRun}
|
||||
onPause={pauseRun}
|
||||
onResume={resumeRun}
|
||||
onRerun={handleRerun}
|
||||
onSpeedChange={updateRunSpeed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-error/10 border border-error/20 rounded-lg">
|
||||
<p className="text-sm text-error">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
apps/stock/web-app/src/features/backtest/BacktestListPageV2.tsx
Normal file
151
apps/stock/web-app/src/features/backtest/BacktestListPageV2.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable } from '@/components/ui/DataTable/DataTable';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { PlusIcon } from '@heroicons/react/24/solid';
|
||||
import { backtestApiV2, type Backtest } from './services/backtestApiV2';
|
||||
import { CreateBacktestDialog } from './components/CreateBacktestDialog';
|
||||
|
||||
export function BacktestListPageV2() {
|
||||
const navigate = useNavigate();
|
||||
const [backtests, setBacktests] = useState<Backtest[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
|
||||
const loadBacktests = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await backtestApiV2.listBacktests();
|
||||
setBacktests(data);
|
||||
} catch (err) {
|
||||
setError('Failed to load backtests');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadBacktests();
|
||||
}, []);
|
||||
|
||||
const handleCreateBacktest = async (config: Omit<Backtest, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
try {
|
||||
const newBacktest = await backtestApiV2.createBacktest(config);
|
||||
await loadBacktests();
|
||||
setShowCreateDialog(false);
|
||||
navigate(`/backtests/${newBacktest.id}`);
|
||||
} catch (err) {
|
||||
setError('Failed to create backtest');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Backtest>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
size: 200,
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium text-text-primary">
|
||||
{row.original.name}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'strategy',
|
||||
header: 'Strategy',
|
||||
size: 150,
|
||||
cell: ({ row }) => (
|
||||
<span className="capitalize text-text-primary">
|
||||
{row.original.strategy}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'symbols',
|
||||
header: 'Symbols',
|
||||
size: 200,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{row.original.symbols.join(', ')}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'dateRange',
|
||||
header: 'Date Range',
|
||||
size: 200,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{new Date(row.original.startDate).toLocaleDateString()} - {new Date(row.original.endDate).toLocaleDateString()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'initialCapital',
|
||||
header: 'Initial Capital',
|
||||
size: 150,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-text-primary font-mono">
|
||||
${row.original.initialCapital.toLocaleString()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
size: 180,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{new Date(row.original.createdAt).toLocaleString()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-6">
|
||||
<div className="flex-shrink-0 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-primary mb-2">Backtests</h1>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Create and manage backtest configurations
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>New Backtest</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-error/10 border border-error text-error px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<DataTable
|
||||
data={backtests}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
onRowClick={(row) => navigate(`/backtests/${row.id}`)}
|
||||
height={600}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCreateDialog && (
|
||||
<CreateBacktestDialog
|
||||
onClose={() => setShowCreateDialog(false)}
|
||||
onCreate={handleCreateBacktest}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { BacktestResult } from '../types/backtest.types';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, memo } from 'react';
|
||||
import { Chart } from '../../../components/charts';
|
||||
|
||||
interface BacktestChartProps {
|
||||
|
|
@ -7,7 +7,8 @@ interface BacktestChartProps {
|
|||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function BacktestChart({ result, isLoading }: BacktestChartProps) {
|
||||
// Memoize the component to prevent unnecessary re-renders
|
||||
export const BacktestChart = memo(function BacktestChart({ result, isLoading }: BacktestChartProps) {
|
||||
const [selectedSymbol, setSelectedSymbol] = useState<string>('');
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
|
|
@ -15,32 +16,58 @@ export function BacktestChart({ result, isLoading }: BacktestChartProps) {
|
|||
|
||||
const symbols = Object.keys(result.ohlcData);
|
||||
const symbol = selectedSymbol || symbols[0] || '';
|
||||
|
||||
const ohlcData = result.ohlcData[symbol] || [];
|
||||
const equityData = result.equity.map(e => ({
|
||||
time: new Date(e.date).getTime() / 1000,
|
||||
value: e.value
|
||||
}));
|
||||
|
||||
// Remove excessive logging in production
|
||||
// Log only on significant changes
|
||||
if (process.env.NODE_ENV === 'development' && ohlcData.length > 0) {
|
||||
// Use a simple hash to detect actual data changes
|
||||
const dataHash = `${symbols.length}-${result.equity?.length}-${ohlcData.length}`;
|
||||
if ((window as any).__lastDataHash !== dataHash) {
|
||||
(window as any).__lastDataHash = dataHash;
|
||||
console.log('BacktestChart data updated:', {
|
||||
symbols,
|
||||
selectedSymbol,
|
||||
symbol,
|
||||
ohlcDataKeys: Object.keys(result.ohlcData),
|
||||
equityLength: result.equity?.length,
|
||||
tradesLength: result.trades?.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const equityData = (result.equity || [])
|
||||
.filter(e => e && e.date && e.value != null)
|
||||
.map(e => ({
|
||||
time: new Date(e.date).getTime() / 1000,
|
||||
value: e.value
|
||||
}));
|
||||
|
||||
// Find trades for this symbol
|
||||
const symbolTrades = result.trades?.filter(t => t.symbol === symbol) || [];
|
||||
const tradeMarkers = symbolTrades.map(trade => ({
|
||||
time: new Date(trade.entryDate).getTime() / 1000,
|
||||
position: 'belowBar' as const,
|
||||
color: trade.side === 'buy' ? '#10b981' : '#ef4444',
|
||||
shape: trade.side === 'buy' ? 'arrowUp' : 'arrowDown' as const,
|
||||
text: `${trade.side === 'buy' ? 'Buy' : 'Sell'} @ $${trade.entryPrice.toFixed(2)}`
|
||||
}));
|
||||
const tradeMarkers = symbolTrades
|
||||
.filter(trade => trade.entryPrice != null && trade.entryDate != null)
|
||||
.map(trade => ({
|
||||
time: new Date(trade.entryDate).getTime() / 1000,
|
||||
position: 'belowBar' as const,
|
||||
color: trade.side === 'buy' ? '#10b981' : '#ef4444',
|
||||
shape: trade.side === 'buy' ? 'arrowUp' : 'arrowDown' as const,
|
||||
text: `${trade.side === 'buy' ? 'Buy' : 'Sell'} @ $${trade.entryPrice.toFixed(2)}`
|
||||
}));
|
||||
|
||||
return {
|
||||
ohlcData: ohlcData.map(d => ({
|
||||
time: d.time / 1000,
|
||||
const processedOhlcData = ohlcData
|
||||
.filter(d => d && d.timestamp != null && d.open != null && d.high != null && d.low != null && d.close != null)
|
||||
.map(d => ({
|
||||
time: d.timestamp / 1000, // timestamp is already in milliseconds
|
||||
open: d.open,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
close: d.close,
|
||||
volume: d.volume
|
||||
})),
|
||||
volume: d.volume || 0
|
||||
}));
|
||||
|
||||
return {
|
||||
ohlcData: processedOhlcData,
|
||||
equityData,
|
||||
tradeMarkers,
|
||||
symbols
|
||||
|
|
@ -90,4 +117,4 @@ export function BacktestChart({ result, isLoading }: BacktestChartProps) {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -35,32 +35,32 @@ export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
|
|||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<MetricsCard
|
||||
title="Total Return"
|
||||
value={`${(metrics.totalReturn * 100).toFixed(2)}%`}
|
||||
color={metrics.totalReturn >= 0 ? 'success' : 'error'}
|
||||
value={`${((metrics.totalReturn ?? 0) * 100).toFixed(2)}%`}
|
||||
color={(metrics.totalReturn ?? 0) >= 0 ? 'success' : 'error'}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Sharpe Ratio"
|
||||
value={metrics.sharpeRatio.toFixed(2)}
|
||||
color={metrics.sharpeRatio > 1 ? 'success' : metrics.sharpeRatio > 0 ? 'warning' : 'error'}
|
||||
value={(metrics.sharpeRatio ?? 0).toFixed(2)}
|
||||
color={(metrics.sharpeRatio ?? 0) > 1 ? 'success' : (metrics.sharpeRatio ?? 0) > 0 ? 'warning' : 'error'}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Max Drawdown"
|
||||
value={`${(metrics.maxDrawdown * 100).toFixed(2)}%`}
|
||||
color={metrics.maxDrawdown > -0.2 ? 'warning' : 'error'}
|
||||
value={`${((metrics.maxDrawdown ?? 0) * 100).toFixed(2)}%`}
|
||||
color={(metrics.maxDrawdown ?? 0) > -0.2 ? 'warning' : 'error'}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Win Rate"
|
||||
value={`${(metrics.winRate * 100).toFixed(1)}%`}
|
||||
color={metrics.winRate > 0.5 ? 'success' : 'warning'}
|
||||
value={`${((metrics.winRate ?? 0) * 100).toFixed(1)}%`}
|
||||
color={(metrics.winRate ?? 0) > 0.5 ? 'success' : 'warning'}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Total Trades"
|
||||
value={metrics.totalTrades.toString()}
|
||||
value={(metrics.totalTrades ?? 0).toString()}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Profit Factor"
|
||||
value={metrics.profitFactor.toFixed(2)}
|
||||
color={metrics.profitFactor > 1.5 ? 'success' : metrics.profitFactor > 1 ? 'warning' : 'error'}
|
||||
value={(metrics.profitFactor ?? 0).toFixed(2)}
|
||||
color={(metrics.profitFactor ?? 0) > 1.5 ? 'success' : (metrics.profitFactor ?? 0) > 1 ? 'warning' : 'error'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -70,20 +70,20 @@ export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
|
|||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary text-sm">Profitable Trades</span>
|
||||
<span className="text-text-primary text-sm font-medium">{metrics.profitableTrades}</span>
|
||||
<span className="text-text-primary text-sm font-medium">{metrics.profitableTrades ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary text-sm">Average Win</span>
|
||||
<span className="text-success text-sm font-medium">${metrics.avgWin.toFixed(2)}</span>
|
||||
<span className="text-success text-sm font-medium">${(metrics.avgWin ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary text-sm">Average Loss</span>
|
||||
<span className="text-error text-sm font-medium">${Math.abs(metrics.avgLoss).toFixed(2)}</span>
|
||||
<span className="text-error text-sm font-medium">${Math.abs(metrics.avgLoss ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary text-sm">Expectancy</span>
|
||||
<span className={`text-sm font-medium ${metrics.expectancy >= 0 ? 'text-success' : 'text-error'}`}>
|
||||
${metrics.expectancy.toFixed(2)}
|
||||
<span className={`text-sm font-medium ${(metrics.expectancy ?? 0) >= 0 ? 'text-success' : 'text-error'}`}>
|
||||
${(metrics.expectancy ?? 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -94,11 +94,11 @@ export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
|
|||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary text-sm">Calmar Ratio</span>
|
||||
<span className="text-text-primary text-sm font-medium">{metrics.calmarRatio.toFixed(2)}</span>
|
||||
<span className="text-text-primary text-sm font-medium">{(metrics.calmarRatio ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary text-sm">Sortino Ratio</span>
|
||||
<span className="text-text-primary text-sm font-medium">{metrics.sortinoRatio.toFixed(2)}</span>
|
||||
<span className="text-text-primary text-sm font-medium">{(metrics.sortinoRatio ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary text-sm">Exposure Time</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
import { useState, memo } from 'react';
|
||||
import { BacktestChart } from './BacktestChart';
|
||||
|
||||
interface BacktestPlaybackProps {
|
||||
result: any | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoading }: BacktestPlaybackProps) {
|
||||
const [showPositions, setShowPositions] = useState(true);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-text-secondary">Loading playback data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-text-secondary">No data available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get open positions from the result
|
||||
// Positions can be an object with symbols as keys or an array
|
||||
let openPositions: any[] = [];
|
||||
|
||||
if (result.positions) {
|
||||
if (Array.isArray(result.positions)) {
|
||||
openPositions = result.positions.filter((p: any) => p.quantity > 0);
|
||||
} else if (typeof result.positions === 'object') {
|
||||
// Convert positions object to array
|
||||
openPositions = Object.entries(result.positions)
|
||||
.filter(([_, position]: [string, any]) => position.quantity > 0)
|
||||
.map(([symbol, position]: [string, any]) => ({
|
||||
symbol,
|
||||
...position
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-4">
|
||||
{/* Chart Section */}
|
||||
<div className="flex-1">
|
||||
<BacktestChart result={result} isLoading={isLoading} />
|
||||
</div>
|
||||
|
||||
{/* Open Positions Section */}
|
||||
{openPositions.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-surface-secondary rounded-lg border border-border p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-medium text-text-primary">
|
||||
Open Positions ({openPositions.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowPositions(!showPositions)}
|
||||
className="text-xs text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
{showPositions ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPositions && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-6 gap-2 text-xs font-medium text-text-secondary pb-2 border-b border-border">
|
||||
<div>Symbol</div>
|
||||
<div>Side</div>
|
||||
<div className="text-right">Quantity</div>
|
||||
<div className="text-right">Entry Price</div>
|
||||
<div className="text-right">Current Price</div>
|
||||
<div className="text-right">P&L</div>
|
||||
</div>
|
||||
|
||||
{openPositions.map((position, index) => {
|
||||
const quantity = position.quantity || 0;
|
||||
const avgPrice = position.averagePrice || position.avgPrice || 0;
|
||||
const currentPrice = position.currentPrice || position.lastPrice || avgPrice;
|
||||
const side = quantity > 0 ? 'buy' : 'sell';
|
||||
const absQuantity = Math.abs(quantity);
|
||||
const pnl = (currentPrice - avgPrice) * quantity;
|
||||
const pnlPercent = avgPrice > 0 ? (pnl / (avgPrice * absQuantity)) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={index} className="grid grid-cols-6 gap-2 text-sm">
|
||||
<div className="font-medium text-text-primary">{position.symbol}</div>
|
||||
<div className={side === 'buy' ? 'text-success' : 'text-error'}>
|
||||
{side.toUpperCase()}
|
||||
</div>
|
||||
<div className="text-right text-text-primary">{absQuantity}</div>
|
||||
<div className="text-right text-text-primary">
|
||||
${avgPrice.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-right text-text-primary">
|
||||
${currentPrice.toFixed(2)}
|
||||
</div>
|
||||
<div className={`text-right font-medium ${pnl >= 0 ? 'text-success' : 'text-error'}`}>
|
||||
${pnl.toFixed(2)} ({pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="grid grid-cols-6 gap-2 text-sm font-medium">
|
||||
<div className="col-span-5 text-right text-text-secondary">Total P&L:</div>
|
||||
<div className={`text-right ${
|
||||
openPositions.reduce((sum, p) => {
|
||||
const quantity = p.quantity || 0;
|
||||
const avgPrice = p.averagePrice || p.avgPrice || 0;
|
||||
const currentPrice = p.currentPrice || p.lastPrice || avgPrice;
|
||||
return sum + ((currentPrice - avgPrice) * quantity);
|
||||
}, 0) >= 0 ? 'text-success' : 'text-error'
|
||||
}`}>
|
||||
${openPositions.reduce((sum, p) => {
|
||||
const quantity = p.quantity || 0;
|
||||
const avgPrice = p.averagePrice || p.avgPrice || 0;
|
||||
const currentPrice = p.currentPrice || p.lastPrice || avgPrice;
|
||||
return sum + ((currentPrice - avgPrice) * quantity);
|
||||
}, 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { BacktestResult } from '../types/backtest.types';
|
||||
import { TradeLog } from './TradeLog';
|
||||
import { DataTable } from '@/components/ui/DataTable';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
interface BacktestTradesProps {
|
||||
result: BacktestResult | null;
|
||||
|
|
@ -27,6 +28,124 @@ export function BacktestTrades({ result, isLoading }: BacktestTradesProps) {
|
|||
);
|
||||
}
|
||||
|
||||
const columns: ColumnDef<typeof result.trades[0]>[] = [
|
||||
{
|
||||
accessorKey: 'symbol',
|
||||
header: 'Symbol',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-sm font-medium text-text-primary">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'side',
|
||||
header: 'Side',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const side = getValue() as string || 'unknown';
|
||||
return (
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-md ${
|
||||
side === 'buy'
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-error/10 text-error'
|
||||
}`}>
|
||||
{side.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'entryDate',
|
||||
header: 'Entry Date',
|
||||
size: 180,
|
||||
cell: ({ getValue }) => {
|
||||
const date = getValue() as string;
|
||||
return (
|
||||
<span className="text-sm text-text-secondary">
|
||||
{date ? new Date(date).toLocaleString() : '-'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'entryPrice',
|
||||
header: 'Entry Price',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => {
|
||||
const price = getValue() as number;
|
||||
return (
|
||||
<span className="text-sm text-text-primary">
|
||||
${price != null ? price.toFixed(2) : '0.00'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'exitDate',
|
||||
header: 'Exit Date',
|
||||
size: 180,
|
||||
cell: ({ getValue }) => {
|
||||
const date = getValue() as string | null;
|
||||
return (
|
||||
<span className="text-sm text-text-secondary">
|
||||
{date ? new Date(date).toLocaleString() : '-'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'exitPrice',
|
||||
header: 'Exit Price',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => {
|
||||
const price = getValue() as number;
|
||||
return (
|
||||
<span className="text-sm text-text-primary">
|
||||
${price != null ? price.toFixed(2) : '0.00'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'quantity',
|
||||
header: 'Quantity',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-sm text-text-primary">{getValue() as number || 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'pnl',
|
||||
header: 'P&L',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => {
|
||||
const pnl = getValue() as number || 0;
|
||||
return (
|
||||
<span className={`text-sm font-medium ${
|
||||
pnl >= 0 ? 'text-success' : 'text-error'
|
||||
}`}>
|
||||
${pnl != null ? pnl.toFixed(2) : '0.00'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'pnlPercent',
|
||||
header: 'P&L %',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => {
|
||||
const pnlPercent = getValue() as number || 0;
|
||||
return (
|
||||
<span className={`text-sm font-medium ${
|
||||
pnlPercent >= 0 ? 'text-success' : 'text-error'
|
||||
}`}>
|
||||
{pnlPercent != null ? pnlPercent.toFixed(2) : '0.00'}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
|
|
@ -36,108 +155,78 @@ export function BacktestTrades({ result, isLoading }: BacktestTradesProps) {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto bg-surface-secondary rounded-lg border border-border">
|
||||
<table className="w-full">
|
||||
<thead className="bg-background border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Symbol
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Side
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Entry Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Entry Price
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Exit Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Exit Price
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
Quantity
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
P&L
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
P&L %
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{result.trades.map((trade) => (
|
||||
<tr key={trade.id} className="hover:bg-background/50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-text-primary font-medium">
|
||||
{trade.symbol}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-md ${
|
||||
trade.side === 'buy'
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-error/10 text-error'
|
||||
}`}>
|
||||
{trade.side.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||
{new Date(trade.entryDate).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-primary">
|
||||
${trade.entryPrice.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||
{trade.exitDate ? new Date(trade.exitDate).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-primary">
|
||||
${trade.exitPrice.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-primary">
|
||||
{trade.quantity}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm font-medium ${
|
||||
trade.pnl >= 0 ? 'text-success' : 'text-error'
|
||||
}`}>
|
||||
${trade.pnl.toFixed(2)}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm font-medium ${
|
||||
trade.pnlPercent >= 0 ? 'text-success' : 'text-error'
|
||||
}`}>
|
||||
{trade.pnlPercent.toFixed(2)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTable
|
||||
data={result.trades}
|
||||
columns={columns}
|
||||
className="bg-surface-secondary rounded-lg border border-border"
|
||||
height={400}
|
||||
/>
|
||||
|
||||
{result.positions && Object.keys(result.positions).length > 0 && (
|
||||
<div className="mt-4 bg-surface-secondary rounded-lg border border-border p-4">
|
||||
<h4 className="text-sm font-medium text-text-secondary mb-3">Open Positions</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(result.positions).map(([symbol, position]) => (
|
||||
<div key={symbol} className="flex justify-between items-center p-2 bg-background rounded">
|
||||
<span className="text-sm font-medium text-text-primary">{symbol}</span>
|
||||
<div className="flex space-x-4 text-sm">
|
||||
<span className="text-text-secondary">
|
||||
Qty: {position.quantity}
|
||||
</span>
|
||||
<span className="text-text-secondary">
|
||||
Avg: ${position.averagePrice.toFixed(2)}
|
||||
</span>
|
||||
<span className={position.unrealizedPnl >= 0 ? 'text-success' : 'text-error'}>
|
||||
P&L: ${position.unrealizedPnl.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{result.positions && Object.keys(result.positions).length > 0 && (() => {
|
||||
const positionsArray = Object.entries(result.positions).map(([symbol, position]) => ({
|
||||
symbol,
|
||||
...position
|
||||
}));
|
||||
|
||||
const positionColumns: ColumnDef<typeof positionsArray[0]>[] = [
|
||||
{
|
||||
accessorKey: 'symbol',
|
||||
header: 'Symbol',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-sm font-medium text-text-primary">{getValue() as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'quantity',
|
||||
header: 'Quantity',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-sm text-text-primary">{getValue() as number || 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'averagePrice',
|
||||
header: 'Avg Price',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => {
|
||||
const price = getValue() as number;
|
||||
return (
|
||||
<span className="text-sm text-text-primary">
|
||||
${price != null ? price.toFixed(2) : '0.00'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'unrealizedPnl',
|
||||
header: 'Unrealized P&L',
|
||||
size: 150,
|
||||
cell: ({ getValue }) => {
|
||||
const pnl = getValue() as number || 0;
|
||||
return (
|
||||
<span className={`text-sm font-medium ${
|
||||
pnl >= 0 ? 'text-success' : 'text-error'
|
||||
}`}>
|
||||
${pnl != null ? pnl.toFixed(2) : '0.00'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-base font-medium text-text-primary mb-3">Open Positions</h4>
|
||||
<DataTable
|
||||
data={positionsArray}
|
||||
columns={positionColumns}
|
||||
className="bg-surface-secondary rounded-lg border border-border"
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import { useState } from 'react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/solid';
|
||||
import type { Backtest } from '../services/backtestApiV2';
|
||||
|
||||
interface CreateBacktestDialogProps {
|
||||
onClose: () => void;
|
||||
onCreate: (config: Omit<Backtest, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
}
|
||||
|
||||
export function CreateBacktestDialog({ onClose, onCreate }: CreateBacktestDialogProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
strategy: 'moving-average',
|
||||
symbols: ['AAPL'],
|
||||
startDate: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
endDate: new Date().toISOString().split('T')[0],
|
||||
initialCapital: 100000,
|
||||
commission: 0,
|
||||
slippage: 0,
|
||||
});
|
||||
|
||||
const [symbolInput, setSymbolInput] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
alert('Please enter a backtest name');
|
||||
return;
|
||||
}
|
||||
|
||||
onCreate({
|
||||
...formData,
|
||||
metadata: {},
|
||||
});
|
||||
};
|
||||
|
||||
const addSymbol = () => {
|
||||
const symbol = symbolInput.trim().toUpperCase();
|
||||
if (symbol && !formData.symbols.includes(symbol)) {
|
||||
setFormData({ ...formData, symbols: [...formData.symbols, symbol] });
|
||||
setSymbolInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeSymbol = (symbol: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
symbols: formData.symbols.filter(s => s !== symbol),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-surface-secondary rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold text-text-primary">Create New Backtest</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-surface-tertiary rounded transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5 text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Backtest Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="My Strategy Backtest"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Strategy
|
||||
</label>
|
||||
<select
|
||||
value={formData.strategy}
|
||||
onChange={(e) => setFormData({ ...formData, strategy: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="moving-average">Moving Average</option>
|
||||
<option value="momentum">Momentum</option>
|
||||
<option value="mean-reversion">Mean Reversion</option>
|
||||
<option value="pairs-trading">Pairs Trading</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Symbols
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={symbolInput}
|
||||
onChange={(e) => setSymbolInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addSymbol())}
|
||||
className="flex-1 px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Enter symbol (e.g., AAPL)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSymbol}
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.symbols.map(symbol => (
|
||||
<span
|
||||
key={symbol}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-background border border-border rounded-full text-sm text-text-primary"
|
||||
>
|
||||
{symbol}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSymbol(symbol)}
|
||||
className="hover:text-error transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.startDate}
|
||||
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.endDate}
|
||||
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Initial Capital
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.initialCapital}
|
||||
onChange={(e) => setFormData({ ...formData, initialCapital: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
min="0"
|
||||
step="1000"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Commission (per trade)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.commission}
|
||||
onChange={(e) => setFormData({ ...formData, commission: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Slippage (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.slippage}
|
||||
onChange={(e) => setFormData({ ...formData, slippage: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-border text-text-secondary rounded-md text-sm font-medium hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
Create Backtest
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
import { useState } from 'react';
|
||||
import type { Run } from '../services/backtestApiV2';
|
||||
import {
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
StopIcon,
|
||||
ForwardIcon,
|
||||
BackwardIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
|
||||
interface RunControlsProps {
|
||||
run: Run;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onCancel: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
}
|
||||
|
||||
export function RunControls({
|
||||
run,
|
||||
onPause,
|
||||
onResume,
|
||||
onCancel,
|
||||
onSpeedChange,
|
||||
}: RunControlsProps) {
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState(run.speedMultiplier);
|
||||
|
||||
const handleSpeedChange = (speed: number) => {
|
||||
setSpeedMultiplier(speed);
|
||||
onSpeedChange(speed);
|
||||
};
|
||||
|
||||
const speedOptions = [
|
||||
{ value: 0.1, label: '0.1x' },
|
||||
{ value: 0.5, label: '0.5x' },
|
||||
{ value: 1, label: '1x' },
|
||||
{ value: 2, label: '2x' },
|
||||
{ value: 5, label: '5x' },
|
||||
{ value: 10, label: '10x' },
|
||||
{ value: 50, label: '50x' },
|
||||
{ value: 100, label: '100x' },
|
||||
{ value: 1000, label: 'Max' },
|
||||
];
|
||||
|
||||
const formatDate = (date?: string) => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString();
|
||||
};
|
||||
|
||||
const formatDuration = () => {
|
||||
if (!run.startedAt) return '00:00';
|
||||
const start = new Date(run.startedAt).getTime();
|
||||
const current = Date.now();
|
||||
const duration = current - start;
|
||||
|
||||
const seconds = Math.floor(duration / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${minutes.toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface-secondary rounded-lg border border-border p-4">
|
||||
<h3 className="text-base font-medium text-text-primary mb-4">Run Control</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Progress Bar */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm text-text-secondary mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{run.progress.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-background rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary-500 h-3 transition-all duration-300 relative"
|
||||
style={{ width: `${run.progress}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-500 to-primary-400 opacity-50"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{run.status === 'running' ? (
|
||||
<button
|
||||
onClick={onPause}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-warning text-white rounded-md text-sm font-medium hover:bg-warning/90 transition-colors"
|
||||
>
|
||||
<PauseIcon className="w-4 h-4" />
|
||||
Pause
|
||||
</button>
|
||||
) : run.status === 'paused' ? (
|
||||
<button
|
||||
onClick={onResume}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-success text-white rounded-md text-sm font-medium hover:bg-success/90 transition-colors"
|
||||
>
|
||||
<PlayIcon className="w-4 h-4" />
|
||||
Resume
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{(run.status === 'running' || run.status === 'paused') && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-error text-white rounded-md text-sm font-medium hover:bg-error/90 transition-colors"
|
||||
>
|
||||
<StopIcon className="w-4 h-4" />
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Speed Control */}
|
||||
{run.status === 'running' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Playback Speed
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const currentIndex = speedOptions.findIndex(s => s.value === speedMultiplier);
|
||||
if (currentIndex > 0) {
|
||||
handleSpeedChange(speedOptions[currentIndex - 1].value);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded hover:bg-surface-tertiary transition-colors"
|
||||
disabled={speedMultiplier <= 0.1}
|
||||
>
|
||||
<BackwardIcon className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
|
||||
<select
|
||||
value={speedMultiplier}
|
||||
onChange={(e) => handleSpeedChange(parseFloat(e.target.value))}
|
||||
className="flex-1 px-3 py-1.5 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
{speedOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const currentIndex = speedOptions.findIndex(s => s.value === speedMultiplier);
|
||||
if (currentIndex < speedOptions.length - 1) {
|
||||
handleSpeedChange(speedOptions[currentIndex + 1].value);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded hover:bg-surface-tertiary transition-colors"
|
||||
disabled={speedMultiplier >= 1000}
|
||||
>
|
||||
<ForwardIcon className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run Info */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">Status:</span>
|
||||
<span className="ml-2 text-text-primary font-medium">
|
||||
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Duration:</span>
|
||||
<span className="ml-2 text-text-primary font-medium">
|
||||
{formatDuration()}
|
||||
</span>
|
||||
</div>
|
||||
{run.currentSimulationDate && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-text-secondary">Current Date:</span>
|
||||
<span className="ml-2 text-text-primary font-medium">
|
||||
{formatDate(run.currentSimulationDate)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{run.error && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-text-secondary">Error:</span>
|
||||
<span className="ml-2 text-error text-xs">
|
||||
{run.error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import type { Run } from '../services/backtestApiV2';
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
|
||||
interface RunControlsCompactProps {
|
||||
run: Run;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onRerun: (speed: number | null) => void;
|
||||
onNext?: () => void;
|
||||
onSpeedChange: (speed: number | null) => void;
|
||||
}
|
||||
|
||||
export function RunControlsCompact({
|
||||
run,
|
||||
onPause,
|
||||
onResume,
|
||||
onRerun,
|
||||
onNext,
|
||||
}: RunControlsCompactProps) {
|
||||
|
||||
const isRunning = run.status === 'running';
|
||||
const isPaused = run.status === 'paused';
|
||||
const isCompleted = run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled';
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Progress */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-text-secondary">Progress:</span>
|
||||
<div className="w-24 bg-background rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary-500 h-2 transition-all duration-300 relative"
|
||||
style={{ width: `${run.progress}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-500 to-primary-400 opacity-50"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-text-primary font-medium">{run.progress.toFixed(0)}%</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isRunning ? (
|
||||
<button
|
||||
onClick={onPause}
|
||||
className="p-1 rounded hover:bg-surface-tertiary transition-colors"
|
||||
title="Pause"
|
||||
>
|
||||
<PauseIcon className="w-4 h-4 text-text-primary" />
|
||||
</button>
|
||||
) : isPaused ? (
|
||||
<button
|
||||
onClick={onResume}
|
||||
className="p-1 rounded hover:bg-surface-tertiary transition-colors"
|
||||
title="Resume"
|
||||
>
|
||||
<PlayIcon className="w-4 h-4 text-text-primary" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{onNext && (
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="p-1 rounded hover:bg-surface-tertiary transition-colors disabled:opacity-50"
|
||||
disabled={!isRunning && !isPaused}
|
||||
title="Next"
|
||||
>
|
||||
<ForwardIcon className="w-4 h-4 text-text-primary" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onRerun(null)} // Always use max speed
|
||||
className="p-1 rounded hover:bg-surface-tertiary transition-colors"
|
||||
title="Rerun"
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4 text-text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Indicator */}
|
||||
{run.status !== 'running' && run.status !== 'paused' && (
|
||||
<>
|
||||
<div className="h-6 w-px bg-border"></div>
|
||||
<span className={`text-xs font-medium ${
|
||||
run.status === 'completed' ? 'text-success' :
|
||||
run.status === 'failed' ? 'text-error' :
|
||||
run.status === 'cancelled' ? 'text-text-secondary' :
|
||||
'text-text-primary'
|
||||
}`}>
|
||||
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
apps/stock/web-app/src/features/backtest/components/RunsList.tsx
Normal file
177
apps/stock/web-app/src/features/backtest/components/RunsList.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { DataTable } from '@/components/ui/DataTable';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { Run } from '../services/backtestApiV2';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface RunsListProps {
|
||||
runs: Run[];
|
||||
currentRunId?: string;
|
||||
onSelectRun: (runId: string) => void;
|
||||
}
|
||||
|
||||
export function RunsList({ runs, currentRunId, onSelectRun }: RunsListProps) {
|
||||
const navigate = useNavigate();
|
||||
const { id: backtestId } = useParams<{ id: string }>();
|
||||
|
||||
const getStatusIcon = (status: Run['status']) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircleIcon className="w-4 h-4 text-success" />;
|
||||
case 'failed':
|
||||
return <XCircleIcon className="w-4 h-4 text-error" />;
|
||||
case 'cancelled':
|
||||
return <XCircleIcon className="w-4 h-4 text-text-secondary" />;
|
||||
case 'running':
|
||||
return <PlayIcon className="w-4 h-4 text-primary-500" />;
|
||||
case 'paused':
|
||||
return <PauseIcon className="w-4 h-4 text-warning" />;
|
||||
case 'pending':
|
||||
return <ClockIcon className="w-4 h-4 text-text-secondary" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: Run['status']) => {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
};
|
||||
|
||||
const formatDuration = (startedAt?: string, completedAt?: string) => {
|
||||
if (!startedAt) return '-';
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||
const duration = end - start;
|
||||
|
||||
const seconds = Math.floor(duration / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Run>[] = [
|
||||
{
|
||||
accessorKey: 'runNumber',
|
||||
header: 'Run #',
|
||||
size: 80,
|
||||
cell: ({ getValue, row }) => (
|
||||
<span className={`text-sm font-medium ${row.original.id === currentRunId ? 'text-primary-500' : 'text-text-primary'}`}>
|
||||
#{getValue() as number}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
size: 120,
|
||||
cell: ({ getValue }) => {
|
||||
const status = getValue() as Run['status'];
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(status)}
|
||||
<span className="text-sm text-text-primary">{getStatusLabel(status)}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'progress',
|
||||
header: 'Progress',
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const progress = row.original.progress;
|
||||
const status = row.original.status;
|
||||
|
||||
if (status === 'pending') return <span className="text-sm text-text-secondary">-</span>;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 bg-surface-tertiary rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary-500 h-2 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">{progress.toFixed(0)}%</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'speedMultiplier',
|
||||
header: 'Speed',
|
||||
size: 80,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-sm text-text-primary">{getValue() as number}x</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'startedAt',
|
||||
header: 'Started',
|
||||
size: 180,
|
||||
cell: ({ getValue }) => {
|
||||
const date = getValue() as string | undefined;
|
||||
return (
|
||||
<span className="text-sm text-text-secondary">
|
||||
{date ? new Date(date).toLocaleString() : '-'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
size: 100,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-text-secondary">
|
||||
{formatDuration(row.original.startedAt, row.original.completedAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'error',
|
||||
header: 'Error',
|
||||
size: 200,
|
||||
cell: ({ getValue }) => {
|
||||
const error = getValue() as string | undefined;
|
||||
return error ? (
|
||||
<span className="text-sm text-error truncate" title={error}>{error}</span>
|
||||
) : (
|
||||
<span className="text-sm text-text-secondary">-</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="bg-surface-secondary rounded-lg border border-border p-8 text-center">
|
||||
<p className="text-text-secondary">No runs yet. Create a new run to get started.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={runs}
|
||||
columns={columns}
|
||||
onRowClick={(run) => {
|
||||
navigate(`/backtests/${backtestId}/run/${run.id}`);
|
||||
onSelectRun(run.id);
|
||||
}}
|
||||
className="bg-surface-secondary rounded-lg border border-border"
|
||||
height={400}
|
||||
/>
|
||||
);
|
||||
}
|
||||
304
apps/stock/web-app/src/features/backtest/hooks/useBacktestV2.ts
Normal file
304
apps/stock/web-app/src/features/backtest/hooks/useBacktestV2.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { backtestApiV2 } from '../services/backtestApiV2';
|
||||
import type { Backtest, Run, RunResult, CreateBacktestRequest } from '../services/backtestApiV2';
|
||||
|
||||
interface UseBacktestV2Return {
|
||||
// State
|
||||
backtest: Backtest | null;
|
||||
runs: Run[];
|
||||
currentRun: Run | null;
|
||||
runResults: RunResult | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
loadBacktest: (id: string) => Promise<void>;
|
||||
createBacktest: (request: CreateBacktestRequest) => Promise<Backtest>;
|
||||
updateBacktest: (id: string, updates: Partial<CreateBacktestRequest>) => Promise<void>;
|
||||
deleteBacktest: (id: string) => Promise<void>;
|
||||
|
||||
createRun: (speedMultiplier?: number | null) => Promise<Run | undefined>;
|
||||
pauseRun: () => Promise<void>;
|
||||
resumeRun: () => Promise<void>;
|
||||
cancelRun: () => Promise<void>;
|
||||
updateRunSpeed: (speedMultiplier: number | null) => Promise<void>;
|
||||
selectRun: (runId: string | undefined) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useBacktestV2(): UseBacktestV2Return {
|
||||
const [backtest, setBacktest] = useState<Backtest | null>(null);
|
||||
const [runs, setRuns] = useState<Run[]>([]);
|
||||
const [currentRun, setCurrentRun] = useState<Run | null>(null);
|
||||
const [runResults, setRunResults] = useState<RunResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load a specific backtest and its runs
|
||||
const loadBacktest = useCallback(async (id: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [loadedBacktest, loadedRuns] = await Promise.all([
|
||||
backtestApiV2.getBacktest(id),
|
||||
backtestApiV2.listRuns(id)
|
||||
]);
|
||||
|
||||
setBacktest(loadedBacktest);
|
||||
setRuns(loadedRuns);
|
||||
|
||||
// If there are runs, select the most recent one
|
||||
if (loadedRuns.length > 0) {
|
||||
const latestRun = loadedRuns[0];
|
||||
setCurrentRun(latestRun);
|
||||
|
||||
if (latestRun.status === 'completed') {
|
||||
const results = await backtestApiV2.getRunResults(latestRun.id);
|
||||
setRunResults(results);
|
||||
} else if (latestRun.status === 'running' || latestRun.status === 'paused') {
|
||||
// Start monitoring the run
|
||||
startMonitoringRun(latestRun.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load backtest');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create a new backtest
|
||||
const createBacktest = useCallback(async (request: CreateBacktestRequest): Promise<Backtest> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newBacktest = await backtestApiV2.createBacktest(request);
|
||||
setBacktest(newBacktest);
|
||||
setRuns([]);
|
||||
setCurrentRun(null);
|
||||
setRunResults(null);
|
||||
return newBacktest;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create backtest');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update backtest configuration
|
||||
const updateBacktest = useCallback(async (id: string, updates: Partial<CreateBacktestRequest>) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const updatedBacktest = await backtestApiV2.updateBacktest(id, updates);
|
||||
setBacktest(updatedBacktest);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update backtest');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Delete backtest
|
||||
const deleteBacktest = useCallback(async (id: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await backtestApiV2.deleteBacktest(id);
|
||||
setBacktest(null);
|
||||
setRuns([]);
|
||||
setCurrentRun(null);
|
||||
setRunResults(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete backtest');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create a new run
|
||||
const createRun = useCallback(async (speedMultiplier?: number | null) => {
|
||||
if (!backtest) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setRunResults(null);
|
||||
|
||||
try {
|
||||
// Pass speedMultiplier as-is, null means max speed
|
||||
const newRun = await backtestApiV2.createRun(backtest.id, {
|
||||
speedMultiplier: speedMultiplier === undefined ? null : speedMultiplier
|
||||
});
|
||||
setRuns(prevRuns => [newRun, ...prevRuns]);
|
||||
setCurrentRun(newRun);
|
||||
|
||||
// Start monitoring the run
|
||||
startMonitoringRun(newRun.id);
|
||||
|
||||
return newRun; // Return the created run
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create run');
|
||||
throw err; // Re-throw so caller knows it failed
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [backtest]);
|
||||
|
||||
// Run control actions
|
||||
const pauseRun = useCallback(async () => {
|
||||
if (!currentRun || currentRun.status !== 'running') return;
|
||||
|
||||
try {
|
||||
await backtestApiV2.pauseRun(currentRun.id);
|
||||
setCurrentRun(prev => prev ? { ...prev, status: 'paused' } : null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to pause run');
|
||||
}
|
||||
}, [currentRun]);
|
||||
|
||||
const resumeRun = useCallback(async () => {
|
||||
if (!currentRun || currentRun.status !== 'paused') return;
|
||||
|
||||
try {
|
||||
await backtestApiV2.resumeRun(currentRun.id);
|
||||
setCurrentRun(prev => prev ? { ...prev, status: 'running' } : null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to resume run');
|
||||
}
|
||||
}, [currentRun]);
|
||||
|
||||
const cancelRun = useCallback(async () => {
|
||||
if (!currentRun || (currentRun.status !== 'running' && currentRun.status !== 'paused')) return;
|
||||
|
||||
try {
|
||||
await backtestApiV2.cancelRun(currentRun.id);
|
||||
setCurrentRun(prev => prev ? { ...prev, status: 'cancelled' } : null);
|
||||
stopMonitoringRun();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel run');
|
||||
}
|
||||
}, [currentRun]);
|
||||
|
||||
const updateRunSpeed = useCallback(async (speedMultiplier: number | null) => {
|
||||
if (!currentRun) return;
|
||||
|
||||
try {
|
||||
// Keep null as null for max speed (no limit)
|
||||
const apiSpeed = speedMultiplier;
|
||||
|
||||
// Only call API if run is active
|
||||
if (currentRun.status === 'running' || currentRun.status === 'paused') {
|
||||
await backtestApiV2.updateRunSpeed(currentRun.id, apiSpeed);
|
||||
}
|
||||
|
||||
// Always update local state so UI reflects the selection
|
||||
setCurrentRun(prev => prev ? { ...prev, speedMultiplier: apiSpeed } : null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update run speed');
|
||||
}
|
||||
}, [currentRun]);
|
||||
|
||||
// Select a specific run
|
||||
const selectRun = useCallback(async (runId: string | undefined) => {
|
||||
if (!runId) {
|
||||
setCurrentRun(null);
|
||||
setRunResults(null);
|
||||
stopMonitoringRun();
|
||||
return;
|
||||
}
|
||||
|
||||
const run = runs.find(r => r.id === runId);
|
||||
if (!run) return;
|
||||
|
||||
setCurrentRun(run);
|
||||
setRunResults(null);
|
||||
|
||||
if (run.status === 'completed') {
|
||||
try {
|
||||
const results = await backtestApiV2.getRunResults(run.id);
|
||||
setRunResults(results);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load run results');
|
||||
}
|
||||
} else if (run.status === 'running' || run.status === 'paused') {
|
||||
startMonitoringRun(run.id);
|
||||
}
|
||||
}, [runs]);
|
||||
|
||||
// Monitor run progress
|
||||
const startMonitoringRun = (runId: string) => {
|
||||
// Stop any existing monitoring
|
||||
stopMonitoringRun();
|
||||
|
||||
// WebSocket updates are handled by the useWebSocket hook in BacktestDetailPageV2
|
||||
// So we don't need polling here
|
||||
console.log('Run monitoring handled by WebSocket, skipping polling');
|
||||
};
|
||||
|
||||
const startPollingRun = (runId: string) => {
|
||||
pollingIntervalRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updatedRun = await backtestApiV2.getRun(runId);
|
||||
handleRunUpdate(updatedRun);
|
||||
|
||||
if (updatedRun.status === 'completed') {
|
||||
const results = await backtestApiV2.getRunResults(runId);
|
||||
setRunResults(results);
|
||||
stopMonitoringRun();
|
||||
} else if (updatedRun.status === 'failed' || updatedRun.status === 'cancelled') {
|
||||
stopMonitoringRun();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to poll run status:', err);
|
||||
}
|
||||
}, 1000); // Poll every second
|
||||
};
|
||||
|
||||
const stopMonitoringRun = () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunUpdate = (update: Run) => {
|
||||
setCurrentRun(update);
|
||||
setRuns(prevRuns =>
|
||||
prevRuns.map(run => run.id === update.id ? update : run)
|
||||
);
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopMonitoringRun();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backtest,
|
||||
runs,
|
||||
currentRun,
|
||||
runResults,
|
||||
isLoading,
|
||||
error,
|
||||
loadBacktest,
|
||||
createBacktest,
|
||||
updateBacktest,
|
||||
deleteBacktest,
|
||||
createRun,
|
||||
pauseRun,
|
||||
resumeRun,
|
||||
cancelRun,
|
||||
updateRunSpeed,
|
||||
selectRun,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export { BacktestPage } from './BacktestPage';
|
||||
export { BacktestListPage } from './BacktestListPage';
|
||||
export { BacktestDetailPage } from './BacktestDetailPage';
|
||||
export { BacktestListPageV2 } from './BacktestListPageV2';
|
||||
export { BacktestDetailPageV2 } from './BacktestDetailPageV2';
|
||||
export * from './types';
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:2003';
|
||||
|
||||
export interface Backtest {
|
||||
id: string;
|
||||
name: string;
|
||||
strategy: string;
|
||||
symbols: string[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
initialCapital: number;
|
||||
config: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Run {
|
||||
id: string;
|
||||
backtestId: string;
|
||||
runNumber: number;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused';
|
||||
speedMultiplier: number;
|
||||
error?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
pausedAt?: string;
|
||||
progress: number;
|
||||
currentSimulationDate?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateBacktestRequest {
|
||||
name: string;
|
||||
strategy: string;
|
||||
symbols: string[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
initialCapital: number;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CreateRunRequest {
|
||||
speedMultiplier?: number | null;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
runId: string;
|
||||
backtestId: string;
|
||||
status: 'completed';
|
||||
completedAt: string;
|
||||
config: {
|
||||
name: string;
|
||||
strategy: string;
|
||||
symbols: string[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
initialCapital: number;
|
||||
commission: number;
|
||||
slippage: number;
|
||||
dataFrequency: string;
|
||||
};
|
||||
metrics: any;
|
||||
equity: any[];
|
||||
ohlcData: Record<string, any[]>;
|
||||
trades: any[];
|
||||
positions: any;
|
||||
analytics: any;
|
||||
executionTime: number;
|
||||
}
|
||||
|
||||
export const backtestApiV2 = {
|
||||
// Backtest operations
|
||||
async createBacktest(request: CreateBacktestRequest): Promise<Backtest> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/backtests`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create backtest: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async getBacktest(id: string): Promise<Backtest> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/backtests/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get backtest: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async listBacktests(limit = 50, offset = 0): Promise<Backtest[]> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/v2/backtests?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list backtests: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async updateBacktest(id: string, updates: Partial<CreateBacktestRequest>): Promise<Backtest> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/backtests/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update backtest: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async deleteBacktest(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/backtests/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete backtest: ${response.statusText}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Run operations
|
||||
async createRun(backtestId: string, request?: CreateRunRequest): Promise<Run> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/backtests/${backtestId}/runs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request || {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create run: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async listRuns(backtestId: string): Promise<Run[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/backtests/${backtestId}/runs`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list runs: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async getRun(id: string): Promise<Run> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/runs/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get run: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async getRunResults(runId: string): Promise<RunResult> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/runs/${runId}/results`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get run results: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async pauseRun(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/runs/${id}/pause`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to pause run: ${response.statusText}`);
|
||||
}
|
||||
},
|
||||
|
||||
async resumeRun(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/runs/${id}/resume`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resume run: ${response.statusText}`);
|
||||
}
|
||||
},
|
||||
|
||||
async cancelRun(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/runs/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to cancel run: ${response.statusText}`);
|
||||
}
|
||||
},
|
||||
|
||||
async updateRunSpeed(id: string, speedMultiplier: number): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v2/runs/${id}/speed`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ speedMultiplier }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update run speed: ${response.statusText}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Note: WebSocket connections are handled by the useWebSocket hook
|
||||
// which connects to ws://localhost:2003/ws?runId={runId}
|
||||
};
|
||||
195
apps/stock/web-app/src/hooks/useWebSocket.ts
Normal file
195
apps/stock/web-app/src/hooks/useWebSocket.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: 'connected' | 'run_update' | 'progress' | 'error' | 'completed' | 'pong';
|
||||
runId?: string;
|
||||
data?: any;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
runId: string | null;
|
||||
onMessage?: (message: WebSocketMessage) => void;
|
||||
onProgress?: (progress: number, currentDate?: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
onCompleted?: (results?: any) => void;
|
||||
}
|
||||
|
||||
export function useWebSocket({
|
||||
runId,
|
||||
onMessage,
|
||||
onProgress,
|
||||
onError,
|
||||
onCompleted
|
||||
}: UseWebSocketOptions) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isIntentionalDisconnect = useRef(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!runId) {
|
||||
console.log('useWebSocket: No runId provided, skipping connection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already connected or connecting
|
||||
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
|
||||
console.log('useWebSocket: Already connected or connecting to runId:', runId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset intentional disconnect flag
|
||||
isIntentionalDisconnect.current = false;
|
||||
|
||||
// Clear any pending reconnect timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const wsUrl = `ws://localhost:2003/ws?runId=${runId}`;
|
||||
console.log('Connecting to WebSocket:', wsUrl);
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
setIsConnected(true);
|
||||
|
||||
// Start ping interval
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000); // Ping every 30 seconds
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
console.log('WebSocket message:', message);
|
||||
setLastMessage(message);
|
||||
|
||||
// Call appropriate callbacks
|
||||
if (onMessage) {
|
||||
onMessage(message);
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'progress':
|
||||
if (onProgress && message.data) {
|
||||
onProgress(message.data.progress, message.data.currentDate);
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
if (onError && message.data?.error) {
|
||||
onError(message.data.error);
|
||||
console.error('Run Error:', message.data.error);
|
||||
}
|
||||
break;
|
||||
case 'completed':
|
||||
if (onCompleted) {
|
||||
onCompleted(message.data?.results);
|
||||
}
|
||||
console.log('Run Completed: The backtest run has completed successfully.');
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setIsConnected(false);
|
||||
wsRef.current = null;
|
||||
|
||||
// Clear ping interval
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Attempt to reconnect after 3 seconds
|
||||
// Only reconnect if not intentionally disconnected and still the same WebSocket
|
||||
if (runId && wsRef.current === ws && !isIntentionalDisconnect.current) {
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
console.log('Attempting to reconnect...');
|
||||
connect();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error creating WebSocket:', error);
|
||||
setIsConnected(false);
|
||||
}
|
||||
}, [runId, onMessage, onProgress, onError, onCompleted]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
console.log('Disconnecting WebSocket...');
|
||||
|
||||
// Set flag to prevent automatic reconnection
|
||||
isIntentionalDisconnect.current = true;
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
// Set a flag to prevent reconnection
|
||||
const ws = wsRef.current;
|
||||
wsRef.current = null;
|
||||
ws.close();
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback((message: any) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('WebSocket is not connected');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Small delay to prevent rapid reconnections during React's render cycles
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (runId) {
|
||||
connect();
|
||||
} else {
|
||||
disconnect();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
disconnect();
|
||||
};
|
||||
}, [runId]); // Only depend on runId, not the functions
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
lastMessage,
|
||||
sendMessage,
|
||||
disconnect,
|
||||
connect
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue