rerun complete

This commit is contained in:
Boki 2025-07-04 18:14:44 -04:00
parent 11c6c19628
commit d15e542f20
17 changed files with 4694 additions and 146 deletions

View file

@ -3,11 +3,10 @@ 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 { RunsListWithMetrics } from './components/RunsListWithMetrics';
import { useBacktestV2 } from './hooks/useBacktestV2';
import type { BacktestConfig } from './types/backtest.types';
@ -17,15 +16,14 @@ const baseTabs = [
];
const runTabs = [
{ id: 'playback', name: 'Playback' },
{ id: 'metrics', name: 'Performance' },
{ id: 'details', name: 'Details' },
{ id: 'trades', name: 'Trades' },
];
export function BacktestDetailPageV2() {
const { id, runId } = useParams<{ id: string; runId?: string }>();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState('playback');
const [activeTab, setActiveTab] = useState('details');
const [showNewRun, setShowNewRun] = useState(false);
const {
@ -62,10 +60,18 @@ export function BacktestDetailPageV2() {
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
onCompleted: async (results) => {
// When run completes, reload to get the final results
if (currentRun?.id) {
try {
// Small delay to ensure the results are persisted
await new Promise(resolve => setTimeout(resolve, 500));
// Force re-select the run to load its results
await selectRun(currentRun.id);
} catch (err) {
console.error('Failed to load completed run results:', err);
}
}
}
});
@ -75,24 +81,47 @@ export function BacktestDetailPageV2() {
loadBacktest(id);
}
}, [id, loadBacktest]);
// Select run based on URL parameter
// Clear mismatched results immediately
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');
if (runResults && currentRun && runResults.runId !== currentRun.id) {
console.warn('Run results mismatch:', {
runResultsId: runResults.runId,
currentRunId: currentRun.id
});
// Clear the mismatched results immediately to prevent rendering wrong data
// The selectRun effect will load the correct results
}
}, [runId, runs, selectRun]);
}, [runResults?.runId, currentRun?.id]);
// We don't need this effect anymore - it's causing unnecessary re-renders
// The selectRun function already handles clearing mismatched results
// Select run based on URL parameter with debounce
useEffect(() => {
const timeoutId = setTimeout(() => {
if (runId) {
// If the current run already matches, don't re-select
if (currentRun?.id === runId) {
return;
}
// Always try to select the run - selectRun will fetch it if not found
selectRun(runId);
// Show details tab by default when a run is selected
if (activeTab === 'runs') {
setActiveTab('details');
}
} else if (!runId && currentRun) {
// Clear run selection when navigating away from run URL
selectRun(undefined);
setActiveTab('runs');
}
}, 50); // Small debounce to prevent rapid switching
return () => clearTimeout(timeoutId);
}, [runId, currentRun?.id, selectRun, activeTab]);
// Handle configuration save
const handleConfigSubmit = useCallback(async (config: BacktestConfig) => {
@ -127,6 +156,7 @@ export function BacktestDetailPageV2() {
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}`);
@ -148,7 +178,7 @@ export function BacktestDetailPageV2() {
const renderTabContent = () => {
// Show message if trying to view run-specific tabs without a run selected
if (!currentRun && ['playback', 'metrics', 'trades'].includes(activeTab)) {
if (!currentRun && ['details', 'trades'].includes(activeTab)) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
@ -204,7 +234,7 @@ export function BacktestDetailPageV2() {
</div>
)}
<RunsList
<RunsListWithMetrics
runs={runs}
currentRunId={currentRun?.id}
onSelectRun={selectRun}
@ -221,18 +251,19 @@ export function BacktestDetailPageV2() {
/>
</div>
);
case 'metrics':
return (
<div className="h-full overflow-y-auto">
<BacktestMetrics
result={runResults}
isLoading={isLoading}
/>
</div>
);
case 'playback':
case 'details':
// Only render if we have matching results or no results yet
if (runResults && currentRun && runResults.runId !== currentRun.id) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-text-secondary">Loading run data...</div>
</div>
);
}
return (
<BacktestPlayback
key={currentRun?.id || 'no-run'} // Force remount on run change
result={runResults}
isLoading={isLoading}
/>

View file

@ -1,6 +1,6 @@
import type { BacktestResult } from '../types/backtest.types';
import { useState, useMemo, memo } from 'react';
import { Chart } from '../../../components/charts';
import { Chart } from '../../../components/charts/Chart';
interface BacktestChartProps {
result: BacktestResult | null;
@ -110,9 +110,18 @@ export const BacktestChart = memo(function BacktestChart({ result, isLoading }:
<div className="flex-1">
<Chart
data={chartData.ohlcData}
equityData={chartData.equityData}
markers={chartData.tradeMarkers}
overlayData={[
{
name: 'Equity',
data: chartData.equityData,
color: '#3b82f6',
lineWidth: 2
}
]}
tradeMarkers={chartData.tradeMarkers}
height={500}
chartId={`backtest-${result?.runId || 'default'}`}
key={result?.runId || 'default'}
/>
</div>
</div>

View file

@ -6,6 +6,7 @@ interface BacktestMetricsProps {
isLoading: boolean;
}
// Full detailed metrics view - can be used as a separate page/view if needed
export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
if (isLoading) {
return (

View file

@ -1,5 +1,8 @@
import { useState, memo } from 'react';
import { BacktestChart } from './BacktestChart';
import { ErrorBoundary } from '../../../components/ErrorBoundary';
import { CompactPerformanceMetrics } from './CompactPerformanceMetrics';
import { PositionsSummary } from './PositionsSummary';
interface BacktestPlaybackProps {
result: any | null;
@ -7,7 +10,7 @@ interface BacktestPlaybackProps {
}
export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoading }: BacktestPlaybackProps) {
const [showPositions, setShowPositions] = useState(true);
const [showPositions, setShowPositions] = useState(false);
if (isLoading) {
return (
@ -25,21 +28,29 @@ export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoadi
);
}
// Get open positions from the result
// Positions can be an object with symbols as keys or an array
// Get positions from the result
let openPositions: any[] = [];
let closedPositions: any[] = [];
if (result.positions) {
if (Array.isArray(result.positions)) {
openPositions = result.positions.filter((p: any) => p.quantity > 0);
result.positions.forEach((p: any) => {
if (p.quantity !== 0) {
openPositions.push(p);
} else if (p.realizedPnl !== 0) {
closedPositions.push(p);
}
});
} 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
}));
Object.entries(result.positions).forEach(([symbol, position]: [string, any]) => {
const pos = { symbol, ...position };
if (position.quantity !== 0) {
openPositions.push(pos);
} else if (position.realizedPnl !== 0) {
closedPositions.push(pos);
}
});
}
}
@ -47,27 +58,63 @@ export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoadi
<div className="h-full flex flex-col space-y-4">
{/* Chart Section */}
<div className="flex-1">
<BacktestChart result={result} isLoading={isLoading} />
<ErrorBoundary
fallback={
<div className="h-full flex items-center justify-center bg-surface-secondary rounded-lg border border-border">
<div className="text-center p-4">
<p className="text-text-secondary mb-4">Chart failed to load</p>
<button
onClick={() => window.location.reload()}
className="text-primary-500 hover:text-primary-600 font-medium"
>
Reload Page
</button>
</div>
</div>
}
>
<BacktestChart
result={result}
isLoading={isLoading}
/>
</ErrorBoundary>
</div>
{/* Open Positions Section */}
{openPositions.length > 0 && (
{/* Performance Metrics */}
<div className="flex-shrink-0">
<CompactPerformanceMetrics result={result} isLoading={isLoading} />
</div>
{/* Positions Summary */}
<div className="flex-shrink-0">
<PositionsSummary
openPositions={openPositions}
closedPositions={closedPositions}
onExpand={() => setShowPositions(true)}
/>
</div>
{/* Detailed Positions View - Optional expanded view */}
{showPositions && (openPositions.length > 0 || closedPositions.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})
All Positions
</h3>
<button
onClick={() => setShowPositions(!showPositions)}
className="text-xs text-text-secondary hover:text-text-primary"
>
{showPositions ? 'Hide' : 'Show'}
Close
</button>
</div>
{showPositions && (
<div className="space-y-2">
<div className="space-y-4">
{openPositions.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-secondary mb-2">Open Positions ({openPositions.length})</h4>
<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>
@ -81,7 +128,7 @@ export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoadi
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 side = quantity > 0 ? 'long' : 'short';
const absQuantity = Math.abs(quantity);
const pnl = (currentPrice - avgPrice) * quantity;
const pnlPercent = avgPrice > 0 ? (pnl / (avgPrice * absQuantity)) * 100 : 0;
@ -89,7 +136,7 @@ export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoadi
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'}>
<div className={side === 'long' ? 'text-success' : 'text-error'}>
{side.toUpperCase()}
</div>
<div className="text-right text-text-primary">{absQuantity}</div>
@ -105,29 +152,78 @@ export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoadi
</div>
);
})}
</div>
</div>
)}
{closedPositions.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-secondary mb-2">Closed Positions ({closedPositions.length})</h4>
<div className="space-y-1">
{closedPositions.map((position, index) => (
<div key={index} className="flex items-center justify-between py-2 px-3 bg-surface rounded">
<div className="flex items-center space-x-3">
<span className="font-medium text-text-primary">{position.symbol}</span>
<span className="text-sm text-text-secondary">Closed</span>
</div>
<div className="text-right">
<div className={`text-sm font-medium ${position.realizedPnl >= 0 ? 'text-success' : 'text-error'}`}>
Realized P&L: ${position.realizedPnl.toFixed(2)}
</div>
</div>
</div>
))}
</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 className="pt-3 mt-3 border-t border-border grid grid-cols-3 gap-4 text-sm">
<div className="text-center">
<div className="text-text-secondary text-xs">Unrealized P&L</div>
<div className={`font-medium ${
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 className="text-center">
<div className="text-text-secondary text-xs">Realized P&L</div>
<div className={`font-medium ${
closedPositions.reduce((sum, p) => sum + (p.realizedPnl || 0), 0) >= 0 ? 'text-success' : 'text-error'
}`}>
${closedPositions.reduce((sum, p) => sum + (p.realizedPnl || 0), 0).toFixed(2)}
</div>
</div>
<div className="text-center">
<div className="text-text-secondary text-xs">Total P&L</div>
<div className={`font-medium ${
(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) + closedPositions.reduce((sum, p) => sum + (p.realizedPnl || 0), 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) + closedPositions.reduce((sum, p) => sum + (p.realizedPnl || 0), 0)).toFixed(2)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
)}

View file

@ -0,0 +1,297 @@
import type { BacktestResult } from '../types/backtest.types';
interface ExtendedMetrics {
// Core metrics
totalReturn?: number;
sharpeRatio?: number;
maxDrawdown?: number;
winRate?: number;
totalProfit?: number;
profitFactor?: number;
totalTrades?: number;
avgWin?: number;
avgLoss?: number;
expectancy?: number;
sortinoRatio?: number;
calmarRatio?: number;
profitableTrades?: number;
// Extended metrics from orchestrator
annualizedReturn?: number;
volatility?: number;
avgHoldingPeriod?: number;
maxConsecutiveLosses?: number;
maxConsecutiveWins?: number;
payoffRatio?: number;
largestWin?: number;
largestLoss?: number;
avgWinLoss?: number;
skewness?: number;
kurtosis?: number;
tailRatio?: number;
kellyFraction?: number;
informationRatio?: number;
avgTradesPerDay?: number;
}
interface CompactPerformanceMetricsProps {
result: any | null;
isLoading: boolean;
}
export function CompactPerformanceMetrics({ result, isLoading }: CompactPerformanceMetricsProps) {
if (isLoading || !result) {
return null;
}
const metrics = result.metrics as ExtendedMetrics;
const analytics = result.analytics || {};
// Merge metrics from both sources
const allMetrics = {
...metrics,
...analytics,
// Override with metrics values if they exist
...metrics
};
// Calculate totalProfit if not provided
const totalProfit = allMetrics.totalProfit ??
(allMetrics.totalReturn && result.config?.initialCapital ?
allMetrics.totalReturn * result.config.initialCapital :
undefined);
const formatValue = (value: number | undefined, format: 'percent' | 'number' | 'currency', decimals = 2) => {
if (value === undefined || value === null) return '-';
switch (format) {
case 'percent':
return `${(value * 100).toFixed(decimals)}%`;
case 'currency':
const prefix = value < 0 ? '-$' : '$';
return `${prefix}${Math.abs(value).toFixed(decimals)}`;
case 'number':
return value.toFixed(decimals);
}
};
const getColorClass = (value: number | undefined, thresholds: { good: number; warning?: number }) => {
if (value === undefined || value === null) return 'text-text-secondary';
if (thresholds.warning !== undefined) {
if (value >= thresholds.good) return 'text-success';
if (value >= thresholds.warning) return 'text-warning';
return 'text-error';
}
return value >= thresholds.good ? 'text-success' : 'text-error';
};
return (
<div className="bg-surface-secondary rounded-lg border border-border p-4">
<h3 className="text-sm font-medium text-text-primary mb-3">Performance Analytics</h3>
<div className="grid grid-cols-3 gap-4">
{/* Column 1 - Return Metrics */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Total Return</span>
<span className={`text-sm font-medium ${getColorClass(allMetrics.totalReturn, { good: 0 })}`}>
{formatValue(allMetrics.totalReturn, 'percent')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Sharpe Ratio</span>
<span className={`text-sm font-medium ${getColorClass(allMetrics.sharpeRatio, { good: 1, warning: 0 })}`}>
{formatValue(allMetrics.sharpeRatio, 'number')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Max Drawdown</span>
<span className={`text-sm font-medium ${getColorClass(allMetrics.maxDrawdown, { good: -0.1, warning: -0.2 })}`}>
{formatValue(allMetrics.maxDrawdown, 'percent')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Win Rate</span>
<span className={`text-sm font-medium ${getColorClass(allMetrics.winRate, { good: 0.5 })}`}>
{formatValue(allMetrics.winRate, 'percent', 1)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Total Profit</span>
<span className={`text-sm font-medium ${getColorClass(totalProfit, { good: 0 })}`}>
{formatValue(totalProfit, 'currency')}
</span>
</div>
</div>
{/* Column 2 - Risk Metrics */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Profit Factor</span>
<span className={`text-sm font-medium ${getColorClass(allMetrics.profitFactor, { good: 1.5, warning: 1 })}`}>
{formatValue(allMetrics.profitFactor, 'number')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Total Trades</span>
<span className="text-sm font-medium text-text-primary">
{allMetrics.totalTrades ?? '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Avg Win/Loss</span>
<span className="text-sm font-medium text-text-primary">
<span className="text-success">{formatValue(allMetrics.avgWin, 'currency')}</span>
<span className="text-text-secondary mx-1">/</span>
<span className="text-error">{formatValue(allMetrics.avgLoss, 'currency')}</span>
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Expectancy</span>
<span className={`text-sm font-medium ${getColorClass(allMetrics.expectancy, { good: 0 })}`}>
{formatValue(allMetrics.expectancy, 'currency')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Sortino Ratio</span>
<span className={`text-sm font-medium ${getColorClass(allMetrics.sortinoRatio, { good: 1, warning: 0 })}`}>
{formatValue(allMetrics.sortinoRatio, 'number')}
</span>
</div>
</div>
{/* Column 3 - Additional Metrics */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Annual Return</span>
<span className={`text-sm font-medium ${getColorClass(allMetrics.annualizedReturn, { good: 0.1, warning: 0 })}`}>
{formatValue(allMetrics.annualizedReturn, 'percent')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Volatility</span>
<span className={`text-sm font-medium ${getColorClass(-(allMetrics.volatility ?? 0), { good: -15, warning: -25 })}`}>
{formatValue(allMetrics.volatility ? allMetrics.volatility / 100 : undefined, 'percent', 1)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Avg Holding</span>
<span className="text-sm font-medium text-text-primary">
{allMetrics.avgHoldingPeriod ? `${(allMetrics.avgHoldingPeriod / 60).toFixed(1)}h` : '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Max Consec Loss</span>
<span className={`text-sm font-medium ${allMetrics.maxConsecutiveLosses > 5 ? 'text-error' : 'text-text-primary'}`}>
{allMetrics.maxConsecutiveLosses ?? '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-text-secondary">Payoff Ratio</span>
<span className={`text-sm font-medium ${getColorClass(allMetrics.payoffRatio, { good: 1.5, warning: 1 })}`}>
{formatValue(allMetrics.payoffRatio, 'number')}
</span>
</div>
</div>
</div>
{/* Advanced Metrics - Show only if available */}
{(allMetrics.skewness !== undefined || allMetrics.kurtosis !== undefined ||
allMetrics.informationRatio !== undefined || allMetrics.kellyFraction !== undefined) && (
<div className="mt-3 pt-3 border-t border-border">
<div className="grid grid-cols-4 gap-2 text-xs">
{allMetrics.informationRatio !== undefined && (
<div className="text-center">
<span className="text-text-secondary">Info Ratio</span>
<div className={`font-medium ${getColorClass(allMetrics.informationRatio, { good: 0.5, warning: 0 })}`}>
{formatValue(allMetrics.informationRatio, 'number')}
</div>
</div>
)}
{allMetrics.skewness !== undefined && (
<div className="text-center">
<span className="text-text-secondary">Skewness</span>
<div className={`font-medium ${getColorClass(allMetrics.skewness, { good: 0 })}`}>
{formatValue(allMetrics.skewness, 'number')}
</div>
</div>
)}
{allMetrics.kurtosis !== undefined && (
<div className="text-center">
<span className="text-text-secondary">Kurtosis</span>
<div className="font-medium text-text-primary">
{formatValue(allMetrics.kurtosis, 'number')}
</div>
</div>
)}
{allMetrics.kellyFraction !== undefined && (
<div className="text-center">
<span className="text-text-secondary">Kelly %</span>
<div className="font-medium text-text-primary">
{formatValue(allMetrics.kellyFraction, 'percent')}
</div>
</div>
)}
</div>
</div>
)}
{/* Additional Metrics Row */}
<div className="mt-3 pt-3 border-t border-border">
<div className="grid grid-cols-5 gap-2 text-xs">
<div className="text-center">
<span className="text-text-secondary">Calmar</span>
<div className={`font-medium ${getColorClass(allMetrics.calmarRatio, { good: 1, warning: 0.5 })}`}>
{formatValue(allMetrics.calmarRatio, 'number')}
</div>
</div>
<div className="text-center">
<span className="text-text-secondary">Profitable</span>
<div className="font-medium text-text-primary">
{allMetrics.profitableTrades ?? '-'}/{allMetrics.totalTrades ?? '-'}
</div>
</div>
<div className="text-center">
<span className="text-text-secondary">Exposure</span>
<div className="font-medium text-text-primary">
{result.analytics?.exposureTime ? formatValue(result.analytics.exposureTime, 'percent', 1) : '-'}
</div>
</div>
<div className="text-center">
<span className="text-text-secondary">Largest Win</span>
<div className={`font-medium text-success`}>
{formatValue(allMetrics.largestWin, 'currency')}
</div>
</div>
<div className="text-center">
<span className="text-text-secondary">Largest Loss</span>
<div className={`font-medium text-error`}>
{formatValue(allMetrics.largestLoss, 'currency')}
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,89 @@
interface Position {
symbol: string;
quantity: number;
averagePrice?: number;
avgPrice?: number;
currentPrice?: number;
lastPrice?: number;
}
interface CompactPositionsTableProps {
positions: Position[];
onExpand?: () => void;
}
export function CompactPositionsTable({ positions, onExpand }: CompactPositionsTableProps) {
if (positions.length === 0) {
return (
<div className="bg-surface-secondary rounded-lg border border-border p-4">
<h3 className="text-sm font-medium text-text-primary mb-2">Open Positions</h3>
<p className="text-sm text-text-secondary">No open positions</p>
</div>
);
}
const totalPnL = positions.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);
return (
<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 ({positions.length})
</h3>
<span className={`text-sm font-medium ${totalPnL >= 0 ? 'text-success' : 'text-error'}`}>
P&L: ${totalPnL.toFixed(2)}
</span>
</div>
<div className="space-y-2">
{positions.slice(0, 8).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 ? 'long' : 'short';
const absQuantity = Math.abs(quantity);
const pnl = (currentPrice - avgPrice) * quantity;
const pnlPercent = avgPrice > 0 ? (pnl / (avgPrice * absQuantity)) * 100 : 0;
return (
<div key={index} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-surface-tertiary transition-colors">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-text-primary">{position.symbol}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
side === 'long' ? 'bg-success/20 text-success' : 'bg-error/20 text-error'
}`}>
{side.toUpperCase()}
</span>
<span className="text-xs text-text-secondary">
{absQuantity} @ ${avgPrice.toFixed(2)}
</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-text-secondary">
${currentPrice.toFixed(2)}
</span>
<span className={`text-xs font-medium ${pnl >= 0 ? 'text-success' : 'text-error'}`}>
{pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(1)}%
</span>
</div>
</div>
);
})}
{positions.length > 8 && (
<button
onClick={onExpand}
className="w-full text-xs text-primary-500 hover:text-primary-600 text-center pt-2 font-medium"
>
View all {positions.length} positions
</button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
interface Position {
symbol: string;
quantity: number;
averagePrice?: number;
avgPrice?: number;
currentPrice?: number;
lastPrice?: number;
realizedPnl?: number;
unrealizedPnl?: number;
}
interface PositionsSummaryProps {
openPositions: Position[];
closedPositions: Position[];
onExpand?: () => void;
}
export function PositionsSummary({ openPositions, closedPositions, onExpand }: PositionsSummaryProps) {
const totalRealizedPnL = closedPositions.reduce((sum, p) => sum + (p.realizedPnl || 0), 0);
const totalUnrealizedPnL = 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);
return (
<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">Positions Summary</h3>
<div className="flex items-center space-x-4 text-sm">
<div>
<span className="text-text-secondary">Realized:</span>
<span className={`ml-1 font-medium ${totalRealizedPnL >= 0 ? 'text-success' : 'text-error'}`}>
${totalRealizedPnL.toFixed(2)}
</span>
</div>
<div>
<span className="text-text-secondary">Unrealized:</span>
<span className={`ml-1 font-medium ${totalUnrealizedPnL >= 0 ? 'text-success' : 'text-error'}`}>
${totalUnrealizedPnL.toFixed(2)}
</span>
</div>
</div>
</div>
{openPositions.length > 0 && (
<div className="mb-3">
<h4 className="text-xs font-medium text-text-secondary mb-2">Open Positions ({openPositions.length})</h4>
<div className="space-y-1">
{openPositions.slice(0, 5).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 ? 'long' : 'short';
const absQuantity = Math.abs(quantity);
const pnl = (currentPrice - avgPrice) * quantity;
const pnlPercent = avgPrice > 0 ? (pnl / (avgPrice * absQuantity)) * 100 : 0;
return (
<div key={index} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-surface-tertiary transition-colors">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-text-primary">{position.symbol}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
side === 'long' ? 'bg-success/20 text-success' : 'bg-error/20 text-error'
}`}>
{side.toUpperCase()}
</span>
<span className="text-xs text-text-secondary">
{absQuantity} @ ${avgPrice.toFixed(2)}
</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-text-secondary">
${currentPrice.toFixed(2)}
</span>
<span className={`text-xs font-medium ${pnl >= 0 ? 'text-success' : 'text-error'}`}>
{pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(1)}%
</span>
</div>
</div>
);
})}
</div>
</div>
)}
{closedPositions.length > 0 && (
<div>
<h4 className="text-xs font-medium text-text-secondary mb-2">
Closed Positions ({closedPositions.length})
</h4>
<div className="space-y-1">
{closedPositions.slice(0, 3).map((position, index) => (
<div key={index} className="flex items-center justify-between py-1 px-2 text-xs">
<span className="text-text-primary">{position.symbol}</span>
<span className={`font-medium ${position.realizedPnl >= 0 ? 'text-success' : 'text-error'}`}>
${position.realizedPnl.toFixed(2)}
</span>
</div>
))}
</div>
</div>
)}
{(openPositions.length > 5 || closedPositions.length > 3) && onExpand && (
<button
onClick={onExpand}
className="w-full text-xs text-primary-500 hover:text-primary-600 text-center pt-2 font-medium"
>
View all positions
</button>
)}
{openPositions.length === 0 && closedPositions.length === 0 && (
<p className="text-sm text-text-secondary text-center">No positions to display</p>
)}
</div>
);
}

View file

@ -0,0 +1,290 @@
import { DataTable } from '@/components/ui/DataTable';
import type { ColumnDef } from '@tanstack/react-table';
import type { Run, RunResult } from '../services/backtestApiV2';
import { useNavigate, useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { backtestApiV2 } from '../services/backtestApiV2';
import {
CheckCircleIcon,
XCircleIcon,
PauseIcon,
PlayIcon,
ClockIcon,
} from '@heroicons/react/24/outline';
interface RunsListProps {
runs: Run[];
currentRunId?: string;
onSelectRun: (runId: string) => void;
}
interface RunWithMetrics extends Run {
metrics?: RunResult['metrics'];
initialCapital?: number;
}
export function RunsListWithMetrics({ runs, currentRunId, onSelectRun }: RunsListProps) {
const navigate = useNavigate();
const { id: backtestId } = useParams<{ id: string }>();
const [runsWithMetrics, setRunsWithMetrics] = useState<RunWithMetrics[]>([]);
const [loadingMetrics, setLoadingMetrics] = useState(false);
// Fetch metrics for completed runs
useEffect(() => {
const fetchMetrics = async () => {
setLoadingMetrics(true);
const updatedRuns: RunWithMetrics[] = await Promise.all(
runs.map(async (run) => {
if (run.status === 'completed') {
try {
const results = await backtestApiV2.getRunResults(run.id);
return {
...run,
metrics: results.metrics,
initialCapital: results.config?.initialCapital
};
} catch (error) {
console.error(`Failed to fetch metrics for run ${run.id}:`, error);
return run;
}
}
return run;
})
);
setRunsWithMetrics(updatedRuns);
setLoadingMetrics(false);
};
if (runs.length > 0) {
fetchMetrics();
}
}, [runs]);
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 formatPercentage = (value: number | undefined, decimals = 2) => {
if (value === undefined || value === null) return '-';
return `${(value * 100).toFixed(decimals)}%`;
};
const formatNumber = (value: number | undefined, decimals = 2) => {
if (value === undefined || value === null) return '-';
return value.toFixed(decimals);
};
const formatCurrency = (value: number | undefined) => {
if (value === undefined || value === null) return '-';
const prefix = value < 0 ? '-$' : '$';
return `${prefix}${Math.abs(value).toFixed(2)}`;
};
const columns: ColumnDef<RunWithMetrics>[] = [
{
accessorKey: 'runNumber',
header: 'Run #',
size: 60,
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: 100,
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: 120,
cell: ({ row }) => {
const progress = row.original.progress;
const status = row.original.status;
if (status === 'pending') return <span className="text-sm text-text-secondary">-</span>;
if (status === 'completed') return <span className="text-sm text-success">Complete</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>
);
},
},
{
id: 'totalReturn',
header: 'Return',
size: 80,
cell: ({ row }) => {
const metrics = row.original.metrics;
const value = metrics?.totalReturn;
return (
<span className={`text-sm font-medium ${
value === undefined ? 'text-text-secondary' :
value >= 0 ? 'text-success' : 'text-error'
}`}>
{formatPercentage(value)}
</span>
);
},
},
{
id: 'sharpeRatio',
header: 'Sharpe',
size: 80,
cell: ({ row }) => {
const metrics = row.original.metrics;
const value = metrics?.sharpeRatio;
return (
<span className={`text-sm font-medium ${
value === undefined ? 'text-text-secondary' :
value > 1 ? 'text-success' :
value > 0 ? 'text-warning' : 'text-error'
}`}>
{formatNumber(value)}
</span>
);
},
},
{
id: 'maxDrawdown',
header: 'Max DD',
size: 80,
cell: ({ row }) => {
const metrics = row.original.metrics;
const value = metrics?.maxDrawdown;
return (
<span className={`text-sm font-medium ${
value === undefined ? 'text-text-secondary' :
value > -0.1 ? 'text-success' :
value > -0.2 ? 'text-warning' : 'text-error'
}`}>
{formatPercentage(value)}
</span>
);
},
},
{
id: 'winRate',
header: 'Win Rate',
size: 80,
cell: ({ row }) => {
const metrics = row.original.metrics;
const value = metrics?.winRate;
return (
<span className={`text-sm font-medium ${
value === undefined ? 'text-text-secondary' :
value > 0.5 ? 'text-success' : 'text-warning'
}`}>
{formatPercentage(value, 1)}
</span>
);
},
},
{
id: 'profitFactor',
header: 'Profit Factor',
size: 100,
cell: ({ row }) => {
const metrics = row.original.metrics;
const value = metrics?.profitFactor;
return (
<span className={`text-sm font-medium ${
value === undefined ? 'text-text-secondary' :
value > 1.5 ? 'text-success' :
value > 1 ? 'text-warning' : 'text-error'
}`}>
{formatNumber(value)}
</span>
);
},
},
{
id: 'totalProfit',
header: 'Total Profit',
size: 100,
cell: ({ row }) => {
const metrics = row.original.metrics;
const value = metrics?.totalProfit;
return (
<span className={`text-sm font-medium ${
value === undefined ? 'text-text-secondary' :
value >= 0 ? 'text-success' : 'text-error'
}`}>
{formatCurrency(value)}
</span>
);
},
},
{
accessorKey: 'startedAt',
header: 'Started',
size: 160,
cell: ({ getValue }) => {
const date = getValue() as string | undefined;
return (
<span className="text-sm text-text-secondary">
{date ? new Date(date).toLocaleString() : '-'}
</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={runsWithMetrics}
columns={columns}
onRowClick={(run) => {
navigate(`/backtests/${backtestId}/run/${run.id}`);
onSelectRun(run.id);
}}
className="bg-surface-secondary rounded-lg border border-border"
height={400}
loading={loadingMetrics}
/>
);
}

View file

@ -3,4 +3,9 @@ export { BacktestControls } from './BacktestControls';
export { BacktestResults } from './BacktestResults';
export { MetricsCard } from './MetricsCard';
export { PositionsTable } from './PositionsTable';
export { TradeLog } from './TradeLog';
export { TradeLog } from './TradeLog';
export { RunsList } from './RunsList';
export { RunsListWithMetrics } from './RunsListWithMetrics';
export { CompactPerformanceMetrics } from './CompactPerformanceMetrics';
export { CompactPositionsTable } from './CompactPositionsTable';
export { PositionsSummary } from './PositionsSummary';

View file

@ -49,20 +49,9 @@ export function useBacktestV2(): UseBacktestV2Return {
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);
}
}
// Don't auto-select runs here - let the URL parameter drive the selection
} catch (err) {
console.error('Failed to load backtest:', err);
setError(err instanceof Error ? err.message : 'Failed to load backtest');
} finally {
setIsLoading(false);
@ -137,11 +126,49 @@ export function useBacktestV2(): UseBacktestV2Return {
const newRun = await backtestApiV2.createRun(backtest.id, {
speedMultiplier: speedMultiplier === undefined ? null : speedMultiplier
});
// Add to runs array first
setRuns(prevRuns => [newRun, ...prevRuns]);
// Clear previous results immediately
setRunResults(null);
// Set as current run
setCurrentRun(newRun);
// Start monitoring the run
startMonitoringRun(newRun.id);
// Small delay to ensure the run is properly created in the database
await new Promise(resolve => setTimeout(resolve, 100));
// Check if the run is completed (it might complete instantly)
let finalRun = newRun;
// If status is pending, check again after a short delay
if (newRun.status === 'pending') {
await new Promise(resolve => setTimeout(resolve, 500));
try {
finalRun = await backtestApiV2.getRun(newRun.id);
// Update the current run with the latest status
setCurrentRun(finalRun);
setRuns(prevRuns =>
prevRuns.map(r => r.id === finalRun.id ? finalRun : r)
);
} catch (err) {
console.error('Failed to re-check run status:', err);
}
}
// If the run is completed, load results
if (finalRun.status === 'completed') {
try {
const results = await backtestApiV2.getRunResults(finalRun.id);
setRunResults(results);
} catch (err) {
console.error('Failed to load new run results:', err);
}
} else {
// Start monitoring the run
startMonitoringRun(finalRun.id);
}
return newRun; // Return the created run
} catch (err) {
@ -215,8 +242,39 @@ export function useBacktestV2(): UseBacktestV2Return {
return;
}
const run = runs.find(r => r.id === runId);
if (!run) return;
let run = runs.find(r => r.id === runId);
// If run not found in list, try to fetch it directly
if (!run) {
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
try {
run = await backtestApiV2.getRun(runId);
// Add to runs list if not present
setRuns(prevRuns => {
const exists = prevRuns.some(r => r.id === run!.id);
if (!exists) {
return [run!, ...prevRuns];
}
return prevRuns;
});
break; // Success, exit loop
} catch (err) {
attempts++;
if (attempts >= maxAttempts) {
console.error('Failed to fetch run after all attempts:', runId, err);
setError(`Failed to load run ${runId}`);
return;
}
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
setCurrentRun(run);
setRunResults(null);
@ -226,22 +284,49 @@ export function useBacktestV2(): UseBacktestV2Return {
const results = await backtestApiV2.getRunResults(run.id);
setRunResults(results);
} catch (err) {
console.error('Failed to load run results:', err);
setError(err instanceof Error ? err.message : 'Failed to load run results');
}
} else if (run.status === 'running' || run.status === 'paused') {
} else if (run.status === 'running' || run.status === 'paused' || run.status === 'pending') {
startMonitoringRun(run.id);
} else {
console.warn('Run has unexpected status:', run.status);
}
}, [runs]);
// Monitor run progress
const startMonitoringRun = (runId: string) => {
const startMonitoringRun = useCallback((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');
};
// For pending runs, we need to poll until they start or complete
// We'll check the run status by fetching it
pollingIntervalRef.current = setInterval(async () => {
try {
const updatedRun = await backtestApiV2.getRun(runId);
// Update the run in state
setCurrentRun(updatedRun);
setRuns(prevRuns =>
prevRuns.map(r => r.id === updatedRun.id ? updatedRun : r)
);
// If run is no longer pending, handle the new status
if (updatedRun.status === 'completed') {
stopMonitoringRun();
try {
const results = await backtestApiV2.getRunResults(runId);
setRunResults(results);
} catch (err) {
console.error('Failed to load completed run results:', err);
}
} else if (updatedRun.status === 'failed' || updatedRun.status === 'cancelled') {
stopMonitoringRun();
}
} catch (err) {
console.error('Failed to poll run status:', err);
}
}, 1000); // Poll every second
}, []);
const startPollingRun = (runId: string) => {
pollingIntervalRef.current = setInterval(async () => {

View file

@ -145,6 +145,8 @@ export const backtestApiV2 = {
});
if (!response.ok) {
const errorText = await response.text();
console.error(`Failed to create run:`, response.status, errorText);
throw new Error(`Failed to create run: ${response.statusText}`);
}
@ -175,7 +177,7 @@ export const backtestApiV2 = {
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}`);
throw new Error(`Failed to get run results: ${response.status} ${response.statusText}`);
}
return response.json();