messy work. backtests / mock-data
This commit is contained in:
parent
4e4a048988
commit
fa70ada2bb
51 changed files with 2576 additions and 887 deletions
215
apps/stock/web-app/src/components/charts/Chart.tsx
Normal file
215
apps/stock/web-app/src/components/charts/Chart.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import * as LightweightCharts from 'lightweight-charts';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface ChartData {
|
||||
time: number;
|
||||
open?: number;
|
||||
high?: number;
|
||||
low?: number;
|
||||
close?: number;
|
||||
value?: number;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
export interface ChartProps {
|
||||
data: ChartData[];
|
||||
height?: number;
|
||||
type?: 'candlestick' | 'line' | 'area';
|
||||
showVolume?: boolean;
|
||||
theme?: 'light' | 'dark';
|
||||
overlayData?: Array<{
|
||||
name: string;
|
||||
data: Array<{ time: number; value: number }>;
|
||||
color?: string;
|
||||
lineWidth?: number;
|
||||
}>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Chart({
|
||||
data,
|
||||
height = 400,
|
||||
type = 'candlestick',
|
||||
showVolume = true,
|
||||
theme = 'dark',
|
||||
overlayData = [],
|
||||
className = '',
|
||||
}: 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());
|
||||
|
||||
// Debug logging
|
||||
console.log('Chart - data received:', data);
|
||||
console.log('Chart - data length:', data?.length);
|
||||
console.log('Chart - data type:', Array.isArray(data) ? 'array' : typeof data);
|
||||
console.log('Chart - first data point:', data?.[0]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current || !data || !data.length) {
|
||||
console.log('Chart - early return:', {
|
||||
hasContainer: !!chartContainerRef.current,
|
||||
hasData: !!data,
|
||||
dataLength: data?.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create chart
|
||||
const chart = LightweightCharts.createChart(chartContainerRef.current, {
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
height: height,
|
||||
layout: {
|
||||
background: {
|
||||
type: LightweightCharts.ColorType.Solid,
|
||||
color: theme === 'dark' ? '#0f0f0f' : '#ffffff'
|
||||
},
|
||||
textColor: theme === 'dark' ? '#d1d5db' : '#374151',
|
||||
},
|
||||
grid: {
|
||||
vertLines: {
|
||||
color: theme === 'dark' ? '#1f2937' : '#e5e7eb',
|
||||
visible: true,
|
||||
},
|
||||
horzLines: {
|
||||
color: theme === 'dark' ? '#1f2937' : '#e5e7eb',
|
||||
visible: true,
|
||||
},
|
||||
},
|
||||
crosshair: {
|
||||
mode: LightweightCharts.CrosshairMode.Normal,
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: theme === 'dark' ? '#1f2937' : '#e5e7eb',
|
||||
autoScale: true,
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: theme === 'dark' ? '#1f2937' : '#e5e7eb',
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
},
|
||||
});
|
||||
|
||||
chartRef.current = chart;
|
||||
|
||||
// Create main series
|
||||
if (type === 'candlestick' && data[0].open !== undefined) {
|
||||
mainSeriesRef.current = chart.addCandlestickSeries({
|
||||
upColor: '#10b981',
|
||||
downColor: '#ef4444',
|
||||
borderUpColor: '#10b981',
|
||||
borderDownColor: '#ef4444',
|
||||
wickUpColor: '#10b981',
|
||||
wickDownColor: '#ef4444',
|
||||
});
|
||||
mainSeriesRef.current.setData(data as LightweightCharts.CandlestickData[]);
|
||||
} else if (type === 'line' || (type === 'candlestick' && data[0].value !== undefined)) {
|
||||
mainSeriesRef.current = chart.addLineSeries({
|
||||
color: '#3b82f6',
|
||||
lineWidth: 2,
|
||||
});
|
||||
const lineData = data.map(d => ({
|
||||
time: d.time,
|
||||
value: d.value ?? d.close ?? 0
|
||||
}));
|
||||
mainSeriesRef.current.setData(lineData);
|
||||
} else if (type === 'area') {
|
||||
mainSeriesRef.current = chart.addAreaSeries({
|
||||
lineColor: '#3b82f6',
|
||||
topColor: '#3b82f6',
|
||||
bottomColor: 'rgba(59, 130, 246, 0.1)',
|
||||
lineWidth: 2,
|
||||
});
|
||||
const areaData = data.map(d => ({
|
||||
time: d.time,
|
||||
value: d.value ?? d.close ?? 0
|
||||
}));
|
||||
mainSeriesRef.current.setData(areaData);
|
||||
}
|
||||
|
||||
// Add volume if available
|
||||
if (showVolume && data.some(d => d.volume)) {
|
||||
volumeSeriesRef.current = chart.addHistogramSeries({
|
||||
color: '#3b82f680',
|
||||
priceFormat: {
|
||||
type: 'volume',
|
||||
},
|
||||
priceScaleId: 'volume',
|
||||
});
|
||||
|
||||
volumeSeriesRef.current.priceScale().applyOptions({
|
||||
scaleMargins: {
|
||||
top: 0.8,
|
||||
bottom: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const volumeData = data
|
||||
.filter(d => d.volume !== undefined)
|
||||
.map(d => ({
|
||||
time: d.time,
|
||||
value: d.volume!,
|
||||
color: d.close && d.open ?
|
||||
(d.close >= d.open ? '#10b98140' : '#ef444440') :
|
||||
'#3b82f640',
|
||||
}));
|
||||
volumeSeriesRef.current.setData(volumeData);
|
||||
}
|
||||
|
||||
// Add overlay series
|
||||
overlaySeriesRef.current.clear();
|
||||
overlayData.forEach((overlay, index) => {
|
||||
const series = chart.addLineSeries({
|
||||
color: overlay.color || ['#ff9800', '#4caf50', '#9c27b0', '#f44336'][index % 4],
|
||||
lineWidth: overlay.lineWidth || 2,
|
||||
title: overlay.name,
|
||||
priceScaleId: index === 0 ? '' : `overlay-${index}`, // First overlay uses main scale
|
||||
});
|
||||
|
||||
if (index > 0) {
|
||||
series.priceScale().applyOptions({
|
||||
scaleMargins: {
|
||||
top: 0.1,
|
||||
bottom: 0.1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
series.setData(overlay.data);
|
||||
overlaySeriesRef.current.set(overlay.name, series);
|
||||
});
|
||||
|
||||
// Fit content
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
if (chartContainerRef.current && chart) {
|
||||
chart.applyOptions({
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (chart) {
|
||||
chart.remove();
|
||||
}
|
||||
};
|
||||
}, [data, height, type, showVolume, theme, overlayData]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div
|
||||
ref={chartContainerRef}
|
||||
style={{ width: '100%', height: `${height}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
apps/stock/web-app/src/components/charts/index.ts
Normal file
2
apps/stock/web-app/src/components/charts/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Chart } from './Chart';
|
||||
export type { ChartProps, ChartData } from './Chart';
|
||||
119
apps/stock/web-app/src/features/backtest/BacktestListPage.tsx
Normal file
119
apps/stock/web-app/src/features/backtest/BacktestListPage.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useBacktestList } from './hooks/useBacktest';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function BacktestListPage() {
|
||||
const { backtests, isLoading, error, loadBacktests } = useBacktestList();
|
||||
|
||||
useEffect(() => {
|
||||
loadBacktests();
|
||||
}, [loadBacktests]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'text-success';
|
||||
case 'running':
|
||||
return 'text-primary-400';
|
||||
case 'failed':
|
||||
return 'text-error';
|
||||
case 'cancelled':
|
||||
return 'text-text-muted';
|
||||
default:
|
||||
return 'text-text-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-6">
|
||||
<div className="flex-shrink-0 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-primary mb-2">Backtest History</h1>
|
||||
<p className="text-text-secondary text-sm">
|
||||
View and manage your backtest runs
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/backtests/new"
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
New Backtest
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-error/10 border border-error text-error px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-text-secondary">Loading backtests...</div>
|
||||
</div>
|
||||
) : backtests.length === 0 ? (
|
||||
<div className="bg-surface-secondary p-8 rounded-lg border border-border text-center">
|
||||
<p className="text-text-secondary mb-4">No backtests found</p>
|
||||
<Link
|
||||
to="/backtests/new"
|
||||
className="inline-flex px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
Create Your First Backtest
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface-secondary rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-background border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-text-secondary">ID</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-text-secondary">Strategy</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-text-secondary">Symbols</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-text-secondary">Period</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-text-secondary">Status</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-text-secondary">Created</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-text-secondary">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backtests.map((backtest) => (
|
||||
<tr key={backtest.id} className="border-b border-border hover:bg-background/50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-text-primary font-mono">
|
||||
{backtest.id.slice(0, 8)}...
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-primary capitalize">
|
||||
{backtest.strategy}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-primary">
|
||||
{backtest.symbols.join(', ')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-primary">
|
||||
{new Date(backtest.startDate).toLocaleDateString()} - {new Date(backtest.endDate).toLocaleDateString()}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm font-medium capitalize ${getStatusColor(backtest.status)}`}>
|
||||
{backtest.status}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||
{formatDate(backtest.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<Link
|
||||
to={`/backtests/${backtest.id}`}
|
||||
className="text-primary-400 hover:text-primary-300 font-medium"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +1,114 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BacktestConfiguration } from './components/BacktestConfiguration';
|
||||
import { BacktestResults } from './components/BacktestResults';
|
||||
import { BacktestControls } from './components/BacktestControls';
|
||||
import { BacktestResults } from './components/BacktestResults';
|
||||
import { useBacktest } from './hooks/useBacktest';
|
||||
import type { BacktestConfig, BacktestResult as LocalBacktestResult } from './types/backtest.types';
|
||||
|
||||
export function BacktestPage() {
|
||||
const {
|
||||
config,
|
||||
status,
|
||||
backtest,
|
||||
results,
|
||||
currentTime,
|
||||
error,
|
||||
isLoading,
|
||||
handleConfigSubmit,
|
||||
handleStart,
|
||||
handlePause,
|
||||
handleResume,
|
||||
handleStop,
|
||||
handleStep,
|
||||
isPolling,
|
||||
error,
|
||||
createBacktest,
|
||||
cancelBacktest,
|
||||
reset,
|
||||
} = useBacktest();
|
||||
|
||||
// Local state to bridge between the API format and the existing UI components
|
||||
const [config, setConfig] = useState<BacktestConfig | null>(null);
|
||||
const [adaptedResults, setAdaptedResults] = useState<LocalBacktestResult | null>(null);
|
||||
|
||||
// Adapt the backtest status from API format to local format
|
||||
const status = backtest ?
|
||||
(backtest.status === 'pending' ? 'configured' :
|
||||
backtest.status === 'running' ? 'running' :
|
||||
backtest.status === 'completed' ? 'completed' :
|
||||
backtest.status === 'failed' ? 'error' :
|
||||
backtest.status === 'cancelled' ? 'stopped' : 'idle') : 'idle';
|
||||
|
||||
// Current time is not available in the new API, so we'll estimate it based on progress
|
||||
const currentTime = null;
|
||||
|
||||
// Adapt the results when they come in
|
||||
useEffect(() => {
|
||||
if (results && config) {
|
||||
setAdaptedResults({
|
||||
id: backtest?.id || '',
|
||||
config,
|
||||
metrics: {
|
||||
totalReturn: results.metrics.totalReturn,
|
||||
sharpeRatio: results.metrics.sharpeRatio,
|
||||
maxDrawdown: results.metrics.maxDrawdown,
|
||||
winRate: results.metrics.winRate,
|
||||
totalTrades: results.metrics.totalTrades,
|
||||
profitableTrades: Math.round(results.metrics.totalTrades * results.metrics.winRate / 100),
|
||||
},
|
||||
positions: [], // Not provided by current API
|
||||
trades: results.trades?.map(t => ({
|
||||
id: `${t.symbol}-${t.entryDate}`,
|
||||
timestamp: t.exitDate,
|
||||
symbol: t.symbol,
|
||||
side: t.pnl > 0 ? 'buy' : 'sell',
|
||||
quantity: t.quantity,
|
||||
price: t.exitPrice,
|
||||
commission: 0,
|
||||
pnl: t.pnl,
|
||||
})) || [],
|
||||
performanceData: results.equity.map(e => ({
|
||||
timestamp: e.date,
|
||||
portfolioValue: e.value,
|
||||
pnl: 0, // Would need to calculate from equity curve
|
||||
drawdown: 0, // Would need to calculate
|
||||
})),
|
||||
});
|
||||
}
|
||||
}, [results, config, backtest]);
|
||||
|
||||
const handleConfigSubmit = useCallback(async (newConfig: BacktestConfig) => {
|
||||
setConfig(newConfig);
|
||||
setAdaptedResults(null);
|
||||
|
||||
// Convert local config to API format
|
||||
await createBacktest({
|
||||
strategy: newConfig.strategy,
|
||||
symbols: newConfig.symbols,
|
||||
startDate: newConfig.startDate.toISOString().split('T')[0],
|
||||
endDate: newConfig.endDate.toISOString().split('T')[0],
|
||||
initialCapital: newConfig.initialCapital,
|
||||
config: {
|
||||
commission: newConfig.commission,
|
||||
slippage: newConfig.slippage,
|
||||
speedMultiplier: newConfig.speedMultiplier,
|
||||
},
|
||||
});
|
||||
}, [createBacktest]);
|
||||
|
||||
const handleStart = useCallback(() => {
|
||||
// Backtest starts automatically after creation in the new API
|
||||
// Nothing to do here
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
// Pause not supported in current API
|
||||
console.warn('Pause not supported in current API');
|
||||
}, []);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
// Resume not supported in current API
|
||||
console.warn('Resume not supported in current API');
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
await cancelBacktest();
|
||||
}, [cancelBacktest]);
|
||||
|
||||
const handleStep = useCallback(() => {
|
||||
// Step not supported in current API
|
||||
console.warn('Step not supported in current API');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-6">
|
||||
|
|
@ -38,7 +129,7 @@ export function BacktestPage() {
|
|||
<div className="lg:col-span-1 space-y-4">
|
||||
<BacktestConfiguration
|
||||
onSubmit={handleConfigSubmit}
|
||||
disabled={status === 'running' || isLoading}
|
||||
disabled={status === 'running' || isLoading || isPolling}
|
||||
/>
|
||||
|
||||
{config && (
|
||||
|
|
@ -59,7 +150,7 @@ export function BacktestPage() {
|
|||
<div className="lg:col-span-2">
|
||||
<BacktestResults
|
||||
status={status}
|
||||
results={results}
|
||||
results={adaptedResults}
|
||||
currentTime={currentTime}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import type { BacktestResult, BacktestStatus } from '../types';
|
||||
import type { BacktestStatus } from '../types';
|
||||
import type { BacktestResult } from '../services/backtestApi';
|
||||
import { MetricsCard } from './MetricsCard';
|
||||
import { PositionsTable } from './PositionsTable';
|
||||
import { TradeLog } from './TradeLog';
|
||||
import { Chart } from '../../../components/charts';
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface BacktestResultsProps {
|
||||
status: BacktestStatus;
|
||||
|
|
@ -10,6 +13,11 @@ interface BacktestResultsProps {
|
|||
}
|
||||
|
||||
export function BacktestResults({ status, results, currentTime }: BacktestResultsProps) {
|
||||
// Debug logging
|
||||
console.log('BacktestResults - results:', results);
|
||||
console.log('BacktestResults - ohlcData keys:', results?.ohlcData ? Object.keys(results.ohlcData) : 'No ohlcData');
|
||||
console.log('BacktestResults - first symbol data:', results?.ohlcData && Object.keys(results.ohlcData).length > 0 ? results.ohlcData[Object.keys(results.ohlcData)[0]] : 'No data');
|
||||
console.log('BacktestResults - equity data:', results?.equity);
|
||||
if (status === 'idle') {
|
||||
return (
|
||||
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
|
||||
|
|
@ -99,41 +107,98 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult
|
|||
title="Total Trades"
|
||||
value={results.metrics.totalTrades.toString()}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Profitable Trades"
|
||||
value={results.metrics.profitableTrades.toString()}
|
||||
/>
|
||||
{results.metrics.profitFactor && (
|
||||
<MetricsCard
|
||||
title="Profit Factor"
|
||||
value={results.metrics.profitFactor.toFixed(2)}
|
||||
trend={results.metrics.profitFactor >= 1 ? 'up' : 'down'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Performance Chart Placeholder */}
|
||||
{/* Performance Chart */}
|
||||
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
|
||||
<h3 className="text-base font-medium text-text-primary mb-4">
|
||||
Portfolio Performance
|
||||
</h3>
|
||||
<div className="h-64 bg-background rounded border border-border flex items-center justify-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
Performance chart will be displayed here (requires recharts)
|
||||
</p>
|
||||
</div>
|
||||
{(() => {
|
||||
const hasOhlcData = results.ohlcData && Object.keys(results.ohlcData).length > 0;
|
||||
const hasEquityData = results.equity && results.equity.length > 0;
|
||||
|
||||
console.log('Chart section - hasOhlcData:', hasOhlcData);
|
||||
console.log('Chart section - hasEquityData:', hasEquityData);
|
||||
|
||||
if (hasOhlcData) {
|
||||
const firstSymbol = Object.keys(results.ohlcData)[0];
|
||||
const ohlcData = results.ohlcData[firstSymbol];
|
||||
console.log('Chart section - using OHLC data for symbol:', firstSymbol);
|
||||
console.log('Chart section - OHLC data points:', ohlcData?.length);
|
||||
|
||||
return (
|
||||
<Chart
|
||||
data={ohlcData}
|
||||
height={400}
|
||||
type="candlestick"
|
||||
showVolume={true}
|
||||
theme="dark"
|
||||
overlayData={hasEquityData ? [
|
||||
{
|
||||
name: 'Portfolio Value',
|
||||
data: results.equity.map(point => ({
|
||||
time: Math.floor(new Date(point.date).getTime() / 1000),
|
||||
value: point.value
|
||||
})),
|
||||
color: '#10b981',
|
||||
lineWidth: 3
|
||||
}
|
||||
] : []}
|
||||
className="rounded"
|
||||
/>
|
||||
);
|
||||
} else if (hasEquityData) {
|
||||
console.log('Chart section - using equity data only');
|
||||
return (
|
||||
<Chart
|
||||
data={results.equity.map(point => ({
|
||||
time: Math.floor(new Date(point.date).getTime() / 1000),
|
||||
value: point.value
|
||||
}))}
|
||||
height={400}
|
||||
type="area"
|
||||
showVolume={false}
|
||||
theme="dark"
|
||||
className="rounded"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
console.log('Chart section - showing no data message');
|
||||
return (
|
||||
<div className="h-96 bg-background rounded border border-border flex items-center justify-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
No data available
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Positions Table */}
|
||||
{results.positions.length > 0 && (
|
||||
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
|
||||
<h3 className="text-base font-medium text-text-primary mb-4">
|
||||
Current Positions
|
||||
</h3>
|
||||
<PositionsTable positions={results.positions} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trade Log */}
|
||||
{results.trades.length > 0 && (
|
||||
{results.trades && results.trades.length > 0 && (
|
||||
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
|
||||
<h3 className="text-base font-medium text-text-primary mb-4">
|
||||
Trade History
|
||||
</h3>
|
||||
<TradeLog trades={results.trades} />
|
||||
<TradeLog trades={results.trades.map(trade => ({
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: trade.entryDate,
|
||||
symbol: trade.symbol,
|
||||
side: 'buy' as const,
|
||||
quantity: trade.quantity,
|
||||
price: trade.entryPrice,
|
||||
commission: 0,
|
||||
pnl: trade.pnl
|
||||
}))} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,169 +1,175 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { BacktestService } from '../services/backtestService';
|
||||
import type { BacktestConfig, BacktestResult, BacktestStatus } from '../types';
|
||||
import type { BacktestJob, BacktestRequest, BacktestResult } from '../services/backtestApi';
|
||||
import { backtestApi, } from '../services/backtestApi';
|
||||
|
||||
export function useBacktest() {
|
||||
const [backtestId, setBacktestId] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<BacktestConfig | null>(null);
|
||||
const [status, setStatus] = useState<BacktestStatus>('idle');
|
||||
interface UseBacktestReturn {
|
||||
// State
|
||||
backtest: BacktestJob | null;
|
||||
results: BacktestResult | null;
|
||||
isLoading: boolean;
|
||||
isPolling: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
createBacktest: (request: BacktestRequest) => Promise<void>;
|
||||
cancelBacktest: () => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useBacktest(): UseBacktestReturn {
|
||||
const [backtest, setBacktest] = useState<BacktestJob | null>(null);
|
||||
const [results, setResults] = useState<BacktestResult | null>(null);
|
||||
const [currentTime, setCurrentTime] = useState<number | null>(null);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const handleConfigSubmit = useCallback(async (newConfig: BacktestConfig) => {
|
||||
// Poll for status updates
|
||||
const pollStatus = useCallback(async (backtestId: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const updatedBacktest = await backtestApi.getBacktest(backtestId);
|
||||
setBacktest(updatedBacktest);
|
||||
|
||||
if (updatedBacktest.status === 'completed') {
|
||||
// Fetch results
|
||||
const backtestResults = await backtestApi.getBacktestResults(backtestId);
|
||||
setResults(backtestResults);
|
||||
setIsPolling(false);
|
||||
|
||||
// Clear polling interval
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
} else if (updatedBacktest.status === 'failed' || updatedBacktest.status === 'cancelled') {
|
||||
setIsPolling(false);
|
||||
|
||||
// Clear polling interval
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (updatedBacktest.status === 'failed' && updatedBacktest.error) {
|
||||
setError(updatedBacktest.error);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error polling backtest status:', err);
|
||||
// Don't stop polling on transient errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create a new backtest
|
||||
const createBacktest = useCallback(async (request: BacktestRequest) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
|
||||
try {
|
||||
const newBacktest = await backtestApi.createBacktest(request);
|
||||
setBacktest(newBacktest);
|
||||
|
||||
// Create backtest
|
||||
const { id } = await BacktestService.createBacktest(newConfig);
|
||||
// Start polling for updates
|
||||
setIsPolling(true);
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
pollStatus(newBacktest.id);
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
setBacktestId(id);
|
||||
setConfig(newConfig);
|
||||
setStatus('configured');
|
||||
setResults(null);
|
||||
setCurrentTime(null);
|
||||
setProgress(0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create backtest');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [pollStatus]);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
if (!backtestId) return;
|
||||
// Cancel running backtest
|
||||
const cancelBacktest = useCallback(async () => {
|
||||
if (!backtest || backtest.status !== 'running') return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
await backtestApi.cancelBacktest(backtest.id);
|
||||
setBacktest({ ...backtest, status: 'cancelled' });
|
||||
setIsPolling(false);
|
||||
|
||||
await BacktestService.startBacktest(backtestId);
|
||||
setStatus('running');
|
||||
|
||||
// Start polling for updates
|
||||
cleanupRef.current = await BacktestService.pollBacktestUpdates(
|
||||
backtestId,
|
||||
(newStatus, newProgress, newTime) => {
|
||||
setStatus(newStatus);
|
||||
if (newProgress !== undefined) setProgress(newProgress);
|
||||
if (newTime !== undefined) setCurrentTime(newTime);
|
||||
|
||||
// Fetch full results when completed
|
||||
if (newStatus === 'completed') {
|
||||
BacktestService.getBacktestResults(backtestId).then(setResults);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start backtest');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [backtestId]);
|
||||
|
||||
const handlePause = useCallback(async () => {
|
||||
if (!backtestId) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await BacktestService.pauseBacktest(backtestId);
|
||||
setStatus('paused');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to pause backtest');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [backtestId]);
|
||||
|
||||
const handleResume = useCallback(async () => {
|
||||
if (!backtestId) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await BacktestService.resumeBacktest(backtestId);
|
||||
setStatus('running');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to resume backtest');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [backtestId]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!backtestId) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await BacktestService.stopBacktest(backtestId);
|
||||
setStatus('stopped');
|
||||
|
||||
// Stop polling
|
||||
if (cleanupRef.current) {
|
||||
cleanupRef.current();
|
||||
cleanupRef.current = null;
|
||||
// Clear polling interval
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to stop backtest');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel backtest');
|
||||
}
|
||||
}, [backtestId]);
|
||||
}, [backtest]);
|
||||
|
||||
const handleStep = useCallback(async () => {
|
||||
if (!backtestId) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await BacktestService.stepBacktest(backtestId);
|
||||
|
||||
// Get updated status
|
||||
const statusUpdate = await BacktestService.getBacktestStatus(backtestId);
|
||||
setStatus(statusUpdate.status);
|
||||
if (statusUpdate.progress !== undefined) setProgress(statusUpdate.progress);
|
||||
if (statusUpdate.currentTime !== undefined) setCurrentTime(statusUpdate.currentTime);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to step backtest');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// Reset state
|
||||
const reset = useCallback(() => {
|
||||
setBacktest(null);
|
||||
setResults(null);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
setIsPolling(false);
|
||||
|
||||
// Clear polling interval
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
}, [backtestId]);
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cleanupRef.current) {
|
||||
cleanupRef.current();
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backtestId,
|
||||
config,
|
||||
status,
|
||||
backtest,
|
||||
results,
|
||||
currentTime,
|
||||
progress,
|
||||
error,
|
||||
isLoading,
|
||||
handleConfigSubmit,
|
||||
handleStart,
|
||||
handlePause,
|
||||
handleResume,
|
||||
handleStop,
|
||||
handleStep,
|
||||
isPolling,
|
||||
error,
|
||||
createBacktest,
|
||||
cancelBacktest,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
// Separate hook for listing backtests
|
||||
interface UseBacktestListReturn {
|
||||
backtests: BacktestJob[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
loadBacktests: (limit?: number, offset?: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useBacktestList(): UseBacktestListReturn {
|
||||
const [backtests, setBacktests] = useState<BacktestJob[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadBacktests = useCallback(async (limit = 50, offset = 0) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const list = await backtestApi.listBacktests(limit, offset);
|
||||
setBacktests(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load backtests');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backtests,
|
||||
isLoading,
|
||||
error,
|
||||
loadBacktests,
|
||||
};
|
||||
}
|
||||
119
apps/stock/web-app/src/features/backtest/services/backtestApi.ts
Normal file
119
apps/stock/web-app/src/features/backtest/services/backtestApi.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:2003';
|
||||
|
||||
export interface BacktestRequest {
|
||||
strategy: string;
|
||||
symbols: string[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
initialCapital: number;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BacktestJob {
|
||||
id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
strategy: string;
|
||||
symbols: string[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
initialCapital: number;
|
||||
config: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BacktestResult {
|
||||
backtestId: string;
|
||||
metrics: {
|
||||
totalReturn: number;
|
||||
sharpeRatio: number;
|
||||
maxDrawdown: number;
|
||||
winRate: number;
|
||||
totalTrades: number;
|
||||
profitFactor?: number;
|
||||
};
|
||||
equity: Array<{ date: string; value: number }>;
|
||||
trades?: Array<{
|
||||
symbol: string;
|
||||
entryDate: string;
|
||||
exitDate: string;
|
||||
entryPrice: number;
|
||||
exitPrice: number;
|
||||
quantity: number;
|
||||
pnl: number;
|
||||
}>;
|
||||
ohlcData?: Record<string, Array<{
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume?: number;
|
||||
}>>;
|
||||
}
|
||||
|
||||
export const backtestApi = {
|
||||
// Create a new backtest
|
||||
async createBacktest(request: BacktestRequest): Promise<BacktestJob> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/backtests`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create backtest: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Get backtest status
|
||||
async getBacktest(id: string): Promise<BacktestJob> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/backtests/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get backtest: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Get backtest results
|
||||
async getBacktestResults(id: string): Promise<BacktestResult> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/backtests/${id}/results`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get results: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// List all backtests
|
||||
async listBacktests(limit = 50, offset = 0): Promise<BacktestJob[]> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/backtests?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list backtests: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Cancel a running backtest
|
||||
async cancelBacktest(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/backtests/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to cancel backtest: ${response.statusText}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue