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

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>
);
};