594 lines
29 KiB
TypeScript
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>
|
|
);
|
|
};
|