350 lines
20 KiB
TypeScript
350 lines
20 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, ReferenceLine } from 'recharts';
|
|
import type { BacktestResult } from '../types';
|
|
import { buildInsightCards, toEquitySeries } from '../utils';
|
|
|
|
interface BacktestResultsDashboardProps {
|
|
result: BacktestResult;
|
|
}
|
|
|
|
const money = (value: number): string => {
|
|
const amount = Number(value || 0);
|
|
return amount.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 });
|
|
};
|
|
|
|
const pct = (value: number): string => `${Number(value || 0).toFixed(2)}%`;
|
|
|
|
export const BacktestResultsDashboard: React.FC<BacktestResultsDashboardProps> = ({ result }) => {
|
|
const equitySeries = useMemo(() => toEquitySeries(result), [result]);
|
|
const [cursorIndex, setCursorIndex] = useState(0);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [speed, setSpeed] = useState<'1x' | '5x' | '20x' | 'instant'>('1x');
|
|
const tradeRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
|
|
const insightCards = useMemo(() => buildInsightCards(result), [result]);
|
|
const orderedTrades = useMemo(
|
|
() => [...result.trades].sort((a, b) => a.entryTimestamp - b.entryTimestamp),
|
|
[result.trades]
|
|
);
|
|
|
|
useEffect(() => {
|
|
setCursorIndex(0);
|
|
setIsPlaying(false);
|
|
setSpeed('1x');
|
|
}, [result]);
|
|
|
|
useEffect(() => {
|
|
if (!isPlaying) return;
|
|
if (!equitySeries.length) return;
|
|
if (speed === 'instant') {
|
|
setCursorIndex(equitySeries.length - 1);
|
|
setIsPlaying(false);
|
|
return;
|
|
}
|
|
|
|
const tickMs = speed === '1x' ? 700 : speed === '5x' ? 200 : 80;
|
|
const timer = window.setInterval(() => {
|
|
setCursorIndex((prev) => {
|
|
const atEnd = prev >= equitySeries.length - 1;
|
|
if (atEnd) {
|
|
setIsPlaying(false);
|
|
return prev;
|
|
}
|
|
return prev + 1;
|
|
});
|
|
}, tickMs);
|
|
return () => window.clearInterval(timer);
|
|
}, [isPlaying, speed, equitySeries.length]);
|
|
|
|
const cursorPoint = equitySeries[Math.min(cursorIndex, Math.max(0, equitySeries.length - 1))];
|
|
const cursorTimestamp = Number(cursorPoint?.timestamp ?? result.window.fromTimestamp);
|
|
const activeTrade = useMemo(() => {
|
|
if (!orderedTrades.length) return null;
|
|
return orderedTrades.find((trade) => (
|
|
cursorTimestamp >= trade.entryTimestamp && cursorTimestamp <= trade.exitTimestamp
|
|
)) || null;
|
|
}, [cursorTimestamp, orderedTrades]);
|
|
|
|
useEffect(() => {
|
|
if (!activeTrade) return;
|
|
const row = tradeRowRefs.current[activeTrade.id];
|
|
if (row && typeof row.scrollIntoView === 'function') {
|
|
row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
}
|
|
}, [activeTrade]);
|
|
|
|
const replayInsight = useMemo(() => {
|
|
const topBlocked = Object.entries(result.diagnostics.blockedRuleCounts || {})
|
|
.sort((a, b) => b[1] - a[1])[0];
|
|
if (activeTrade) {
|
|
return `Active trade ${activeTrade.symbol} (${activeTrade.side}). Entry: ${activeTrade.entryReason}. Exit trigger: ${activeTrade.exitReason}.`;
|
|
}
|
|
const nextTrade = orderedTrades.find((trade) => trade.entryTimestamp > cursorTimestamp);
|
|
if (nextTrade) {
|
|
if (topBlocked) {
|
|
return `No trade - rule failed (${topBlocked[0]}). Next trade starts at ${new Date(nextTrade.entryTimestamp).toLocaleString()}.`;
|
|
}
|
|
return `No trade - rule failed or no valid signal. Next trade starts at ${new Date(nextTrade.entryTimestamp).toLocaleString()}.`;
|
|
}
|
|
if (topBlocked) {
|
|
return `No trade - rule failed (${topBlocked[0]}). Replay window ended.`;
|
|
}
|
|
return 'No trade - rule failed or no qualifying setup during this replay segment.';
|
|
}, [activeTrade, cursorTimestamp, orderedTrades, result.diagnostics.blockedRuleCounts]);
|
|
|
|
const isAtEnd = cursorIndex >= Math.max(0, equitySeries.length - 1);
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div data-testid="backtest-replay-controls" className="rounded-2xl border border-cyan-500/30 bg-cyan-500/5 p-4 space-y-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<h3 className="text-sm font-bold uppercase tracking-wider text-cyan-200">Replay Controls (UI-Only)</h3>
|
|
<span className="text-[11px] text-cyan-100/80">
|
|
Replay window: {result.window.fromIso.slice(0, 10)}{' -> '}{result.window.toIso.slice(0, 10)} ({result.window.timezone})
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsPlaying(true)}
|
|
disabled={isPlaying || !equitySeries.length || isAtEnd}
|
|
className={`rounded-lg px-3 py-1 text-[11px] font-bold uppercase tracking-wide ${isPlaying || isAtEnd ? 'bg-zinc-800 text-zinc-500' : 'bg-emerald-400 text-black hover:bg-emerald-300'}`}
|
|
>
|
|
Play
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsPlaying(false)}
|
|
disabled={!isPlaying}
|
|
className={`rounded-lg px-3 py-1 text-[11px] font-bold uppercase tracking-wide ${!isPlaying ? 'bg-zinc-800 text-zinc-500' : 'bg-zinc-200 text-black hover:bg-white'}`}
|
|
>
|
|
Pause
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCursorIndex((prev) => Math.min(prev + 1, Math.max(0, equitySeries.length - 1)))}
|
|
disabled={!equitySeries.length || isAtEnd}
|
|
className={`rounded-lg px-3 py-1 text-[11px] font-bold uppercase tracking-wide ${isAtEnd ? 'bg-zinc-800 text-zinc-500' : 'bg-indigo-400 text-black hover:bg-indigo-300'}`}
|
|
>
|
|
Step
|
|
</button>
|
|
<select
|
|
value={speed}
|
|
onChange={(event) => setSpeed(event.target.value as '1x' | '5x' | '20x' | 'instant')}
|
|
className="rounded-lg border border-white/10 bg-zinc-900 px-2 py-1 text-[11px] text-white"
|
|
>
|
|
<option value="1x">1x</option>
|
|
<option value="5x">5x</option>
|
|
<option value="20x">20x</option>
|
|
<option value="instant">Instant</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsPlaying(false);
|
|
setCursorIndex(0);
|
|
}}
|
|
className="rounded-lg px-3 py-1 text-[11px] font-bold uppercase tracking-wide bg-white/10 text-zinc-300 hover:bg-white/20"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs text-cyan-100/90">
|
|
<div data-testid="backtest-replay-cursor">
|
|
<span className="text-cyan-300/70">Cursor:</span> {new Date(cursorTimestamp).toLocaleString()}
|
|
</div>
|
|
<div>
|
|
<span className="text-cyan-300/70">Insight:</span> {replayInsight}
|
|
</div>
|
|
</div>
|
|
<p className="text-[11px] text-cyan-100/80">
|
|
Replay shows a historical simulation. No real or paper trades are placed.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-white/10 bg-zinc-950/70 p-4 space-y-3">
|
|
<h3 className="text-sm font-bold uppercase tracking-wider text-white">Execution Assumptions</h3>
|
|
<div data-testid="backtest-assumptions-panel" className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs text-zinc-300">
|
|
<div><span className="text-zinc-500">Fill Model:</span> {result.assumptions.fillModel}</div>
|
|
<div><span className="text-zinc-500">Slippage:</span> {result.assumptions.slippageModel}</div>
|
|
<div><span className="text-zinc-500">Fees:</span> {result.assumptions.feeModel}</div>
|
|
<div><span className="text-zinc-500">Latency:</span> {result.assumptions.latencyModel}</div>
|
|
<div><span className="text-zinc-500">Intra-candle:</span> {result.assumptions.intraCandleModel}</div>
|
|
<div><span className="text-zinc-500">Trigger Resolution:</span> {result.assumptions.triggerResolution}</div>
|
|
<div><span className="text-zinc-500">Warm-up Enforced:</span> {result.assumptions.warmupEnforced ? 'Yes' : 'No'}</div>
|
|
<div><span className="text-zinc-500">Deterministic Replay:</span> {result.assumptions.deterministicReplay ? 'Yes' : 'No'}</div>
|
|
<div><span className="text-zinc-500">Replay Window:</span> {result.assumptions.replayWindow}</div>
|
|
<div><span className="text-zinc-500">Window End Policy:</span> {result.assumptions.endOfWindowPolicy}</div>
|
|
</div>
|
|
<p className="text-[11px] text-amber-300">{result.assumptions.disclaimer}</p>
|
|
</div>
|
|
|
|
<div data-testid="backtest-warmup-meta" className="rounded-2xl border border-white/10 bg-zinc-950/70 p-4 grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<div>
|
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500">Warm-up Start</p>
|
|
<p className="text-xs text-white">{new Date(result.warmup.startTimestamp).toLocaleString()}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500">Warm-up End</p>
|
|
<p className="text-xs text-white">{new Date(result.warmup.endTimestamp).toLocaleString()}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500">Candles Required</p>
|
|
<p className="text-xs text-white">
|
|
15m {result.warmup.candlesRequired['15m']} | 1h {result.warmup.candlesRequired['1h']} | 4h {result.warmup.candlesRequired['4h']}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500">Signals Ignored</p>
|
|
<p className="text-xs text-white">{result.warmup.signalsIgnored}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
|
<div className="rounded-xl border border-white/10 bg-zinc-950/70 p-3">
|
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500">Net PnL</p>
|
|
<p className="text-sm font-bold text-white">{money(result.summary.netPnlUsd)}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-white/10 bg-zinc-950/70 p-3">
|
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500">Win Rate</p>
|
|
<p className="text-sm font-bold text-white">{pct(result.summary.winRate)}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-white/10 bg-zinc-950/70 p-3">
|
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500">Max Drawdown</p>
|
|
<p className="text-sm font-bold text-white">{pct(result.summary.maxDrawdownPct)}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-white/10 bg-zinc-950/70 p-3">
|
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500">Sharpe</p>
|
|
<p className="text-sm font-bold text-white">{result.summary.sharpe.toFixed(3)}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-white/10 bg-zinc-950/70 p-3">
|
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500">Total Trades</p>
|
|
<p className="text-sm font-bold text-white">{result.summary.totalTrades}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="rounded-2xl border border-white/10 bg-zinc-950/70 p-3">
|
|
<h4 className="text-xs font-bold uppercase tracking-wider text-zinc-300 mb-2">Equity Curve</h4>
|
|
<div style={{ width: '100%', height: 220 }}>
|
|
<ResponsiveContainer>
|
|
<LineChart data={equitySeries}>
|
|
<CartesianGrid stroke="var(--bl-border-subtle)" strokeDasharray="3 3" />
|
|
<XAxis
|
|
dataKey="timestamp"
|
|
tickFormatter={(value) => new Date(Number(value)).toLocaleDateString()}
|
|
tick={{ fontSize: 10, fill: 'var(--bl-text-faint)' }}
|
|
/>
|
|
<YAxis tick={{ fontSize: 10, fill: 'var(--bl-text-faint)' }} />
|
|
<Tooltip
|
|
formatter={(value: any) => money(Number(value))}
|
|
labelFormatter={(label) => new Date(Number(label)).toLocaleString()}
|
|
/>
|
|
<ReferenceLine x={cursorTimestamp} stroke="var(--bl-attention)" strokeWidth={1.5} strokeDasharray="4 4" />
|
|
<Line type="monotone" dataKey="equityUsd" stroke="var(--bl-success)" strokeWidth={2} dot={false} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/10 bg-zinc-950/70 p-3">
|
|
<h4 className="text-xs font-bold uppercase tracking-wider text-zinc-300 mb-2">Drawdown Curve</h4>
|
|
<div style={{ width: '100%', height: 220 }}>
|
|
<ResponsiveContainer>
|
|
<LineChart data={equitySeries}>
|
|
<CartesianGrid stroke="var(--bl-border-subtle)" strokeDasharray="3 3" />
|
|
<XAxis
|
|
dataKey="timestamp"
|
|
tickFormatter={(value) => new Date(Number(value)).toLocaleDateString()}
|
|
tick={{ fontSize: 10, fill: 'var(--bl-text-faint)' }}
|
|
/>
|
|
<YAxis tick={{ fontSize: 10, fill: 'var(--bl-text-faint)' }} />
|
|
<Tooltip
|
|
formatter={(value: any) => pct(Number(value))}
|
|
labelFormatter={(label) => new Date(Number(label)).toLocaleString()}
|
|
/>
|
|
<ReferenceLine x={cursorTimestamp} stroke="var(--bl-attention)" strokeWidth={1.5} strokeDasharray="4 4" />
|
|
<Line type="monotone" dataKey="drawdownPct" stroke="var(--bl-warning)" strokeWidth={2} dot={false} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
{insightCards.map((card) => (
|
|
<div key={card.title} className="rounded-xl border border-white/10 bg-zinc-950/70 p-3">
|
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500">{card.title}</p>
|
|
<p className="text-sm font-bold text-white mt-1">{card.value}</p>
|
|
<p className="text-[11px] text-zinc-400 mt-1">{card.detail}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{result.openPositionsAtEnd.length > 0 && (
|
|
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 p-4">
|
|
<h4 className="text-xs font-bold uppercase tracking-wider text-amber-200 mb-2">Open At End</h4>
|
|
<div className="space-y-2">
|
|
{result.openPositionsAtEnd.map((position) => (
|
|
<div key={`${position.symbol}-${position.entryTimestamp}`} className="grid grid-cols-1 md:grid-cols-5 gap-2 text-xs text-amber-100">
|
|
<div>{position.symbol} {position.side}</div>
|
|
<div>Entry {new Date(position.entryTimestamp).toLocaleString()}</div>
|
|
<div>Size {position.size}</div>
|
|
<div>Unrealized {money(position.unrealizedPnlUsd)} ({pct(position.unrealizedPnlPct)})</div>
|
|
<div>{position.status}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<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">
|
|
<h4 className="text-xs font-bold uppercase tracking-wider text-zinc-300">Trades</h4>
|
|
</div>
|
|
<div className="overflow-x-auto max-h-[360px]">
|
|
<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">Symbol</th>
|
|
<th className="px-3 py-2">Side</th>
|
|
<th className="px-3 py-2">Entry</th>
|
|
<th className="px-3 py-2">Exit</th>
|
|
<th className="px-3 py-2">PnL</th>
|
|
<th className="px-3 py-2">Reasoning</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/5">
|
|
{result.trades.map((trade) => (
|
|
<tr
|
|
key={trade.id}
|
|
ref={(element) => { tradeRowRefs.current[trade.id] = element; }}
|
|
data-trade-id={trade.id}
|
|
className={`${activeTrade?.id === trade.id ? 'bg-cyan-500/15' : 'hover:bg-white/5'}`}
|
|
>
|
|
<td className="px-3 py-2 text-white">{trade.symbol}</td>
|
|
<td className="px-3 py-2 text-zinc-300">{trade.side}</td>
|
|
<td className="px-3 py-2 text-zinc-300">{new Date(trade.entryTimestamp).toLocaleString()}</td>
|
|
<td className="px-3 py-2 text-zinc-300">{new Date(trade.exitTimestamp).toLocaleString()}</td>
|
|
<td className={`px-3 py-2 font-semibold ${trade.pnlUsd >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
|
{money(trade.pnlUsd)} ({pct(trade.pnlPct)})
|
|
</td>
|
|
<td className="px-3 py-2 text-zinc-400">
|
|
<div>{trade.entryReason}</div>
|
|
<div className="text-zinc-500">Exit: {trade.exitReason}</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{result.trades.length === 0 && (
|
|
<tr>
|
|
<td className="px-3 py-8 text-center text-zinc-500" colSpan={6}>
|
|
No trades were executed during this replay window.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|