learning_ai_invt_trdg/web/src/backtest/components/BacktestComparePanel.tsx

594 lines
29 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { runBacktestApi } from '../api';
import type {
BacktestIntraCandlePolicy,
BacktestRequestPayload,
BacktestResult,
BacktestTimeframe,
BacktestTriggerTimeframe
} from '../types';
import { parseSymbolsInput, toDateInputValue } from '../utils';
import { BacktestResultsDashboard } from './BacktestResultsDashboard';
import { Button, Checkbox, Input, Select } from '../../components/ui/Primitives';
type SourceType = 'csv' | 'json' | 'replay' | 'kraken';
export interface BacktestProfile {
id: string;
name: string;
symbols: string;
strategy_config: any;
allocated_capital?: number;
}
interface CompareRow {
profileId: string;
profileName: string;
status: 'success' | 'error';
durationMs: number;
result?: BacktestResult;
error?: string;
}
interface BacktestComparePanelProps {
profiles: BacktestProfile[];
}
const money = (value: number): string =>
Number(value || 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 });
const pct = (value: number): string => `${Number(value || 0).toFixed(2)}%`;
export const BacktestComparePanel: React.FC<BacktestComparePanelProps> = ({ profiles }) => {
const now = Date.now();
const defaultFrom = toDateInputValue(now - (30 * 24 * 60 * 60 * 1000));
const defaultTo = toDateInputValue(now);
const [selectedProfileIds, setSelectedProfileIds] = useState<string[]>([]);
const [timeframe, setTimeframe] = useState<BacktestTimeframe>('15m');
const [fromDate, setFromDate] = useState(defaultFrom);
const [toDate, setToDate] = useState(defaultTo);
const [sourceType, setSourceType] = useState<SourceType>('kraken');
const [sourcePayload, setSourcePayload] = useState<any>({ exchange: 'kraken' });
const [sourceName, setSourceName] = useState('Kraken historical API');
const [slippageBps, setSlippageBps] = useState(5);
const [feeBps, setFeeBps] = useState(10);
const [partialFillPct, setPartialFillPct] = useState(1);
const [fillOnNextBar, setFillOnNextBar] = useState(true);
const [intraCandlePolicy, setIntraCandlePolicy] = useState<BacktestIntraCandlePolicy>('ohlc_path');
const [triggerTimeframe, setTriggerTimeframe] = useState<BacktestTriggerTimeframe>('1m');
const [forceCloseAtWindowEnd, setForceCloseAtWindowEnd] = useState(false);
const [krakenLookbackCandles, setKrakenLookbackCandles] = useState(2000);
const [useNormalizedCapital, setUseNormalizedCapital] = useState(true);
const [normalizedCapitalUsd, setNormalizedCapitalUsd] = useState(1000);
const [running, setRunning] = useState(false);
const [runningIndex, setRunningIndex] = useState(0);
const [rows, setRows] = useState<CompareRow[]>([]);
const [selectedResultProfileId, setSelectedResultProfileId] = useState<string>('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const existing = new Set(profiles.map((profile) => profile.id));
setSelectedProfileIds((prev) => {
const filtered = prev.filter((id) => existing.has(id));
if (filtered.length) return filtered;
return profiles.slice(0, Math.min(3, profiles.length)).map((profile) => profile.id);
});
}, [profiles]);
const profileMap = useMemo(() => {
const map = new Map<string, BacktestProfile>();
for (const profile of profiles) {
map.set(profile.id, profile);
}
return map;
}, [profiles]);
const selectedProfiles = useMemo(() => (
selectedProfileIds
.map((id) => profileMap.get(id))
.filter((value): value is BacktestProfile => Boolean(value))
.sort((a, b) => a.name.localeCompare(b.name))
), [selectedProfileIds, profileMap]);
const successfulRows = useMemo(() => rows.filter((row) => row.status === 'success' && row.result), [rows]);
const rankedRows = useMemo(() => {
const success = successfulRows
.slice()
.sort((a, b) => (b.result?.summary.netPnlUsd || 0) - (a.result?.summary.netPnlUsd || 0));
const errored = rows
.filter((row) => row.status === 'error')
.slice()
.sort((a, b) => a.profileName.localeCompare(b.profileName));
return [...success, ...errored];
}, [successfulRows, rows]);
const selectedResultRow = useMemo(
() => rows.find((row) => row.profileId === selectedResultProfileId && row.result),
[rows, selectedResultProfileId]
);
const readFile = async (file: File): Promise<void> => {
const text = await file.text();
setSourceName(file.name);
if (sourceType === 'csv') {
setSourcePayload(text);
return;
}
try {
const parsed = JSON.parse(text);
setSourcePayload(parsed);
} catch {
throw new Error('Failed to parse JSON file.');
}
};
const buildPayload = (profile: BacktestProfile): BacktestRequestPayload => {
const parsedSymbols = parseSymbolsInput(profile.symbols || '');
const initialCapital = useNormalizedCapital
? Number(normalizedCapitalUsd || 0)
: Number(profile.allocated_capital || 1000);
return {
mode: 'backtest',
profileId: profile.id,
strategyConfig: profile.strategy_config,
symbols: parsedSymbols,
timeframe,
dateRange: {
from: new Date(`${fromDate}T00:00:00.000Z`).toISOString(),
to: new Date(`${toDate}T23:59:59.999Z`).toISOString()
},
dataSource: {
type: sourceType,
payload: sourceType === 'kraken'
? {
exchange: 'kraken',
lookbackCandles: Number(krakenLookbackCandles || 2000)
}
: (sourceType === 'csv' ? String(sourcePayload || '') : sourcePayload)
},
execution: {
initialCapitalUsd: initialCapital,
orderType: 'market',
slippageBps: Number(slippageBps),
feeBps: Number(feeBps),
partialFillPct: Number(partialFillPct),
fillOnNextBar,
intraCandlePolicy,
triggerTimeframe,
enforceWarmup: true,
allowNegativeCash: false,
forceCloseAtWindowEnd
}
};
};
const runCompare = async () => {
setError(null);
if (selectedProfiles.length < 2) {
setError('Select at least two strategy profiles to compare.');
return;
}
if (!fromDate || !toDate) {
setError('Please provide both from/to dates.');
return;
}
if (new Date(`${toDate}T23:59:59.999Z`).getTime() < new Date(`${fromDate}T00:00:00.000Z`).getTime()) {
setError('To date must be greater than or equal to From date.');
return;
}
if (sourceType !== 'kraken' && !sourcePayload) {
setError('Please upload historical data before running comparison.');
return;
}
if (useNormalizedCapital && Number(normalizedCapitalUsd || 0) <= 0) {
setError('Normalized capital must be greater than zero.');
return;
}
setRunning(true);
setRunningIndex(0);
setRows([]);
setSelectedResultProfileId('');
const nextRows: CompareRow[] = [];
for (let index = 0; index < selectedProfiles.length; index += 1) {
const profile = selectedProfiles[index];
setRunningIndex(index + 1);
const startedAt = Date.now();
try {
const payload = buildPayload(profile);
if (!payload.symbols.length) {
throw new Error('Profile has no symbols configured.');
}
const result = await runBacktestApi(payload);
nextRows.push({
profileId: profile.id,
profileName: profile.name,
status: 'success',
durationMs: Date.now() - startedAt,
result
});
} catch (runError: any) {
nextRows.push({
profileId: profile.id,
profileName: profile.name,
status: 'error',
durationMs: Date.now() - startedAt,
error: runError?.message || 'Backtest compare run failed'
});
}
setRows([...nextRows]);
}
const firstSuccess = nextRows.find((row) => row.status === 'success');
if (firstSuccess) {
setSelectedResultProfileId(firstSuccess.profileId);
}
setRunning(false);
setRunningIndex(0);
};
const toggleProfile = (profileId: string, checked: boolean) => {
setSelectedProfileIds((prev) => {
if (checked) {
if (prev.includes(profileId)) return prev;
return [...prev, profileId];
}
return prev.filter((id) => id !== profileId);
});
};
return (
<div className="space-y-4">
<div className="rounded-2xl border border-indigo-400/20 bg-gradient-to-br from-zinc-950 to-zinc-900 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-black uppercase tracking-wide text-white">Compare Strategies</h3>
<p className="text-xs text-zinc-400">Run one deterministic replay window across multiple profiles and rank outcomes.</p>
</div>
<div className="text-[11px] text-zinc-500">
Selected: <span className="text-zinc-300 font-semibold">{selectedProfiles.length}</span>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-zinc-950/70 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-300">Profiles</span>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-7 px-2 text-[10px]"
onClick={() => setSelectedProfileIds(profiles.map((profile) => profile.id))}
>
Select All
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-7 px-2 text-[10px]"
onClick={() => setSelectedProfileIds([])}
>
Clear
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-[180px] overflow-auto">
{profiles.map((profile) => (
<label key={profile.id} className="flex items-center gap-2 rounded-lg border border-white/10 bg-zinc-900/60 px-2 py-2">
<Checkbox
checked={selectedProfileIds.includes(profile.id)}
onChange={(event) => toggleProfile(profile.id, event.target.checked)}
/>
<span className="text-xs text-zinc-200">{profile.name}</span>
<span className="ml-auto text-[10px] text-zinc-500">{profile.symbols}</span>
</label>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">Timeframe</span>
<Select
value={timeframe}
onChange={(event) => setTimeframe(event.target.value as BacktestTimeframe)}
controlSize="sm"
options={[
{ value: '1m', label: '1m' },
{ value: '15m', label: '15m' },
{ value: '1h', label: '1h' },
{ value: '4h', label: '4h' },
]}
/>
</label>
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">Historical Data Type</span>
<Select
value={sourceType}
onChange={(event) => {
const nextType = event.target.value as SourceType;
setSourceType(nextType);
if (nextType === 'kraken') {
setSourcePayload({ exchange: 'kraken' });
setSourceName('Kraken historical API');
} else {
setSourcePayload(null);
setSourceName('');
}
}}
controlSize="sm"
options={[
{ value: 'kraken', label: 'Kraken Historical API' },
{ value: 'csv', label: 'CSV' },
{ value: 'json', label: 'JSON' },
{ value: 'replay', label: 'Replay JSON' },
]}
/>
</label>
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">From</span>
<Input
type="date"
value={fromDate}
onChange={(event) => setFromDate(event.target.value)}
controlSize="sm"
/>
</label>
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">To</span>
<Input
type="date"
value={toDate}
onChange={(event) => setToDate(event.target.value)}
controlSize="sm"
/>
</label>
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">Slippage (bps)</span>
<Input
type="number"
min={0}
value={slippageBps}
onChange={(event) => setSlippageBps(Number(event.target.value))}
controlSize="sm"
/>
</label>
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">Fee (bps)</span>
<Input
type="number"
min={0}
value={feeBps}
onChange={(event) => setFeeBps(Number(event.target.value))}
controlSize="sm"
/>
</label>
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">Partial Fill %</span>
<Input
type="number"
min={1}
max={100}
value={Math.round(partialFillPct * 100)}
onChange={(event) => setPartialFillPct(Math.max(0.01, Math.min(1, Number(event.target.value) / 100)))}
controlSize="sm"
/>
</label>
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">Signal Fill Timing</span>
<Select
value={fillOnNextBar ? 'next_open' : 'same_close'}
onChange={(event) => setFillOnNextBar(event.target.value === 'next_open')}
controlSize="sm"
options={[
{ value: 'next_open', label: 'Next bar open' },
{ value: 'same_close', label: 'Same bar close' },
]}
/>
</label>
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">Intra-candle Conflict</span>
<Select
value={intraCandlePolicy}
onChange={(event) => setIntraCandlePolicy(event.target.value as BacktestIntraCandlePolicy)}
controlSize="sm"
options={[
{ value: 'ohlc_path', label: 'OHLC path' },
{ value: 'stop_loss_first', label: 'Stop-loss first' },
{ value: 'take_profit_first', label: 'Take-profit first' },
]}
/>
</label>
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">Trigger Resolution</span>
<Select
value={triggerTimeframe}
onChange={(event) => setTriggerTimeframe(event.target.value as BacktestTriggerTimeframe)}
controlSize="sm"
options={[
{ value: '1m', label: '1m trigger candles' },
{ value: 'off', label: 'Base timeframe only' },
]}
/>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">End-of-Window Position Handling</span>
<Select
value={forceCloseAtWindowEnd ? 'force_close' : 'open_at_end'}
onChange={(event) => setForceCloseAtWindowEnd(event.target.value === 'force_close')}
controlSize="sm"
options={[
{ value: 'open_at_end', label: 'Mark as OPEN_AT_END (Default)' },
{ value: 'force_close', label: 'Force close at last candle' },
]}
/>
</label>
{sourceType === 'kraken' ? (
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">Kraken Warm-up Lookback Candles</span>
<Input
type="number"
min={100}
value={krakenLookbackCandles}
onChange={(event) => setKrakenLookbackCandles(Math.max(100, Number(event.target.value || 0)))}
controlSize="sm"
/>
</label>
) : (
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">Upload Historical File</span>
<Input
type="file"
accept={sourceType === 'csv' ? '.csv,text/csv' : '.json,application/json'}
onChange={async (event) => {
try {
const file = event.target.files?.[0];
if (!file) return;
await readFile(file);
setError(null);
} catch (uploadError: any) {
setError(uploadError.message || 'Failed to read file');
}
}}
controlSize="sm"
/>
</label>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label className="flex items-center gap-2 text-xs text-zinc-300">
<Checkbox
checked={useNormalizedCapital}
onChange={(event) => setUseNormalizedCapital(event.target.checked)}
/>
Normalize initial capital across compared strategies
</label>
<label className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-zinc-400">
{useNormalizedCapital ? 'Normalized Capital (USD)' : 'Capital Source'}
</span>
{useNormalizedCapital ? (
<Input
type="number"
min={1}
value={normalizedCapitalUsd}
onChange={(event) => setNormalizedCapitalUsd(Number(event.target.value))}
controlSize="sm"
/>
) : (
<div className="rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-xs text-zinc-400">
Uses each profile allocated capital value.
</div>
)}
</label>
</div>
<div className="text-[11px] text-zinc-500">
Source: <span className="text-zinc-300">{sourceName || 'none'}</span>
</div>
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
<Button
type="button"
onClick={() => { void runCompare(); }}
disabled={running}
size="lg"
className="w-full"
>
{running
? `Running compare... (${runningIndex}/${selectedProfiles.length})`
: 'Run Strategy Comparison'}
</Button>
</div>
{rows.length > 0 && (
<div className="rounded-2xl border border-white/10 bg-zinc-950/70 overflow-hidden">
<div className="px-4 py-3 border-b border-white/10 flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-zinc-300">Comparison Results</h4>
<span className="text-[11px] text-zinc-500">
Success: {successfulRows.length} / {rows.length}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-zinc-900 text-zinc-500 uppercase tracking-wider text-[10px]">
<tr>
<th className="px-3 py-2">Rank</th>
<th className="px-3 py-2">Strategy</th>
<th className="px-3 py-2">Net PnL</th>
<th className="px-3 py-2">Win Rate</th>
<th className="px-3 py-2">Max DD</th>
<th className="px-3 py-2">Sharpe</th>
<th className="px-3 py-2">Trades</th>
<th className="px-3 py-2">Duration</th>
<th className="px-3 py-2">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{rankedRows.map((row, index) => {
const summary = row.result?.summary;
const active = row.profileId === selectedResultProfileId;
return (
<tr key={row.profileId} className={active ? 'bg-indigo-500/15' : 'hover:bg-white/5'}>
<td className="px-3 py-2 text-zinc-300">
{row.status === 'success' ? `#${index + 1}` : '-'}
</td>
<td className="px-3 py-2 text-white">{row.profileName}</td>
<td className={`px-3 py-2 font-semibold ${summary && summary.netPnlUsd >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
{summary ? money(summary.netPnlUsd) : '-'}
</td>
<td className="px-3 py-2 text-zinc-300">{summary ? pct(summary.winRate) : '-'}</td>
<td className="px-3 py-2 text-zinc-300">{summary ? pct(summary.maxDrawdownPct) : '-'}</td>
<td className="px-3 py-2 text-zinc-300">{summary ? summary.sharpe.toFixed(3) : '-'}</td>
<td className="px-3 py-2 text-zinc-300">{summary ? summary.totalTrades : '-'}</td>
<td className="px-3 py-2 text-zinc-300">{(row.durationMs / 1000).toFixed(1)}s</td>
<td className="px-3 py-2">
{row.status === 'success' && row.result ? (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-7 px-2 text-[10px]"
onClick={() => setSelectedResultProfileId(row.profileId)}
>
Open
</Button>
) : (
<span className="text-[11px] text-rose-300">{row.error || 'Run failed'}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{selectedResultRow?.result && (
<div className="space-y-2">
<h4 className="text-sm font-bold uppercase tracking-wide text-white">
Detailed Result: {selectedResultRow.profileName}
</h4>
<BacktestResultsDashboard result={selectedResultRow.result} />
</div>
)}
</div>
);
};