rerun complete
This commit is contained in:
parent
11c6c19628
commit
d15e542f20
17 changed files with 4694 additions and 146 deletions
53
apps/stock/web-app/src/components/ErrorBoundary.tsx
Normal file
53
apps/stock/web-app/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
159
apps/stock/web-app/src/hooks/useChartManager.ts
Normal file
159
apps/stock/web-app/src/hooks/useChartManager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
47
apps/stock/web-app/src/utils/chartErrorHandler.ts
Normal file
47
apps/stock/web-app/src/utils/chartErrorHandler.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue