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 * 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 {
|
export interface ChartData {
|
||||||
time: number;
|
time: number;
|
||||||
|
|
@ -35,6 +36,7 @@ export interface ChartProps {
|
||||||
}>;
|
}>;
|
||||||
tradeMarkers?: TradeMarker[];
|
tradeMarkers?: TradeMarker[];
|
||||||
className?: string;
|
className?: string;
|
||||||
|
chartId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chart({
|
export function Chart({
|
||||||
|
|
@ -46,16 +48,32 @@ export function Chart({
|
||||||
overlayData = [],
|
overlayData = [],
|
||||||
tradeMarkers = [],
|
tradeMarkers = [],
|
||||||
className = '',
|
className = '',
|
||||||
|
chartId = 'default',
|
||||||
}: ChartProps) {
|
}: ChartProps) {
|
||||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const chartRef = useRef<LightweightCharts.IChartApi | null>(null);
|
const { createChart, getChart, setMainSeries, setVolumeSeries, addOverlaySeries, disposeChart } = useChartManager();
|
||||||
const mainSeriesRef = useRef<LightweightCharts.ISeriesApi<any> | null>(null);
|
|
||||||
const volumeSeriesRef = useRef<LightweightCharts.ISeriesApi<any> | null>(null);
|
// Use a stable unique ID that changes only when chartId prop changes
|
||||||
const overlaySeriesRef = useRef<Map<string, LightweightCharts.ISeriesApi<any>>>(new Map());
|
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
|
// Reset zoom handler
|
||||||
const resetZoom = useCallback(() => {
|
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
|
// Get the validated data to ensure we're using the correct time values
|
||||||
const validateData = (rawData: any[]) => {
|
const validateData = (rawData: any[]) => {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
|
|
@ -84,23 +102,35 @@ export function Chart({
|
||||||
const timeRange = lastTime - firstTime;
|
const timeRange = lastTime - firstTime;
|
||||||
const padding = timeRange * 0.05;
|
const padding = timeRange * 0.05;
|
||||||
|
|
||||||
chartRef.current.timeScale().setVisibleRange({
|
try {
|
||||||
|
chartInstance.chart.timeScale().setVisibleRange({
|
||||||
from: (firstTime - padding) as any,
|
from: (firstTime - padding) as any,
|
||||||
to: (lastTime + padding) as any,
|
to: (lastTime + padding) as any,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
chartRef.current.timeScale().fitContent();
|
chartInstance.chart.timeScale().fitContent();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting zoom:', error);
|
||||||
}
|
}
|
||||||
}, [data]);
|
}
|
||||||
|
}
|
||||||
|
}, [data, getChart]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chartContainerRef.current || !data || !data.length) {
|
if (!chartContainerRef.current || !data || !data.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create chart
|
let handleResize: (() => void) | undefined;
|
||||||
const chart = LightweightCharts.createChart(chartContainerRef.current, {
|
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 using the chart manager
|
||||||
|
const chart = createChart(uniqueChartId, chartContainerRef.current, {
|
||||||
width: chartContainerRef.current.clientWidth,
|
width: chartContainerRef.current.clientWidth,
|
||||||
height: height,
|
height: height,
|
||||||
layout: {
|
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
|
// Filter, validate and sort data
|
||||||
const validateAndFilterData = (rawData: any[]) => {
|
const validateAndFilterData = (rawData: any[]) => {
|
||||||
|
|
@ -166,8 +199,10 @@ export function Chart({
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create main series
|
// Create main series
|
||||||
|
let mainSeries: LightweightCharts.ISeriesApi<any> | null = null;
|
||||||
|
|
||||||
if (type === 'candlestick' && data[0].open !== undefined) {
|
if (type === 'candlestick' && data[0].open !== undefined) {
|
||||||
mainSeriesRef.current = chart.addCandlestickSeries({
|
mainSeries = chart.addCandlestickSeries({
|
||||||
upColor: '#10b981',
|
upColor: '#10b981',
|
||||||
downColor: '#ef4444',
|
downColor: '#ef4444',
|
||||||
borderUpColor: '#10b981',
|
borderUpColor: '#10b981',
|
||||||
|
|
@ -176,9 +211,9 @@ export function Chart({
|
||||||
wickDownColor: '#ef4444',
|
wickDownColor: '#ef4444',
|
||||||
});
|
});
|
||||||
const validData = validateAndFilterData(data);
|
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)) {
|
} else if (type === 'line' || (type === 'candlestick' && data[0].value !== undefined)) {
|
||||||
mainSeriesRef.current = chart.addLineSeries({
|
mainSeries = chart.addLineSeries({
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
lineWidth: 2,
|
lineWidth: 2,
|
||||||
});
|
});
|
||||||
|
|
@ -187,9 +222,9 @@ export function Chart({
|
||||||
value: d.value ?? d.close ?? 0
|
value: d.value ?? d.close ?? 0
|
||||||
}));
|
}));
|
||||||
const validData = validateAndFilterData(lineData);
|
const validData = validateAndFilterData(lineData);
|
||||||
mainSeriesRef.current.setData(validData);
|
mainSeries.setData(validData);
|
||||||
} else if (type === 'area') {
|
} else if (type === 'area') {
|
||||||
mainSeriesRef.current = chart.addAreaSeries({
|
mainSeries = chart.addAreaSeries({
|
||||||
lineColor: '#3b82f6',
|
lineColor: '#3b82f6',
|
||||||
topColor: '#3b82f6',
|
topColor: '#3b82f6',
|
||||||
bottomColor: 'rgba(59, 130, 246, 0.1)',
|
bottomColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
|
@ -200,12 +235,16 @@ export function Chart({
|
||||||
value: d.value ?? d.close ?? 0
|
value: d.value ?? d.close ?? 0
|
||||||
}));
|
}));
|
||||||
const validData = validateAndFilterData(areaData);
|
const validData = validateAndFilterData(areaData);
|
||||||
mainSeriesRef.current.setData(validData);
|
mainSeries.setData(validData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainSeries) {
|
||||||
|
setMainSeries(uniqueChartId, mainSeries);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add volume if available
|
// Add volume if available
|
||||||
if (showVolume && data.some(d => d.volume)) {
|
if (showVolume && data.some(d => d.volume)) {
|
||||||
volumeSeriesRef.current = chart.addHistogramSeries({
|
const volumeSeries = chart.addHistogramSeries({
|
||||||
color: '#3b82f680',
|
color: '#3b82f680',
|
||||||
priceFormat: {
|
priceFormat: {
|
||||||
type: 'volume',
|
type: 'volume',
|
||||||
|
|
@ -213,7 +252,7 @@ export function Chart({
|
||||||
priceScaleId: 'volume',
|
priceScaleId: 'volume',
|
||||||
});
|
});
|
||||||
|
|
||||||
volumeSeriesRef.current.priceScale().applyOptions({
|
volumeSeries.priceScale().applyOptions({
|
||||||
scaleMargins: {
|
scaleMargins: {
|
||||||
top: 0.8,
|
top: 0.8,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|
@ -231,11 +270,11 @@ export function Chart({
|
||||||
'#3b82f640',
|
'#3b82f640',
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
volumeSeriesRef.current.setData(volumeData);
|
volumeSeries.setData(volumeData);
|
||||||
|
setVolumeSeries(uniqueChartId, volumeSeries);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add overlay series
|
// Add overlay series
|
||||||
overlaySeriesRef.current.clear();
|
|
||||||
overlayData.forEach((overlay, index) => {
|
overlayData.forEach((overlay, index) => {
|
||||||
const series = chart.addLineSeries({
|
const series = chart.addLineSeries({
|
||||||
color: overlay.color || ['#ff9800', '#4caf50', '#9c27b0', '#f44336'][index % 4],
|
color: overlay.color || ['#ff9800', '#4caf50', '#9c27b0', '#f44336'][index % 4],
|
||||||
|
|
@ -260,11 +299,11 @@ export function Chart({
|
||||||
}));
|
}));
|
||||||
const validatedData = validateAndFilterData(overlayDataWithTime);
|
const validatedData = validateAndFilterData(overlayDataWithTime);
|
||||||
series.setData(validatedData);
|
series.setData(validatedData);
|
||||||
overlaySeriesRef.current.set(overlay.name, series);
|
addOverlaySeries(uniqueChartId, overlay.name, series);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add trade markers
|
// 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
|
// Sort markers by time to ensure they're in ascending order
|
||||||
const sortedMarkers = [...tradeMarkers].sort((a, b) => a.time - b.time);
|
const sortedMarkers = [...tradeMarkers].sort((a, b) => a.time - b.time);
|
||||||
const markers: LightweightCharts.SeriesMarker<LightweightCharts.Time>[] = sortedMarkers.map(marker => ({
|
const markers: LightweightCharts.SeriesMarker<LightweightCharts.Time>[] = sortedMarkers.map(marker => ({
|
||||||
|
|
@ -276,7 +315,7 @@ export function Chart({
|
||||||
id: marker.id,
|
id: marker.id,
|
||||||
size: 1
|
size: 1
|
||||||
}));
|
}));
|
||||||
mainSeriesRef.current.setMarkers(markers);
|
mainSeries.setMarkers(markers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fit content with a slight delay to ensure all series are loaded
|
// Fit content with a slight delay to ensure all series are loaded
|
||||||
|
|
@ -322,29 +361,39 @@ export function Chart({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize
|
||||||
const handleResize = () => {
|
handleResize = () => {
|
||||||
if (chartContainerRef.current && chart) {
|
if (chartContainerRef.current && !isCleanedUp) {
|
||||||
chart.applyOptions({
|
const chartInstance = getChart(uniqueChartId);
|
||||||
|
if (chartInstance && !chartInstance.isDisposed) {
|
||||||
|
try {
|
||||||
|
chartInstance.chart.applyOptions({
|
||||||
width: chartContainerRef.current.clientWidth,
|
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);
|
window.addEventListener('resize', handleResize);
|
||||||
|
}, 0); // End of setTimeout
|
||||||
|
}); // End of requestAnimationFrame
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
return () => {
|
return () => {
|
||||||
|
isCleanedUp = true;
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
if (handleResize) {
|
||||||
window.removeEventListener('resize', handleResize);
|
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;
|
|
||||||
}
|
}
|
||||||
|
// 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 (
|
return (
|
||||||
<div className={`relative ${className}`}>
|
<div className={`relative ${className}`}>
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,10 @@ import { ArrowLeftIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { BacktestConfiguration } from './components/BacktestConfiguration';
|
import { BacktestConfiguration } from './components/BacktestConfiguration';
|
||||||
import { BacktestMetrics } from './components/BacktestMetrics';
|
|
||||||
import { BacktestPlayback } from './components/BacktestPlayback';
|
import { BacktestPlayback } from './components/BacktestPlayback';
|
||||||
import { BacktestTrades } from './components/BacktestTrades';
|
import { BacktestTrades } from './components/BacktestTrades';
|
||||||
import { RunControlsCompact } from './components/RunControlsCompact';
|
import { RunControlsCompact } from './components/RunControlsCompact';
|
||||||
import { RunsList } from './components/RunsList';
|
import { RunsListWithMetrics } from './components/RunsListWithMetrics';
|
||||||
import { useBacktestV2 } from './hooks/useBacktestV2';
|
import { useBacktestV2 } from './hooks/useBacktestV2';
|
||||||
import type { BacktestConfig } from './types/backtest.types';
|
import type { BacktestConfig } from './types/backtest.types';
|
||||||
|
|
||||||
|
|
@ -17,15 +16,14 @@ const baseTabs = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const runTabs = [
|
const runTabs = [
|
||||||
{ id: 'playback', name: 'Playback' },
|
{ id: 'details', name: 'Details' },
|
||||||
{ id: 'metrics', name: 'Performance' },
|
|
||||||
{ id: 'trades', name: 'Trades' },
|
{ id: 'trades', name: 'Trades' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BacktestDetailPageV2() {
|
export function BacktestDetailPageV2() {
|
||||||
const { id, runId } = useParams<{ id: string; runId?: string }>();
|
const { id, runId } = useParams<{ id: string; runId?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeTab, setActiveTab] = useState('playback');
|
const [activeTab, setActiveTab] = useState('details');
|
||||||
const [showNewRun, setShowNewRun] = useState(false);
|
const [showNewRun, setShowNewRun] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -62,10 +60,18 @@ export function BacktestDetailPageV2() {
|
||||||
loadBacktest(id);
|
loadBacktest(id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCompleted: (results) => {
|
onCompleted: async (results) => {
|
||||||
console.log('Run completed:', results);
|
// When run completes, reload to get the final results
|
||||||
// Don't reload the entire backtest, just update the current run status
|
if (currentRun?.id) {
|
||||||
// The results are already available from the WebSocket message
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -76,23 +82,46 @@ export function BacktestDetailPageV2() {
|
||||||
}
|
}
|
||||||
}, [id, loadBacktest]);
|
}, [id, loadBacktest]);
|
||||||
|
|
||||||
// Select run based on URL parameter
|
// Clear mismatched results immediately
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (runId && runs.length > 0) {
|
if (runResults && currentRun && runResults.runId !== currentRun.id) {
|
||||||
const run = runs.find(r => r.id === runId);
|
console.warn('Run results mismatch:', {
|
||||||
if (run && run.id !== currentRun?.id) {
|
runResultsId: runResults.runId,
|
||||||
selectRun(run.id);
|
currentRunId: currentRun.id
|
||||||
// Show playback tab by default when a run is selected
|
});
|
||||||
if (activeTab === 'runs') {
|
// Clear the mismatched results immediately to prevent rendering wrong data
|
||||||
setActiveTab('playback');
|
// The selectRun effect will load the correct results
|
||||||
}
|
}
|
||||||
|
}, [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) {
|
} else if (!runId && currentRun) {
|
||||||
// Clear run selection when navigating away from run URL
|
// Clear run selection when navigating away from run URL
|
||||||
selectRun(undefined);
|
selectRun(undefined);
|
||||||
setActiveTab('runs');
|
setActiveTab('runs');
|
||||||
}
|
}
|
||||||
}, [runId, runs, selectRun]);
|
}, 50); // Small debounce to prevent rapid switching
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [runId, currentRun?.id, selectRun, activeTab]);
|
||||||
|
|
||||||
// Handle configuration save
|
// Handle configuration save
|
||||||
const handleConfigSubmit = useCallback(async (config: BacktestConfig) => {
|
const handleConfigSubmit = useCallback(async (config: BacktestConfig) => {
|
||||||
|
|
@ -127,6 +156,7 @@ export function BacktestDetailPageV2() {
|
||||||
const handleRerun = useCallback(async (speedMultiplier: number | null) => {
|
const handleRerun = useCallback(async (speedMultiplier: number | null) => {
|
||||||
// Pass null for max speed (no limit)
|
// Pass null for max speed (no limit)
|
||||||
const newRun = await createRun(speedMultiplier ?? undefined);
|
const newRun = await createRun(speedMultiplier ?? undefined);
|
||||||
|
|
||||||
// Navigate to the new run's URL
|
// Navigate to the new run's URL
|
||||||
if (newRun && id) {
|
if (newRun && id) {
|
||||||
navigate(`/backtests/${id}/run/${newRun.id}`);
|
navigate(`/backtests/${id}/run/${newRun.id}`);
|
||||||
|
|
@ -148,7 +178,7 @@ export function BacktestDetailPageV2() {
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
// Show message if trying to view run-specific tabs without a run selected
|
// 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 (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -204,7 +234,7 @@ export function BacktestDetailPageV2() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RunsList
|
<RunsListWithMetrics
|
||||||
runs={runs}
|
runs={runs}
|
||||||
currentRunId={currentRun?.id}
|
currentRunId={currentRun?.id}
|
||||||
onSelectRun={selectRun}
|
onSelectRun={selectRun}
|
||||||
|
|
@ -221,18 +251,19 @@ export function BacktestDetailPageV2() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'metrics':
|
case 'details':
|
||||||
|
// Only render if we have matching results or no results yet
|
||||||
|
if (runResults && currentRun && runResults.runId !== currentRun.id) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full flex items-center justify-center">
|
||||||
<BacktestMetrics
|
<div className="text-text-secondary">Loading run data...</div>
|
||||||
result={runResults}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'playback':
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BacktestPlayback
|
<BacktestPlayback
|
||||||
|
key={currentRun?.id || 'no-run'} // Force remount on run change
|
||||||
result={runResults}
|
result={runResults}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { BacktestResult } from '../types/backtest.types';
|
import type { BacktestResult } from '../types/backtest.types';
|
||||||
import { useState, useMemo, memo } from 'react';
|
import { useState, useMemo, memo } from 'react';
|
||||||
import { Chart } from '../../../components/charts';
|
import { Chart } from '../../../components/charts/Chart';
|
||||||
|
|
||||||
interface BacktestChartProps {
|
interface BacktestChartProps {
|
||||||
result: BacktestResult | null;
|
result: BacktestResult | null;
|
||||||
|
|
@ -110,9 +110,18 @@ export const BacktestChart = memo(function BacktestChart({ result, isLoading }:
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Chart
|
<Chart
|
||||||
data={chartData.ohlcData}
|
data={chartData.ohlcData}
|
||||||
equityData={chartData.equityData}
|
overlayData={[
|
||||||
markers={chartData.tradeMarkers}
|
{
|
||||||
|
name: 'Equity',
|
||||||
|
data: chartData.equityData,
|
||||||
|
color: '#3b82f6',
|
||||||
|
lineWidth: 2
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
tradeMarkers={chartData.tradeMarkers}
|
||||||
height={500}
|
height={500}
|
||||||
|
chartId={`backtest-${result?.runId || 'default'}`}
|
||||||
|
key={result?.runId || 'default'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ interface BacktestMetricsProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full detailed metrics view - can be used as a separate page/view if needed
|
||||||
export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
|
export function BacktestMetrics({ result, isLoading }: BacktestMetricsProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { useState, memo } from 'react';
|
import { useState, memo } from 'react';
|
||||||
import { BacktestChart } from './BacktestChart';
|
import { BacktestChart } from './BacktestChart';
|
||||||
|
import { ErrorBoundary } from '../../../components/ErrorBoundary';
|
||||||
|
import { CompactPerformanceMetrics } from './CompactPerformanceMetrics';
|
||||||
|
import { PositionsSummary } from './PositionsSummary';
|
||||||
|
|
||||||
interface BacktestPlaybackProps {
|
interface BacktestPlaybackProps {
|
||||||
result: any | null;
|
result: any | null;
|
||||||
|
|
@ -7,7 +10,7 @@ interface BacktestPlaybackProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoading }: BacktestPlaybackProps) {
|
export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoading }: BacktestPlaybackProps) {
|
||||||
const [showPositions, setShowPositions] = useState(true);
|
const [showPositions, setShowPositions] = useState(false);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -25,21 +28,29 @@ export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoadi
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get open positions from the result
|
// Get positions from the result
|
||||||
// Positions can be an object with symbols as keys or an array
|
|
||||||
let openPositions: any[] = [];
|
let openPositions: any[] = [];
|
||||||
|
let closedPositions: any[] = [];
|
||||||
|
|
||||||
if (result.positions) {
|
if (result.positions) {
|
||||||
if (Array.isArray(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') {
|
} else if (typeof result.positions === 'object') {
|
||||||
// Convert positions object to array
|
// Convert positions object to array
|
||||||
openPositions = Object.entries(result.positions)
|
Object.entries(result.positions).forEach(([symbol, position]: [string, any]) => {
|
||||||
.filter(([_, position]: [string, any]) => position.quantity > 0)
|
const pos = { symbol, ...position };
|
||||||
.map(([symbol, position]: [string, any]) => ({
|
if (position.quantity !== 0) {
|
||||||
symbol,
|
openPositions.push(pos);
|
||||||
...position
|
} else if (position.realizedPnl !== 0) {
|
||||||
}));
|
closedPositions.push(pos);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,26 +58,62 @@ export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoadi
|
||||||
<div className="h-full flex flex-col space-y-4">
|
<div className="h-full flex flex-col space-y-4">
|
||||||
{/* Chart Section */}
|
{/* Chart Section */}
|
||||||
<div className="flex-1">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Open Positions Section */}
|
{/* Performance Metrics */}
|
||||||
{openPositions.length > 0 && (
|
<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="flex-shrink-0">
|
||||||
<div className="bg-surface-secondary rounded-lg border border-border p-4">
|
<div className="bg-surface-secondary rounded-lg border border-border p-4">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<h3 className="text-sm font-medium text-text-primary">
|
<h3 className="text-sm font-medium text-text-primary">
|
||||||
Open Positions ({openPositions.length})
|
All Positions
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPositions(!showPositions)}
|
onClick={() => setShowPositions(!showPositions)}
|
||||||
className="text-xs text-text-secondary hover:text-text-primary"
|
className="text-xs text-text-secondary hover:text-text-primary"
|
||||||
>
|
>
|
||||||
{showPositions ? 'Hide' : 'Show'}
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPositions && (
|
<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="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 className="grid grid-cols-6 gap-2 text-xs font-medium text-text-secondary pb-2 border-b border-border">
|
||||||
<div>Symbol</div>
|
<div>Symbol</div>
|
||||||
|
|
@ -81,7 +128,7 @@ export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoadi
|
||||||
const quantity = position.quantity || 0;
|
const quantity = position.quantity || 0;
|
||||||
const avgPrice = position.averagePrice || position.avgPrice || 0;
|
const avgPrice = position.averagePrice || position.avgPrice || 0;
|
||||||
const currentPrice = position.currentPrice || position.lastPrice || avgPrice;
|
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 absQuantity = Math.abs(quantity);
|
||||||
const pnl = (currentPrice - avgPrice) * quantity;
|
const pnl = (currentPrice - avgPrice) * quantity;
|
||||||
const pnlPercent = avgPrice > 0 ? (pnl / (avgPrice * absQuantity)) * 100 : 0;
|
const pnlPercent = avgPrice > 0 ? (pnl / (avgPrice * absQuantity)) * 100 : 0;
|
||||||
|
|
@ -89,7 +136,7 @@ export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoadi
|
||||||
return (
|
return (
|
||||||
<div key={index} className="grid grid-cols-6 gap-2 text-sm">
|
<div key={index} className="grid grid-cols-6 gap-2 text-sm">
|
||||||
<div className="font-medium text-text-primary">{position.symbol}</div>
|
<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()}
|
{side.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-text-primary">{absQuantity}</div>
|
<div className="text-right text-text-primary">{absQuantity}</div>
|
||||||
|
|
@ -105,11 +152,35 @@ export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoadi
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pt-2 border-t border-border">
|
{closedPositions.length > 0 && (
|
||||||
<div className="grid grid-cols-6 gap-2 text-sm font-medium">
|
<div>
|
||||||
<div className="col-span-5 text-right text-text-secondary">Total P&L:</div>
|
<h4 className="text-sm font-medium text-text-secondary mb-2">Closed Positions ({closedPositions.length})</h4>
|
||||||
<div className={`text-right ${
|
<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-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) => {
|
openPositions.reduce((sum, p) => {
|
||||||
const quantity = p.quantity || 0;
|
const quantity = p.quantity || 0;
|
||||||
const avgPrice = p.averagePrice || p.avgPrice || 0;
|
const avgPrice = p.averagePrice || p.avgPrice || 0;
|
||||||
|
|
@ -125,9 +196,34 @@ export const BacktestPlayback = memo(function BacktestPlayback({ result, isLoadi
|
||||||
}, 0).toFixed(2)}
|
}, 0).toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
)}
|
|
||||||
</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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,3 +4,8 @@ export { BacktestResults } from './BacktestResults';
|
||||||
export { MetricsCard } from './MetricsCard';
|
export { MetricsCard } from './MetricsCard';
|
||||||
export { PositionsTable } from './PositionsTable';
|
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);
|
setBacktest(loadedBacktest);
|
||||||
setRuns(loadedRuns);
|
setRuns(loadedRuns);
|
||||||
|
|
||||||
// If there are runs, select the most recent one
|
// Don't auto-select runs here - let the URL parameter drive the selection
|
||||||
if (loadedRuns.length > 0) {
|
|
||||||
const latestRun = loadedRuns[0];
|
|
||||||
setCurrentRun(latestRun);
|
|
||||||
|
|
||||||
if (latestRun.status === 'completed') {
|
|
||||||
const results = await backtestApiV2.getRunResults(latestRun.id);
|
|
||||||
setRunResults(results);
|
|
||||||
} else if (latestRun.status === 'running' || latestRun.status === 'paused') {
|
|
||||||
// Start monitoring the run
|
|
||||||
startMonitoringRun(latestRun.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Failed to load backtest:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load backtest');
|
setError(err instanceof Error ? err.message : 'Failed to load backtest');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -137,11 +126,49 @@ export function useBacktestV2(): UseBacktestV2Return {
|
||||||
const newRun = await backtestApiV2.createRun(backtest.id, {
|
const newRun = await backtestApiV2.createRun(backtest.id, {
|
||||||
speedMultiplier: speedMultiplier === undefined ? null : speedMultiplier
|
speedMultiplier: speedMultiplier === undefined ? null : speedMultiplier
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add to runs array first
|
||||||
setRuns(prevRuns => [newRun, ...prevRuns]);
|
setRuns(prevRuns => [newRun, ...prevRuns]);
|
||||||
|
|
||||||
|
// Clear previous results immediately
|
||||||
|
setRunResults(null);
|
||||||
|
|
||||||
|
// Set as current run
|
||||||
setCurrentRun(newRun);
|
setCurrentRun(newRun);
|
||||||
|
|
||||||
|
// 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
|
// Start monitoring the run
|
||||||
startMonitoringRun(newRun.id);
|
startMonitoringRun(finalRun.id);
|
||||||
|
}
|
||||||
|
|
||||||
return newRun; // Return the created run
|
return newRun; // Return the created run
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -215,8 +242,39 @@ export function useBacktestV2(): UseBacktestV2Return {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = runs.find(r => r.id === runId);
|
let run = runs.find(r => r.id === runId);
|
||||||
if (!run) return;
|
|
||||||
|
// 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);
|
setCurrentRun(run);
|
||||||
setRunResults(null);
|
setRunResults(null);
|
||||||
|
|
@ -226,22 +284,49 @@ export function useBacktestV2(): UseBacktestV2Return {
|
||||||
const results = await backtestApiV2.getRunResults(run.id);
|
const results = await backtestApiV2.getRunResults(run.id);
|
||||||
setRunResults(results);
|
setRunResults(results);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Failed to load run results:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load run results');
|
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);
|
startMonitoringRun(run.id);
|
||||||
|
} else {
|
||||||
|
console.warn('Run has unexpected status:', run.status);
|
||||||
}
|
}
|
||||||
}, [runs]);
|
}, [runs]);
|
||||||
|
|
||||||
// Monitor run progress
|
// Monitor run progress
|
||||||
const startMonitoringRun = (runId: string) => {
|
const startMonitoringRun = useCallback((runId: string) => {
|
||||||
// Stop any existing monitoring
|
// Stop any existing monitoring
|
||||||
stopMonitoringRun();
|
stopMonitoringRun();
|
||||||
|
|
||||||
// WebSocket updates are handled by the useWebSocket hook in BacktestDetailPageV2
|
// For pending runs, we need to poll until they start or complete
|
||||||
// So we don't need polling here
|
// We'll check the run status by fetching it
|
||||||
console.log('Run monitoring handled by WebSocket, skipping polling');
|
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) => {
|
const startPollingRun = (runId: string) => {
|
||||||
pollingIntervalRef.current = setInterval(async () => {
|
pollingIntervalRef.current = setInterval(async () => {
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,8 @@ export const backtestApiV2 = {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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}`);
|
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`);
|
const response = await fetch(`${API_BASE_URL}/api/v2/runs/${runId}/results`);
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
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 React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
import { setupChartErrorHandler } from './utils/chartErrorHandler';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
// Set up error handler for chart disposal errors
|
||||||
|
setupChartErrorHandler();
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error('Root element not found');
|
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