feat: overhaul paper run detail page summary UI
- Key metrics row: trades replayed, closed positions, total PnL, win rate shown as stat cards at the top - Run Details card: data range start/end alongside engine start/end times - Instrument statistics table: numbers formatted to readable decimals, overflow guard for barter bug remnants (>1e15 shows --), PnL coloured green/red, pnl_return % column added - Asset Balances table: shows final total/free balance per asset - Raw Result JSON accordion collapsed by default (was open) - Mode badge added to header - displayMetric replaced by typed extractNumber + formatMetric/formatPnl/ formatPct helpers that cap and format values sensibly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ const STATUS_BADGE: Record<PaperRun['status'], { bg: string; text?: string }> =
|
||||
cancelled: { bg: 'secondary' },
|
||||
};
|
||||
|
||||
function formatTimestamp(iso: string | null): string {
|
||||
function formatTimestamp(iso: string | null | undefined): string {
|
||||
if (!iso) return '--';
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
@@ -38,30 +38,67 @@ function durationBetween(start: string | null, end: string | null): string {
|
||||
return `${mins}m ${remSecs}s`;
|
||||
}
|
||||
|
||||
/** Try to extract a displayable value from a potentially nested metric. */
|
||||
function displayMetric(value: unknown): string {
|
||||
if (value === null || value === undefined) return '--';
|
||||
if (typeof value === 'number') return value === 0 ? '--' : String(value);
|
||||
/** Extract a numeric value from barter's nested metric wrappers. */
|
||||
function extractNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
const num = Number(value);
|
||||
return !isNaN(num) && num === 0 ? '--' : value;
|
||||
const n = parseFloat(value);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Wrapper types may serialize with a nested value field
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of ['value', '0', 'ratio', 'rate']) {
|
||||
if (key in obj) return displayMetric(obj[key]);
|
||||
if (key in obj) {
|
||||
const inner = extractNumber(obj[key]);
|
||||
if (inner !== null) return inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '--';
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ResultSummary {
|
||||
time_engine_start?: string;
|
||||
time_engine_end?: string;
|
||||
instruments?: Record<string, TearSheetData>;
|
||||
assets?: Record<string, unknown>;
|
||||
backtest_metadata?: { position_count?: number };
|
||||
function formatMetric(value: unknown, decimals = 4): string {
|
||||
const n = extractNumber(value);
|
||||
if (n === null || n === 0) return '--';
|
||||
// Detect suspiciously large absolute values (barter bug remnants)
|
||||
if (Math.abs(n) > 1e15) return '--';
|
||||
return n.toFixed(decimals);
|
||||
}
|
||||
|
||||
function formatPct(value: unknown): string {
|
||||
const n = extractNumber(value);
|
||||
if (n === null || n === 0) return '--';
|
||||
return `${(n * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatPnl(value: unknown): string {
|
||||
const n = extractNumber(value);
|
||||
if (n === null || n === 0) return '--';
|
||||
if (Math.abs(n) > 1e15) return '--';
|
||||
const sign = n >= 0 ? '+' : '';
|
||||
return `${sign}${n.toFixed(4)}`;
|
||||
}
|
||||
|
||||
interface BacktestMetadata {
|
||||
trade_count?: number;
|
||||
position_count?: number;
|
||||
data_range_start?: string;
|
||||
data_range_end?: string;
|
||||
}
|
||||
|
||||
interface Balance {
|
||||
free?: unknown;
|
||||
total?: unknown;
|
||||
}
|
||||
|
||||
interface AssetEntry {
|
||||
exchange?: string;
|
||||
asset?: string;
|
||||
tear_sheet?: {
|
||||
balance_end?: Balance;
|
||||
drawdown_max?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface TearSheetData {
|
||||
@@ -72,6 +109,15 @@ interface TearSheetData {
|
||||
win_rate?: unknown;
|
||||
profit_factor?: unknown;
|
||||
pnl_drawdown_max?: unknown;
|
||||
pnl_return?: unknown;
|
||||
}
|
||||
|
||||
interface ResultSummary {
|
||||
time_engine_start?: string;
|
||||
time_engine_end?: string;
|
||||
instruments?: Record<string, TearSheetData>;
|
||||
assets?: AssetEntry[];
|
||||
backtest_metadata?: BacktestMetadata;
|
||||
}
|
||||
|
||||
export default function PaperRunDetailPage() {
|
||||
@@ -79,7 +125,7 @@ export default function PaperRunDetailPage() {
|
||||
const { data: run, isLoading, isError, error } = usePaperRun(id!);
|
||||
const cancelMutation = useCancelPaperRun();
|
||||
const isComplete = run?.status === 'complete';
|
||||
const { data: positions } = usePaperRunPositions(isComplete ? id : undefined, 500);
|
||||
const { data: positionsData } = usePaperRunPositions(isComplete ? id : undefined, 500);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -101,6 +147,7 @@ export default function PaperRunDetailPage() {
|
||||
|
||||
const badge = STATUS_BADGE[run.status];
|
||||
const summary = run.result_summary as ResultSummary | null;
|
||||
const meta = summary?.backtest_metadata;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@@ -120,7 +167,8 @@ export default function PaperRunDetailPage() {
|
||||
)}
|
||||
{run.status}
|
||||
</Badge>
|
||||
<code>{run.id}</code>
|
||||
<Badge bg="secondary" className="me-2">{run.mode}</Badge>
|
||||
<code style={{ fontSize: '0.85em' }}>{run.id}</code>
|
||||
</div>
|
||||
{run.status === 'queued' && (
|
||||
<Button
|
||||
@@ -143,12 +191,12 @@ export default function PaperRunDetailPage() {
|
||||
<div>{formatTimestamp(run.finishes_at)}</div>
|
||||
</Col>
|
||||
<Col sm={3}>
|
||||
<small className="text-muted">Worker</small>
|
||||
<div>{run.worker_id ? <code>{run.worker_id}</code> : '--'}</div>
|
||||
<small className="text-muted">Duration</small>
|
||||
<div>{durationBetween(run.starts_at, run.finishes_at)}</div>
|
||||
</Col>
|
||||
<Col sm={3}>
|
||||
<small className="text-muted">Created</small>
|
||||
<div>{formatTimestamp(run.created_at)}</div>
|
||||
<small className="text-muted">Worker</small>
|
||||
<div>{run.worker_id ? <code style={{ fontSize: '0.8em' }}>{run.worker_id}</code> : '--'}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -190,69 +238,184 @@ export default function PaperRunDetailPage() {
|
||||
{/* Result summary for completed runs */}
|
||||
{run.status === 'complete' && summary && (
|
||||
<>
|
||||
{/* Key metrics row */}
|
||||
<Row className="mb-3">
|
||||
{meta?.trade_count != null && (
|
||||
<Col sm={3}>
|
||||
<Card className="h-100 text-center">
|
||||
<Card.Body className="py-3">
|
||||
<div style={{ fontSize: '1.6em', fontWeight: 600 }}>
|
||||
{meta.trade_count.toLocaleString()}
|
||||
</div>
|
||||
<small className="text-muted">Trades Replayed</small>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
{positionsData != null && (
|
||||
<Col sm={3}>
|
||||
<Card className="h-100 text-center">
|
||||
<Card.Body className="py-3">
|
||||
<div style={{ fontSize: '1.6em', fontWeight: 600 }}>
|
||||
{positionsData.total.toLocaleString()}
|
||||
</div>
|
||||
<small className="text-muted">Closed Positions</small>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
{summary.instruments && Object.values(summary.instruments).map((sheet, i) => {
|
||||
const pnl = extractNumber(sheet.pnl);
|
||||
if (pnl === null || Math.abs(pnl) > 1e15) return null;
|
||||
const isPositive = pnl >= 0;
|
||||
return (
|
||||
<Col sm={3} key={i}>
|
||||
<Card className="h-100 text-center">
|
||||
<Card.Body className="py-3">
|
||||
<div style={{ fontSize: '1.6em', fontWeight: 600, color: isPositive ? '#198754' : '#dc3545' }}>
|
||||
{formatPnl(sheet.pnl)}
|
||||
</div>
|
||||
<small className="text-muted">Total PnL</small>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
{summary.instruments && Object.values(summary.instruments).map((sheet, i) => {
|
||||
const wr = extractNumber(sheet.win_rate);
|
||||
if (wr === null) return null;
|
||||
return (
|
||||
<Col sm={3} key={i}>
|
||||
<Card className="h-100 text-center">
|
||||
<Card.Body className="py-3">
|
||||
<div style={{ fontSize: '1.6em', fontWeight: 600 }}>
|
||||
{formatPct(sheet.win_rate)}
|
||||
</div>
|
||||
<small className="text-muted">Win Rate</small>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* Data range + engine times */}
|
||||
<Card className="mb-3">
|
||||
<Card.Header>Summary</Card.Header>
|
||||
<Card.Header>Run Details</Card.Header>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col sm={6}>
|
||||
{meta?.data_range_start && (
|
||||
<Col sm={3}>
|
||||
<small className="text-muted">Data Start</small>
|
||||
<div>{formatTimestamp(meta.data_range_start)}</div>
|
||||
</Col>
|
||||
)}
|
||||
{meta?.data_range_end && (
|
||||
<Col sm={3}>
|
||||
<small className="text-muted">Data End</small>
|
||||
<div>{formatTimestamp(meta.data_range_end)}</div>
|
||||
</Col>
|
||||
)}
|
||||
<Col sm={3}>
|
||||
<small className="text-muted">Engine Start</small>
|
||||
<div>{formatTimestamp(summary.time_engine_start ?? null)}</div>
|
||||
<div>{formatTimestamp(summary.time_engine_start)}</div>
|
||||
</Col>
|
||||
<Col sm={6}>
|
||||
<Col sm={3}>
|
||||
<small className="text-muted">Engine End</small>
|
||||
<div>{formatTimestamp(summary.time_engine_end ?? null)}</div>
|
||||
<div>{formatTimestamp(summary.time_engine_end)}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{positions && positions.positions.length > 0 && (
|
||||
{/* Equity curve */}
|
||||
{positionsData && positionsData.positions.length > 0 && (
|
||||
<Card className="mb-3">
|
||||
<Card.Header>
|
||||
Equity Curve
|
||||
<small className="text-muted ms-2">
|
||||
{positions.total.toLocaleString()} closed position{positions.total !== 1 ? 's' : ''}
|
||||
{positions.sampled < positions.total && (
|
||||
<span className="ms-1">(showing {positions.sampled.toLocaleString()} sampled)</span>
|
||||
{positionsData.total.toLocaleString()} closed position{positionsData.total !== 1 ? 's' : ''}
|
||||
{positionsData.sampled < positionsData.total && (
|
||||
<span className="ms-1">(showing {positionsData.sampled.toLocaleString()} sampled)</span>
|
||||
)}
|
||||
</small>
|
||||
</Card.Header>
|
||||
<Card.Body className="pt-3 pb-2 px-2">
|
||||
<EquityCurveChart positions={positions.positions} />
|
||||
<EquityCurveChart positions={positionsData.positions} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Instrument statistics */}
|
||||
{summary.instruments && Object.keys(summary.instruments).length > 0 && (
|
||||
<Card className="mb-3">
|
||||
<Card.Header>Instruments</Card.Header>
|
||||
<Card.Header>Instrument Statistics</Card.Header>
|
||||
<Card.Body className="p-0">
|
||||
<Table striped bordered hover size="sm" className="mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Instrument</th>
|
||||
<th>PnL</th>
|
||||
<th>Sharpe</th>
|
||||
<th>Sortino</th>
|
||||
<th>Calmar</th>
|
||||
<th>Return</th>
|
||||
<th>Win Rate</th>
|
||||
<th>Profit Factor</th>
|
||||
<th>Sharpe</th>
|
||||
<th>Sortino</th>
|
||||
<th>Max Drawdown</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(summary.instruments).map(([name, sheet]) => (
|
||||
<tr key={name}>
|
||||
<td><code>{name}</code></td>
|
||||
<td>{displayMetric(sheet.pnl)}</td>
|
||||
<td>{displayMetric(sheet.sharpe_ratio)}</td>
|
||||
<td>{displayMetric(sheet.sortino_ratio)}</td>
|
||||
<td>{displayMetric(sheet.calmar_ratio)}</td>
|
||||
<td>{displayMetric(sheet.win_rate)}</td>
|
||||
<td>{displayMetric(sheet.profit_factor)}</td>
|
||||
<td>{displayMetric(sheet.pnl_drawdown_max)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{Object.entries(summary.instruments).map(([name, sheet]) => {
|
||||
const pnl = extractNumber(sheet.pnl);
|
||||
const pnlColor = pnl == null || Math.abs(pnl) > 1e15
|
||||
? undefined
|
||||
: pnl >= 0 ? '#198754' : '#dc3545';
|
||||
return (
|
||||
<tr key={name}>
|
||||
<td><code>{name}</code></td>
|
||||
<td style={{ color: pnlColor }}>{formatPnl(sheet.pnl)}</td>
|
||||
<td>{formatPct(sheet.pnl_return)}</td>
|
||||
<td>{formatPct(sheet.win_rate)}</td>
|
||||
<td>{formatMetric(sheet.profit_factor, 3)}</td>
|
||||
<td>{formatMetric(sheet.sharpe_ratio, 3)}</td>
|
||||
<td>{formatMetric(sheet.sortino_ratio, 3)}</td>
|
||||
<td style={{ color: '#dc3545' }}>{formatPnl(sheet.pnl_drawdown_max)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Asset balances */}
|
||||
{summary.assets && summary.assets.length > 0 && (
|
||||
<Card className="mb-3">
|
||||
<Card.Header>Asset Balances</Card.Header>
|
||||
<Card.Body className="p-0">
|
||||
<Table striped bordered hover size="sm" className="mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Exchange</th>
|
||||
<th>Asset</th>
|
||||
<th>Total</th>
|
||||
<th>Free</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.assets.map((entry, i) => {
|
||||
const total = extractNumber(entry.tear_sheet?.balance_end?.total);
|
||||
const free = extractNumber(entry.tear_sheet?.balance_end?.free);
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td>{entry.exchange ?? '--'}</td>
|
||||
<td><code>{entry.asset ?? '--'}</code></td>
|
||||
<td>{total != null ? total.toFixed(4) : '--'}</td>
|
||||
<td>{free != null ? free.toFixed(4) : '--'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Card.Body>
|
||||
|
||||
Reference in New Issue
Block a user