initial charts / backtest

This commit is contained in:
Boki 2025-07-02 19:58:43 -04:00
parent 11c24b2280
commit 1b9010ebf4
37 changed files with 3888 additions and 23 deletions

View file

@ -0,0 +1,69 @@
import { BacktestConfiguration } from './components/BacktestConfiguration';
import { BacktestResults } from './components/BacktestResults';
import { BacktestControls } from './components/BacktestControls';
import { useBacktest } from './hooks/useBacktest';
export function BacktestPage() {
const {
config,
status,
results,
currentTime,
error,
isLoading,
handleConfigSubmit,
handleStart,
handlePause,
handleResume,
handleStop,
handleStep,
} = useBacktest();
return (
<div className="flex flex-col h-full space-y-6">
<div className="flex-shrink-0">
<h1 className="text-lg font-bold text-text-primary mb-2">Backtest Strategy</h1>
<p className="text-text-secondary mb-6 text-sm">
Test your trading strategies against historical data to evaluate performance and risk.
</p>
</div>
{error && (
<div className="bg-error/10 border border-error text-error px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
<div className="lg:col-span-1 space-y-4">
<BacktestConfiguration
onSubmit={handleConfigSubmit}
disabled={status === 'running' || isLoading}
/>
{config && (
<BacktestControls
status={status}
onStart={handleStart}
onPause={handlePause}
onResume={handleResume}
onStop={handleStop}
onStep={handleStep}
currentTime={currentTime}
startTime={config.startDate.getTime()}
endTime={config.endDate.getTime()}
/>
)}
</div>
<div className="lg:col-span-2">
<BacktestResults
status={status}
results={results}
currentTime={currentTime}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,257 @@
import { useState } from 'react';
import type { BacktestConfig } from '../types';
interface BacktestConfigurationProps {
onSubmit: (config: BacktestConfig) => void;
disabled?: boolean;
}
export function BacktestConfiguration({ onSubmit, disabled }: BacktestConfigurationProps) {
const [formData, setFormData] = useState<BacktestConfig>({
name: '',
startDate: new Date(new Date().setMonth(new Date().getMonth() - 6)), // 6 months ago
endDate: new Date(),
initialCapital: 100000,
symbols: [],
strategy: 'momentum',
speedMultiplier: 1,
commission: 0.001, // 0.1%
slippage: 0.0005, // 0.05%
});
const [symbolInput, setSymbolInput] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (formData.symbols.length === 0) {
alert('Please add at least one symbol');
return;
}
onSubmit(formData);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'number' ? parseFloat(value) :
type === 'date' ? new Date(value) : value
}));
};
const handleAddSymbol = () => {
const symbol = symbolInput.trim().toUpperCase();
if (symbol && !formData.symbols.includes(symbol)) {
setFormData(prev => ({
...prev,
symbols: [...prev.symbols, symbol]
}));
setSymbolInput('');
}
};
const handleRemoveSymbol = (symbol: string) => {
setFormData(prev => ({
...prev,
symbols: prev.symbols.filter(s => s !== symbol)
}));
};
const formatDate = (date: Date) => {
return date.toISOString().split('T')[0];
};
return (
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
<h2 className="text-base font-medium text-text-primary mb-4">Configuration</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Backtest Name
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="My Strategy Backtest"
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Start Date
</label>
<input
type="date"
name="startDate"
value={formatDate(formData.startDate)}
onChange={handleChange}
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
End Date
</label>
<input
type="date"
name="endDate"
value={formatDate(formData.endDate)}
onChange={handleChange}
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Initial Capital ($)
</label>
<input
type="number"
name="initialCapital"
value={formData.initialCapital}
onChange={handleChange}
min="1000"
step="1000"
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Symbols
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={symbolInput}
onChange={(e) => setSymbolInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddSymbol())}
placeholder="AAPL"
className="flex-1 px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
/>
<button
type="button"
onClick={handleAddSymbol}
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors disabled:opacity-50"
disabled={disabled}
>
Add
</button>
</div>
<div className="flex flex-wrap gap-2">
{formData.symbols.map(symbol => (
<span
key={symbol}
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-500/10 text-primary-400 rounded-md text-sm"
>
{symbol}
<button
type="button"
onClick={() => handleRemoveSymbol(symbol)}
className="text-primary-400 hover:text-primary-300"
disabled={disabled}
>
×
</button>
</span>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Strategy
</label>
<select
name="strategy"
value={formData.strategy}
onChange={handleChange}
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
>
<option value="momentum">Momentum</option>
<option value="mean-reversion">Mean Reversion</option>
<option value="pairs-trading">Pairs Trading</option>
<option value="custom">Custom</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Commission (%)
</label>
<input
type="number"
name="commission"
value={formData.commission * 100}
onChange={(e) => setFormData(prev => ({ ...prev, commission: parseFloat(e.target.value) / 100 }))}
min="0"
step="0.01"
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Slippage (%)
</label>
<input
type="number"
name="slippage"
value={formData.slippage * 100}
onChange={(e) => setFormData(prev => ({ ...prev, slippage: parseFloat(e.target.value) / 100 }))}
min="0"
step="0.01"
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Speed Multiplier
</label>
<input
type="number"
name="speedMultiplier"
value={formData.speedMultiplier}
onChange={handleChange}
min="0.1"
max="1000"
step="0.1"
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
/>
<p className="text-xs text-text-muted mt-1">1x = real-time, 10x = 10x faster</p>
</div>
<button
type="submit"
className="w-full px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={disabled}
>
Configure Backtest
</button>
</form>
</div>
);
}

View file

@ -0,0 +1,147 @@
import {
ArrowPathIcon,
ForwardIcon,
PauseIcon,
PlayIcon,
StopIcon,
} from '@heroicons/react/24/solid';
import type { BacktestStatus } from '../types';
interface BacktestControlsProps {
status: BacktestStatus;
onStart: () => void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
onStep: () => void;
currentTime: number | null;
startTime: number;
endTime: number;
}
export function BacktestControls({
status,
onStart,
onPause,
onResume,
onStop,
onStep,
currentTime,
startTime,
endTime,
}: BacktestControlsProps) {
const progress = currentTime
? ((currentTime - startTime) / (endTime - startTime)) * 100
: 0;
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
return (
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
<h2 className="text-base font-medium text-text-primary mb-4">Controls</h2>
<div className="space-y-4">
<div className="flex gap-2">
{status === 'configured' || status === 'stopped' ? (
<button
onClick={onStart}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-success text-white rounded-md text-sm font-medium hover:bg-success/90 transition-colors"
>
<PlayIcon className="w-4 h-4" />
Start
</button>
) : status === 'running' ? (
<button
onClick={onPause}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-warning text-white rounded-md text-sm font-medium hover:bg-warning/90 transition-colors"
>
<PauseIcon className="w-4 h-4" />
Pause
</button>
) : status === 'paused' ? (
<button
onClick={onResume}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-success text-white rounded-md text-sm font-medium hover:bg-success/90 transition-colors"
>
<PlayIcon className="w-4 h-4" />
Resume
</button>
) : null}
{(status === 'running' || status === 'paused') && (
<button
onClick={onStop}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-error text-white rounded-md text-sm font-medium hover:bg-error/90 transition-colors"
>
<StopIcon className="w-4 h-4" />
Stop
</button>
)}
{status === 'paused' && (
<button
onClick={onStep}
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
>
<ForwardIcon className="w-4 h-4" />
Step
</button>
)}
</div>
{status !== 'idle' && status !== 'configured' && (
<>
<div>
<div className="flex justify-between text-sm text-text-secondary mb-1">
<span>Progress</span>
<span>{progress.toFixed(1)}%</span>
</div>
<div className="w-full bg-background rounded-full h-2 overflow-hidden">
<div
className="bg-primary-500 h-2 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-secondary">Status</span>
<span className={`text-text-primary font-medium ${
status === 'running' ? 'text-success' :
status === 'paused' ? 'text-warning' :
status === 'completed' ? 'text-primary-400' :
status === 'error' ? 'text-error' :
'text-text-muted'
}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</div>
{currentTime && (
<div className="flex justify-between">
<span className="text-text-secondary">Current Time</span>
<span className="text-text-primary text-xs">
{formatTime(currentTime)}
</span>
</div>
)}
</div>
</>
)}
{status === 'completed' && (
<button
onClick={onStart}
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
>
<ArrowPathIcon className="w-4 h-4" />
Run Again
</button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,141 @@
import type { BacktestResult, BacktestStatus } from '../types';
import { MetricsCard } from './MetricsCard';
import { PositionsTable } from './PositionsTable';
import { TradeLog } from './TradeLog';
interface BacktestResultsProps {
status: BacktestStatus;
results: BacktestResult | null;
currentTime: number | null;
}
export function BacktestResults({ status, results, currentTime }: BacktestResultsProps) {
if (status === 'idle') {
return (
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
<div className="text-center">
<h3 className="text-lg font-medium text-text-primary mb-2">
Configure Your Backtest
</h3>
<p className="text-sm text-text-secondary">
Set up your strategy parameters and click "Configure Backtest" to begin.
</p>
</div>
</div>
);
}
if (status === 'configured') {
return (
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
<div className="text-center">
<h3 className="text-lg font-medium text-text-primary mb-2">
Ready to Start
</h3>
<p className="text-sm text-text-secondary">
Click the "Start" button to begin backtesting your strategy.
</p>
</div>
</div>
);
}
if (status === 'running' && !results) {
return (
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-primary-500 mx-auto mb-4"></div>
<h3 className="text-lg font-medium text-text-primary mb-2">
Running Backtest...
</h3>
<p className="text-sm text-text-secondary">
Processing historical data and executing trades.
</p>
</div>
</div>
);
}
if (!results) {
return (
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
<div className="text-center">
<h3 className="text-lg font-medium text-text-primary mb-2">
No Results Yet
</h3>
<p className="text-sm text-text-secondary">
Results will appear here once the backtest is complete.
</p>
</div>
</div>
);
}
return (
<div className="space-y-6 h-full overflow-y-auto">
{/* Metrics Overview */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<MetricsCard
title="Total Return"
value={`${results.metrics.totalReturn >= 0 ? '+' : ''}${results.metrics.totalReturn.toFixed(2)}%`}
trend={results.metrics.totalReturn >= 0 ? 'up' : 'down'}
/>
<MetricsCard
title="Sharpe Ratio"
value={results.metrics.sharpeRatio.toFixed(2)}
trend={results.metrics.sharpeRatio >= 1 ? 'up' : 'down'}
/>
<MetricsCard
title="Max Drawdown"
value={`${results.metrics.maxDrawdown.toFixed(2)}%`}
trend="down"
/>
<MetricsCard
title="Win Rate"
value={`${results.metrics.winRate.toFixed(1)}%`}
trend={results.metrics.winRate >= 50 ? 'up' : 'down'}
/>
<MetricsCard
title="Total Trades"
value={results.metrics.totalTrades.toString()}
/>
<MetricsCard
title="Profitable Trades"
value={results.metrics.profitableTrades.toString()}
/>
</div>
{/* Performance Chart Placeholder */}
<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>
</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 && (
<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} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,38 @@
import { ArrowUpIcon, ArrowDownIcon } from '@heroicons/react/24/solid';
interface MetricsCardProps {
title: string;
value: string;
trend?: 'up' | 'down';
subtitle?: string;
}
export function MetricsCard({ title, value, trend, subtitle }: MetricsCardProps) {
return (
<div className="bg-background p-4 rounded-lg border border-border">
<h4 className="text-sm font-medium text-text-secondary mb-1">{title}</h4>
<div className="flex items-center gap-2">
<p className={`text-2xl font-bold ${
trend === 'up' ? 'text-success' :
trend === 'down' ? 'text-error' :
'text-text-primary'
}`}>
{value}
</p>
{trend && (
<span className={`inline-flex ${
trend === 'up' ? 'text-success' : 'text-error'
}`}>
{trend === 'up' ?
<ArrowUpIcon className="w-4 h-4" /> :
<ArrowDownIcon className="w-4 h-4" />
}
</span>
)}
</div>
{subtitle && (
<p className="text-xs text-text-muted mt-1">{subtitle}</p>
)}
</div>
);
}

View file

@ -0,0 +1,67 @@
import type { Position } from '../types';
interface PositionsTableProps {
positions: Position[];
}
export function PositionsTable({ positions }: PositionsTableProps) {
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const formatPnl = (value: number) => {
const formatted = formatCurrency(Math.abs(value));
return value >= 0 ? `+${formatted}` : `-${formatted}`;
};
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-2 font-medium text-text-secondary">Symbol</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Quantity</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Avg Price</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Current</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">P&L</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Unrealized</th>
</tr>
</thead>
<tbody>
{positions.map((position) => {
const totalPnl = position.realizedPnl + position.unrealizedPnl;
return (
<tr key={position.symbol} className="border-b border-border hover:bg-surface-tertiary">
<td className="py-2 px-2 font-medium text-text-primary">{position.symbol}</td>
<td className="text-right py-2 px-2 text-text-primary">
{position.quantity.toLocaleString()}
</td>
<td className="text-right py-2 px-2 text-text-primary">
{formatCurrency(position.averagePrice)}
</td>
<td className="text-right py-2 px-2 text-text-primary">
{formatCurrency(position.currentPrice)}
</td>
<td className={`text-right py-2 px-2 font-medium ${
totalPnl >= 0 ? 'text-success' : 'text-error'
}`}>
{formatPnl(totalPnl)}
</td>
<td className={`text-right py-2 px-2 ${
position.unrealizedPnl >= 0 ? 'text-success' : 'text-error'
}`}>
{formatPnl(position.unrealizedPnl)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,90 @@
import type { Trade } from '../types';
interface TradeLogProps {
trades: Trade[];
}
export function TradeLog({ trades }: TradeLogProps) {
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const formatTime = (timestamp: string) => {
return new Date(timestamp).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Show latest trades first
const sortedTrades = [...trades].reverse();
return (
<div className="overflow-x-auto max-h-96">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-surface-secondary">
<tr className="border-b border-border">
<th className="text-left py-2 px-2 font-medium text-text-secondary">Time</th>
<th className="text-left py-2 px-2 font-medium text-text-secondary">Symbol</th>
<th className="text-center py-2 px-2 font-medium text-text-secondary">Side</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Quantity</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Price</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Value</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Comm.</th>
{trades.some(t => t.pnl !== undefined) && (
<th className="text-right py-2 px-2 font-medium text-text-secondary">P&L</th>
)}
</tr>
</thead>
<tbody>
{sortedTrades.map((trade) => {
const tradeValue = trade.quantity * trade.price;
return (
<tr key={trade.id} className="border-b border-border hover:bg-surface-tertiary">
<td className="py-2 px-2 text-text-muted text-xs">
{formatTime(trade.timestamp)}
</td>
<td className="py-2 px-2 font-medium text-text-primary">{trade.symbol}</td>
<td className="text-center py-2 px-2">
<span className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
trade.side === 'buy'
? 'bg-success/10 text-success'
: 'bg-error/10 text-error'
}`}>
{trade.side.toUpperCase()}
</span>
</td>
<td className="text-right py-2 px-2 text-text-primary">
{trade.quantity.toLocaleString()}
</td>
<td className="text-right py-2 px-2 text-text-primary">
{formatCurrency(trade.price)}
</td>
<td className="text-right py-2 px-2 text-text-primary">
{formatCurrency(tradeValue)}
</td>
<td className="text-right py-2 px-2 text-text-muted">
{formatCurrency(trade.commission)}
</td>
{trade.pnl !== undefined && (
<td className={`text-right py-2 px-2 font-medium ${
trade.pnl >= 0 ? 'text-success' : 'text-error'
}`}>
{trade.pnl >= 0 ? '+' : ''}{formatCurrency(trade.pnl)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,6 @@
export { BacktestConfiguration } from './BacktestConfiguration';
export { BacktestControls } from './BacktestControls';
export { BacktestResults } from './BacktestResults';
export { MetricsCard } from './MetricsCard';
export { PositionsTable } from './PositionsTable';
export { TradeLog } from './TradeLog';

View file

@ -0,0 +1 @@
export { useBacktest } from './useBacktest';

View file

@ -0,0 +1,169 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { BacktestService } from '../services/backtestService';
import type { BacktestConfig, BacktestResult, BacktestStatus } from '../types';
export function useBacktest() {
const [backtestId, setBacktestId] = useState<string | null>(null);
const [config, setConfig] = useState<BacktestConfig | null>(null);
const [status, setStatus] = useState<BacktestStatus>('idle');
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 cleanupRef = useRef<(() => void) | null>(null);
const handleConfigSubmit = useCallback(async (newConfig: BacktestConfig) => {
try {
setIsLoading(true);
setError(null);
// Create backtest
const { id } = await BacktestService.createBacktest(newConfig);
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);
}
}, []);
const handleStart = useCallback(async () => {
if (!backtestId) return;
try {
setIsLoading(true);
setError(null);
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;
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to stop backtest');
} finally {
setIsLoading(false);
}
}, [backtestId]);
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);
}
}, [backtestId]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (cleanupRef.current) {
cleanupRef.current();
}
};
}, []);
return {
backtestId,
config,
status,
results,
currentTime,
progress,
error,
isLoading,
handleConfigSubmit,
handleStart,
handlePause,
handleResume,
handleStop,
handleStep,
};
}

View file

@ -0,0 +1,2 @@
export { BacktestPage } from './BacktestPage';
export * from './types';

View file

@ -0,0 +1,173 @@
import type { BacktestConfig, BacktestResult, BacktestStatus } from '../types';
const API_BASE = '/api/backtest';
export class BacktestService {
static async createBacktest(config: BacktestConfig): Promise<{ id: string }> {
const response = await fetch(`${API_BASE}/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...config,
startDate: config.startDate.toISOString(),
endDate: config.endDate.toISOString(),
}),
});
if (!response.ok) {
throw new Error('Failed to create backtest');
}
return response.json();
}
static async startBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}/start`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to start backtest');
}
}
static async pauseBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}/pause`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to pause backtest');
}
}
static async resumeBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}/resume`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to resume backtest');
}
}
static async stopBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}/stop`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to stop backtest');
}
}
static async stepBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}/step`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to step backtest');
}
}
static async getBacktestStatus(id: string): Promise<{
status: BacktestStatus;
currentTime?: number;
progress?: number;
}> {
const response = await fetch(`${API_BASE}/${id}/status`);
if (!response.ok) {
throw new Error('Failed to get backtest status');
}
return response.json();
}
static async getBacktestResults(id: string): Promise<BacktestResult> {
const response = await fetch(`${API_BASE}/${id}/results`);
if (!response.ok) {
throw new Error('Failed to get backtest results');
}
const data = await response.json();
// Convert date strings back to Date objects
return {
...data,
config: {
...data.config,
startDate: new Date(data.config.startDate),
endDate: new Date(data.config.endDate),
},
};
}
static async listBacktests(): Promise<BacktestResult[]> {
const response = await fetch(`${API_BASE}/list`);
if (!response.ok) {
throw new Error('Failed to list backtests');
}
const data = await response.json();
// Convert date strings back to Date objects
return data.map((backtest: any) => ({
...backtest,
config: {
...backtest.config,
startDate: new Date(backtest.config.startDate),
endDate: new Date(backtest.config.endDate),
},
}));
}
static async deleteBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete backtest');
}
}
// Helper method to poll for updates
static async pollBacktestUpdates(
id: string,
onUpdate: (status: BacktestStatus, progress?: number, currentTime?: number) => void,
interval: number = 1000
): Promise<() => void> {
let isPolling = true;
const poll = async () => {
while (isPolling) {
try {
const { status, progress, currentTime } = await this.getBacktestStatus(id);
onUpdate(status, progress, currentTime);
if (status === 'completed' || status === 'error' || status === 'stopped') {
isPolling = false;
break;
}
} catch (error) {
console.error('Polling error:', error);
}
await new Promise(resolve => setTimeout(resolve, interval));
}
};
poll();
// Return cleanup function
return () => {
isPolling = false;
};
}
}

View file

@ -0,0 +1,88 @@
export type BacktestStatus =
| 'idle'
| 'configured'
| 'running'
| 'paused'
| 'stopped'
| 'completed'
| 'error';
export interface BacktestConfig {
name: string;
startDate: Date;
endDate: Date;
initialCapital: number;
symbols: string[];
strategy: string;
speedMultiplier: number;
commission: number;
slippage: number;
}
export interface BacktestMetrics {
totalReturn: number;
sharpeRatio: number;
maxDrawdown: number;
winRate: number;
totalTrades: number;
profitableTrades: number;
}
export interface Position {
symbol: string;
quantity: number;
averagePrice: number;
currentPrice: number;
realizedPnl: number;
unrealizedPnl: number;
lastUpdate: string;
}
export interface Trade {
id: string;
timestamp: string;
symbol: string;
side: 'buy' | 'sell';
quantity: number;
price: number;
commission: number;
pnl?: number;
}
export interface PerformanceDataPoint {
timestamp: string;
portfolioValue: number;
pnl: number;
drawdown: number;
}
export interface BacktestResult {
id: string;
config: BacktestConfig;
metrics: BacktestMetrics;
positions: Position[];
trades: Trade[];
performanceData: PerformanceDataPoint[];
}
export interface OrderBookLevel {
price: number;
size: number;
orderCount?: number;
}
export interface OrderBookSnapshot {
symbol: string;
timestamp: string;
bids: OrderBookLevel[];
asks: OrderBookLevel[];
}
export interface RiskMetrics {
currentExposure: number;
dailyPnl: number;
positionCount: number;
grossExposure: number;
maxPositionSize: number;
utilizationPct: number;
}

View file

@ -0,0 +1,12 @@
export type {
BacktestStatus,
BacktestConfig,
BacktestMetrics,
Position,
Trade,
PerformanceDataPoint,
BacktestResult,
OrderBookLevel,
OrderBookSnapshot,
RiskMetrics,
} from './backtest.types';