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

@ -0,0 +1,53 @@
import React, { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className="flex items-center justify-center h-full bg-background">
<div className="text-center p-8">
<h2 className="text-lg font-semibold text-text-primary mb-2">
Something went wrong
</h2>
<p className="text-sm text-text-secondary mb-4">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
>
Reload Page
</button>
</div>
</div>
)
);
}
return this.props.children;
}
}

View file

@ -1,5 +1,6 @@
import * as LightweightCharts from 'lightweight-charts';
import { useEffect, useRef, useCallback } from 'react';
import { useEffect, useRef, useCallback, useMemo } from 'react';
import { useChartManager } from '@/hooks/useChartManager';
export interface ChartData {
time: number;
@ -35,6 +36,7 @@ export interface ChartProps {
}>;
tradeMarkers?: TradeMarker[];
className?: string;
chartId?: string;
}
export function Chart({
@ -46,16 +48,32 @@ export function Chart({
overlayData = [],
tradeMarkers = [],
className = '',
chartId = 'default',
}: ChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<LightweightCharts.IChartApi | null>(null);
const mainSeriesRef = useRef<LightweightCharts.ISeriesApi<any> | null>(null);
const volumeSeriesRef = useRef<LightweightCharts.ISeriesApi<any> | null>(null);
const overlaySeriesRef = useRef<Map<string, LightweightCharts.ISeriesApi<any>>>(new Map());
const { createChart, getChart, setMainSeries, setVolumeSeries, addOverlaySeries, disposeChart } = useChartManager();
// Use a stable unique ID that changes only when chartId prop changes
const uniqueChartId = useMemo(() => {
return `${chartId}-${Date.now()}`;
}, [chartId])
const mountedRef = useRef(true);
const lastChartIdRef = useRef(chartId);
// Track component mount state
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
// Cleanup on unmount
disposeChart(uniqueChartId);
};
}, [disposeChart]);
// Reset zoom handler
const resetZoom = useCallback(() => {
if (chartRef.current && data.length > 0) {
const chartInstance = getChart(uniqueChartId);
if (chartInstance && !chartInstance.isDisposed && data.length > 0) {
// Get the validated data to ensure we're using the correct time values
const validateData = (rawData: any[]) => {
const seen = new Set<number>();
@ -84,23 +102,35 @@ export function Chart({
const timeRange = lastTime - firstTime;
const padding = timeRange * 0.05;
chartRef.current.timeScale().setVisibleRange({
from: (firstTime - padding) as any,
to: (lastTime + padding) as any,
});
try {
chartInstance.chart.timeScale().setVisibleRange({
from: (firstTime - padding) as any,
to: (lastTime + padding) as any,
});
chartInstance.chart.timeScale().fitContent();
} catch (error) {
console.error('Error resetting zoom:', error);
}
}
chartRef.current.timeScale().fitContent();
}
}, [data]);
}, [data, getChart]);
useEffect(() => {
if (!chartContainerRef.current || !data || !data.length) {
return;
}
let handleResize: (() => void) | undefined;
let isCleanedUp = false;
// Use requestAnimationFrame to ensure DOM is ready and avoid conflicts
const rafId = requestAnimationFrame(() => {
const initTimeout = setTimeout(() => {
if (!chartContainerRef.current || isCleanedUp || !mountedRef.current) return;
// Create chart
const chart = LightweightCharts.createChart(chartContainerRef.current, {
// Create chart using the chart manager
const chart = createChart(uniqueChartId, chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: height,
layout: {
@ -138,7 +168,10 @@ export function Chart({
},
});
chartRef.current = chart;
if (!chart) {
console.error('Failed to create chart');
return;
}
// Filter, validate and sort data
const validateAndFilterData = (rawData: any[]) => {
@ -166,8 +199,10 @@ export function Chart({
};
// Create main series
let mainSeries: LightweightCharts.ISeriesApi<any> | null = null;
if (type === 'candlestick' && data[0].open !== undefined) {
mainSeriesRef.current = chart.addCandlestickSeries({
mainSeries = chart.addCandlestickSeries({
upColor: '#10b981',
downColor: '#ef4444',
borderUpColor: '#10b981',
@ -176,9 +211,9 @@ export function Chart({
wickDownColor: '#ef4444',
});
const validData = validateAndFilterData(data);
mainSeriesRef.current.setData(validData as LightweightCharts.CandlestickData[]);
mainSeries.setData(validData as LightweightCharts.CandlestickData[]);
} else if (type === 'line' || (type === 'candlestick' && data[0].value !== undefined)) {
mainSeriesRef.current = chart.addLineSeries({
mainSeries = chart.addLineSeries({
color: '#3b82f6',
lineWidth: 2,
});
@ -187,9 +222,9 @@ export function Chart({
value: d.value ?? d.close ?? 0
}));
const validData = validateAndFilterData(lineData);
mainSeriesRef.current.setData(validData);
mainSeries.setData(validData);
} else if (type === 'area') {
mainSeriesRef.current = chart.addAreaSeries({
mainSeries = chart.addAreaSeries({
lineColor: '#3b82f6',
topColor: '#3b82f6',
bottomColor: 'rgba(59, 130, 246, 0.1)',
@ -200,12 +235,16 @@ export function Chart({
value: d.value ?? d.close ?? 0
}));
const validData = validateAndFilterData(areaData);
mainSeriesRef.current.setData(validData);
mainSeries.setData(validData);
}
if (mainSeries) {
setMainSeries(uniqueChartId, mainSeries);
}
// Add volume if available
if (showVolume && data.some(d => d.volume)) {
volumeSeriesRef.current = chart.addHistogramSeries({
const volumeSeries = chart.addHistogramSeries({
color: '#3b82f680',
priceFormat: {
type: 'volume',
@ -213,7 +252,7 @@ export function Chart({
priceScaleId: 'volume',
});
volumeSeriesRef.current.priceScale().applyOptions({
volumeSeries.priceScale().applyOptions({
scaleMargins: {
top: 0.8,
bottom: 0,
@ -231,11 +270,11 @@ export function Chart({
'#3b82f640',
}))
);
volumeSeriesRef.current.setData(volumeData);
volumeSeries.setData(volumeData);
setVolumeSeries(uniqueChartId, volumeSeries);
}
// Add overlay series
overlaySeriesRef.current.clear();
overlayData.forEach((overlay, index) => {
const series = chart.addLineSeries({
color: overlay.color || ['#ff9800', '#4caf50', '#9c27b0', '#f44336'][index % 4],
@ -260,11 +299,11 @@ export function Chart({
}));
const validatedData = validateAndFilterData(overlayDataWithTime);
series.setData(validatedData);
overlaySeriesRef.current.set(overlay.name, series);
addOverlaySeries(uniqueChartId, overlay.name, series);
});
// Add trade markers
if (tradeMarkers.length > 0 && mainSeriesRef.current) {
if (tradeMarkers.length > 0 && mainSeries) {
// Sort markers by time to ensure they're in ascending order
const sortedMarkers = [...tradeMarkers].sort((a, b) => a.time - b.time);
const markers: LightweightCharts.SeriesMarker<LightweightCharts.Time>[] = sortedMarkers.map(marker => ({
@ -276,7 +315,7 @@ export function Chart({
id: marker.id,
size: 1
}));
mainSeriesRef.current.setMarkers(markers);
mainSeries.setMarkers(markers);
}
// Fit content with a slight delay to ensure all series are loaded
@ -322,29 +361,39 @@ export function Chart({
});
// Handle resize
const handleResize = () => {
if (chartContainerRef.current && chart) {
chart.applyOptions({
width: chartContainerRef.current.clientWidth,
});
handleResize = () => {
if (chartContainerRef.current && !isCleanedUp) {
const chartInstance = getChart(uniqueChartId);
if (chartInstance && !chartInstance.isDisposed) {
try {
chartInstance.chart.applyOptions({
width: chartContainerRef.current.clientWidth,
});
} catch (error) {
// Ignore errors if the chart is being disposed
if (!error?.message?.includes('disposed')) {
console.error('Error resizing chart:', error);
}
}
}
}
};
window.addEventListener('resize', handleResize);
}, 0); // End of setTimeout
}); // End of requestAnimationFrame
// Cleanup
return () => {
window.removeEventListener('resize', handleResize);
// 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;
isCleanedUp = true;
cancelAnimationFrame(rafId);
if (handleResize) {
window.removeEventListener('resize', handleResize);
}
// Dispose immediately - the chart manager handles cleanup safely
disposeChart(uniqueChartId);
};
}, [data, height, type, showVolume, theme, overlayData, tradeMarkers]);
}, [data, height, type, showVolume, theme, overlayData, tradeMarkers, createChart, getChart, setMainSeries, setVolumeSeries, addOverlaySeries, disposeChart]);
return (
<div className={`relative ${className}`}>

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

View file

@ -0,0 +1,159 @@
import { useRef, useCallback, useEffect } from 'react';
import * as LightweightCharts from 'lightweight-charts';
interface ChartInstance {
chart: LightweightCharts.IChartApi;
mainSeries: LightweightCharts.ISeriesApi<any> | null;
volumeSeries: LightweightCharts.ISeriesApi<any> | null;
overlaySeries: Map<string, LightweightCharts.ISeriesApi<any>>;
isDisposed: boolean;
}
export function useChartManager() {
const instancesRef = useRef<Map<string, ChartInstance>>(new Map());
const disposeChart = useCallback((id: string) => {
const instance = instancesRef.current.get(id);
if (!instance || instance.isDisposed) {
return;
}
// Mark as disposed first to prevent any further operations
instance.isDisposed = true;
try {
// Disable all chart updates before removal
const chart = instance.chart;
// Stop all animations and pending operations
if (chart.timeScale) {
try {
chart.timeScale().unsubscribeVisibleLogicalRangeChange();
} catch (e) {
// Ignore
}
}
// Clear all series references
instance.mainSeries = null;
instance.volumeSeries = null;
instance.overlaySeries.clear();
// Remove the chart
chart.remove();
} catch (error) {
// Only log if it's not a disposed error
if (!error?.message?.includes('disposed')) {
console.error('Error disposing chart:', error);
}
} finally {
// Remove from instances map
instancesRef.current.delete(id);
}
}, []);
const createChart = useCallback((
id: string,
container: HTMLElement,
options: LightweightCharts.DeepPartial<LightweightCharts.ChartOptions>
): LightweightCharts.IChartApi | null => {
// Dispose any existing chart with this ID first
disposeChart(id);
try {
const chart = LightweightCharts.createChart(container, options);
const instance: ChartInstance = {
chart,
mainSeries: null,
volumeSeries: null,
overlaySeries: new Map(),
isDisposed: false,
};
// Wrap chart methods to check disposal state
const originalApplyOptions = chart.applyOptions.bind(chart);
chart.applyOptions = (options: any) => {
if (!instance.isDisposed) {
originalApplyOptions(options);
}
};
const originalTimeScale = chart.timeScale.bind(chart);
chart.timeScale = () => {
if (instance.isDisposed) {
// Return a mock object that does nothing
return {
fitContent: () => {},
setVisibleRange: () => {},
getVisibleRange: () => null,
scrollToPosition: () => {},
applyOptions: () => {},
options: () => ({}),
subscribeVisibleTimeRangeChange: () => {},
unsubscribeVisibleTimeRangeChange: () => {},
subscribeVisibleLogicalRangeChange: () => {},
unsubscribeVisibleLogicalRangeChange: () => {},
} as any;
}
return originalTimeScale();
};
instancesRef.current.set(id, instance);
return chart;
} catch (error) {
console.error('Failed to create chart:', error);
return null;
}
}, [disposeChart]);
const getChart = useCallback((id: string): ChartInstance | null => {
const instance = instancesRef.current.get(id);
if (!instance || instance.isDisposed) {
return null;
}
return instance;
}, []);
const setMainSeries = useCallback((id: string, series: LightweightCharts.ISeriesApi<any> | null) => {
const instance = getChart(id);
if (instance) {
instance.mainSeries = series;
}
}, [getChart]);
const setVolumeSeries = useCallback((id: string, series: LightweightCharts.ISeriesApi<any> | null) => {
const instance = getChart(id);
if (instance) {
instance.volumeSeries = series;
}
}, [getChart]);
const addOverlaySeries = useCallback((id: string, name: string, series: LightweightCharts.ISeriesApi<any>) => {
const instance = getChart(id);
if (instance) {
instance.overlaySeries.set(name, series);
}
}, [getChart]);
const disposeAllCharts = useCallback(() => {
const ids = Array.from(instancesRef.current.keys());
ids.forEach(id => disposeChart(id));
}, [disposeChart]);
// Cleanup on unmount
useEffect(() => {
return () => {
disposeAllCharts();
};
}, [disposeAllCharts]);
return {
createChart,
getChart,
setMainSeries,
setVolumeSeries,
addOverlaySeries,
disposeChart,
disposeAllCharts,
};
}

View file

@ -1,8 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './app';
import { setupChartErrorHandler } from './utils/chartErrorHandler';
import './index.css';
// Set up error handler for chart disposal errors
setupChartErrorHandler();
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');

View file

@ -0,0 +1,47 @@
// Global error handler for chart disposal errors
export function setupChartErrorHandler() {
// Only suppress chart disposal errors during page unload
let isUnloading = false;
window.addEventListener('beforeunload', () => {
isUnloading = true;
});
window.addEventListener('unload', () => {
isUnloading = true;
});
// Intercept console errors for chart disposal
const originalError = console.error;
console.error = (...args) => {
// Check if this is a chart disposal error during unload
if (isUnloading) {
const errorStr = args.join(' ');
if (errorStr.includes('Object is disposed') ||
errorStr.includes('Cannot read properties of null') ||
errorStr.includes('lightweight-charts')) {
// Suppress the error during unload
return;
}
}
// Otherwise, log normally
originalError.apply(console, args);
};
// Also catch unhandled errors
window.addEventListener('error', (event) => {
if (isUnloading && event.error?.message?.includes('Object is disposed')) {
event.preventDefault();
return;
}
});
// Catch unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
if (isUnloading && event.reason?.message?.includes('Object is disposed')) {
event.preventDefault();
return;
}
});
}

3211
bun.lock Normal file

File diff suppressed because it is too large Load diff