Files
Auto-Trader/frontend/src/pages/Compare.tsx
T
Haozhe Wu 8f40de5a5c feat: FW-3 live-test fix wave — connector resume + i18n, hero tabs, activity chain, pro validation charts
- ConnectionBanner: surface only during an active reconnect loop (boot-state false alarm fix)
- Trading-monitor wording replaces literal 'runtime' jargon across zh/ja/ko/ar
- Hero: vertical centering + category-tab example library; About relocated to sidebar footer
- Brand: ascending-candles mark in BrandMark/favicon/logo.svg (replaces VT lettermark + stale FIN-ANYTHING wordmark)
- Dark theme: surface separation and border/muted-foreground contrast lift
- Connector panel: /live/resume wired with a resume button; RunnerStatus internals fully i18n'd
- Activity chain: coalesced xN steps with argument summaries, chronological order, sub-second durations
- RunDetail: trades side-normalization fix + pnl/return/holding columns + filters/pagination/totals; code copy button; run-card validation rendered via ValidationPanel
- Validation: Monte Carlo fan chart + Sharpe distribution histograms + walk-forward bars; validation.py emits sharpe samples and path envelopes
- Compare: centered layout, rebase-to-100 overlay toggle, in-cell metric bars, swap button
2026-07-30 11:44:31 +08:00

487 lines
21 KiB
TypeScript

import i18n from '@/i18n';
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { GitCompare, ArrowLeftRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { api, type RunListItem, type RunData, type EquityPoint } from "@/lib/api";
import { echarts, CHART_GROUP, connectCharts } from "@/lib/echarts";
import { getChartTheme } from "@/lib/chart-theme";
import { useThemeDark } from "@/lib/theme-store";
import { SkeletonChart, SkeletonMetrics } from "@/components/common/Skeleton";
interface MetricDef {
key: string;
label: string;
type: "pct" | "num" | "int" | "days";
higherIsBetter: boolean;
}
function fmt(v: unknown, type: "pct" | "num" | "int" | "days" = "num"): string {
const n = Number(v);
if (!Number.isFinite(n)) return "\u2014";
if (type === "pct") return (n * 100).toFixed(2) + "%";
if (type === "int") return n.toFixed(0);
if (type === "days") return n.toFixed(1);
return n.toFixed(3);
}
type DiffPresentation = {
className: string;
verdict: "better" | "worse" | null;
glyph: "\u2191" | "\u2193" | null;
};
function diffClass(a: unknown, b: unknown, higherIsBetter: boolean): DiffPresentation {
const na = Number(a), nb = Number(b);
if (!Number.isFinite(na) || !Number.isFinite(nb)) {
return { className: "", verdict: null, glyph: null };
}
const better = higherIsBetter ? nb > na : nb < na;
const worse = higherIsBetter ? nb < na : nb > na;
if (better) {
return { className: "text-green-600 dark:text-green-400", verdict: "better", glyph: "\u2191" };
}
if (worse) {
return { className: "text-red-600 dark:text-red-400", verdict: "worse", glyph: "\u2193" };
}
return { className: "", verdict: null, glyph: null };
}
function diffStr(a: unknown, b: unknown, type: "pct" | "num" | "int" | "days"): string {
const na = Number(a), nb = Number(b);
if (!Number.isFinite(na) || !Number.isFinite(nb)) return "\u2014";
const d = nb - na;
return (d > 0 ? "+" : "") + fmt(d, type);
}
function truncatePrompt(prompt: string | undefined, maxLen = 40): string {
if (!prompt) return "";
const trimmed = prompt.replace(/\n/g, " ").trim();
return trimmed.length > maxLen ? trimmed.slice(0, maxLen) + "\u2026" : trimmed;
}
function runLabel(r: RunListItem): string {
const summary = truncatePrompt(r.prompt);
if (summary) return summary;
return r.run_id;
}
const METRICS: MetricDef[] = [
{ key: "total_return", label: i18n.t("compare.totalReturn"), type: "pct", higherIsBetter: true },
{ key: "annualized_return", label: i18n.t("compare.annualizedReturn"), type: "pct", higherIsBetter: true },
{ key: "sharpe", label: i18n.t("compare.sharpeRatio"), type: "num", higherIsBetter: true },
{ key: "calmar_ratio", label: i18n.t("compare.calmarRatio"), type: "num", higherIsBetter: true },
{ key: "sortino_ratio", label: i18n.t("compare.sortinoRatio"), type: "num", higherIsBetter: true },
{ key: "max_drawdown", label: i18n.t("compare.maxDrawdown"), type: "pct", higherIsBetter: false },
{ key: "volatility", label: i18n.t("compare.volatility"), type: "pct", higherIsBetter: false },
{ key: "win_rate", label: i18n.t("compare.winRate"), type: "pct", higherIsBetter: true },
{ key: "profit_factor", label: i18n.t("compare.profitFactor"), type: "num", higherIsBetter: true },
{ key: "avg_win", label: i18n.t("compare.avgWin"), type: "pct", higherIsBetter: true },
{ key: "avg_loss", label: i18n.t("compare.avgLoss"), type: "pct", higherIsBetter: false },
{ key: "trade_count", label: i18n.t("compare.trades"), type: "int", higherIsBetter: true },
{ key: "max_consecutive_losses", label: i18n.t("compare.maxConsecLosses"), type: "int", higherIsBetter: false },
{ key: "exposure_time", label: i18n.t("compare.exposureTime"), type: "pct", higherIsBetter: true },
{ key: "avg_holding_period", label: i18n.t("compare.avgHoldingPeriod"), type: "days", higherIsBetter: false },
];
// Also accept backend aliases
const METRIC_ALIASES: Record<string, string> = {
annual_return: "annualized_return",
calmar: "calmar_ratio",
sortino: "sortino_ratio",
profit_loss_ratio: "profit_factor",
max_consec_loss: "max_consecutive_losses",
max_consecutive_loss: "max_consecutive_losses",
avg_hold_days: "avg_holding_period",
avg_holding_days: "avg_holding_period",
};
function resolveMetric(metrics: Record<string, number> | null, key: string): number | undefined {
if (!metrics) return undefined;
if (metrics[key] !== undefined) return metrics[key];
// Check if any alias maps to this key
for (const [alias, canonical] of Object.entries(METRIC_ALIASES)) {
if (canonical === key && metrics[alias] !== undefined) return metrics[alias];
}
return undefined;
}
interface EquityChartOverlayProps {
leftCurve: EquityPoint[];
rightCurve: EquityPoint[];
leftLabel: string;
rightLabel: string;
/** Rebase both curves to 100 at their first bar — runs with different
* initial capital are only comparable on an indexed scale. */
rebase: boolean;
}
function EquityChartOverlay({ leftCurve, rightCurve, leftLabel, rightLabel, rebase }: EquityChartOverlayProps) {
const ref = useRef<HTMLDivElement>(null);
const dark = useThemeDark();
useEffect(() => {
if (!ref.current) return;
if (leftCurve.length === 0 && rightCurve.length === 0) return;
const t = getChartTheme();
const chart = echarts.init(ref.current);
chart.group = CHART_GROUP;
connectCharts();
// Merge dates from both curves and sort
const dateSet = new Set<string>();
for (const p of leftCurve) dateSet.add(p.time);
for (const p of rightCurve) dateSet.add(p.time);
const dates = Array.from(dateSet).sort();
// Build lookup maps, optionally rebased to an index of 100
const leftBase = Number(leftCurve[0]?.equity) || 0;
const rightBase = Number(rightCurve[0]?.equity) || 0;
const scale = (raw: number, base: number) =>
rebase && base > 0 ? Number(((raw / base) * 100).toFixed(2)) : raw;
const leftMap = new Map(leftCurve.map((p) => [p.time, scale(Number(p.equity), leftBase)]));
const rightMap = new Map(rightCurve.map((p) => [p.time, scale(Number(p.equity), rightBase)]));
const leftData = dates.map((d) => leftMap.get(d) ?? null);
const rightData = dates.map((d) => rightMap.get(d) ?? null);
const PRIMARY_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--chart-compare-a").trim() || "#3b82f6";
const SECONDARY_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--chart-compare-b").trim() || "#f59e0b";
chart.setOption({
backgroundColor: "transparent",
tooltip: {
trigger: "axis",
axisPointer: { type: "cross" },
backgroundColor: t.tooltipBg,
borderColor: t.tooltipBorder,
textStyle: { color: t.tooltipText, fontSize: 11 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter: (params: any) => {
if (!Array.isArray(params) || !params.length) return "";
let html = `<b>${params[0].axisValue}</b>`;
for (const p of params) {
if (p.value == null) continue;
const value = rebase ? Number(p.value).toFixed(2) : Number(p.value).toLocaleString();
html += `<br/>${p.marker} ${p.seriesName}: <b>${value}</b>`;
}
return html;
},
},
legend: {
data: [leftLabel, rightLabel],
textStyle: { color: t.textColor, fontSize: 11 },
right: 8,
top: 4,
},
grid: { left: 8, right: 8, top: 36, bottom: 40, containLabel: true },
xAxis: {
type: "category",
data: dates,
axisLine: { lineStyle: { color: t.axisColor } },
axisLabel: { color: t.textColor, fontSize: 10 },
},
yAxis: {
type: "value",
splitLine: { lineStyle: { color: t.gridColor } },
axisLabel: { color: t.textColor, fontSize: 10 },
},
dataZoom: [{ type: "inside" }, { type: "slider", height: 20, bottom: 4 }],
series: [
{
name: leftLabel,
type: "line",
data: leftData,
smooth: false,
symbol: "none",
lineStyle: { color: PRIMARY_COLOR, width: 2 },
connectNulls: true,
},
{
name: rightLabel,
type: "line",
data: rightData,
smooth: false,
symbol: "none",
lineStyle: { color: SECONDARY_COLOR, width: 2 },
connectNulls: true,
},
],
});
let resizeFrame: number | null = null;
const ro = new ResizeObserver(() => {
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null;
chart.resize();
});
});
ro.observe(ref.current!);
return () => {
ro.disconnect();
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
chart.dispose();
};
}, [leftCurve, rightCurve, leftLabel, rightLabel, rebase, dark]);
if (leftCurve.length === 0 && rightCurve.length === 0) return null;
return <div ref={ref} style={{ height: 320 }} />;
}
export function Compare() {
const { t } = useTranslation();
const [runs, setRuns] = useState<RunListItem[]>([]);
const [leftId, setLeftId] = useState("");
const [rightId, setRightId] = useState("");
const [leftData, setLeftData] = useState<Record<string, number> | null>(null);
const [rightData, setRightData] = useState<Record<string, number> | null>(null);
const [leftCurve, setLeftCurve] = useState<EquityPoint[]>([]);
const [rightCurve, setRightCurve] = useState<EquityPoint[]>([]);
const [leftLoading, setLeftLoading] = useState(false);
const [rightLoading, setRightLoading] = useState(false);
const [rebase, setRebase] = useState(true);
const leftRequestGeneration = useRef(0);
const rightRequestGeneration = useRef(0);
useEffect(() => {
api.listRuns().then((items) => {
setRuns(Array.isArray(items) ? items : []);
if (items.length >= 2) { setLeftId(items[1].run_id); setRightId(items[0].run_id); }
else if (items.length === 1) { setLeftId(items[0].run_id); }
}).catch((error) => {
toast.error(error instanceof Error ? error.message : i18n.t("compare.loadError"));
});
}, []);
useEffect(() => {
const generation = ++leftRequestGeneration.current;
setLeftData(null);
setLeftCurve([]);
if (!leftId) {
setLeftLoading(false);
return;
}
setLeftLoading(true);
api.getRun(leftId).then((d: RunData) => {
if (leftRequestGeneration.current === generation) {
setLeftData(d.metrics || null);
setLeftCurve(d.equity_curve || []);
}
}).catch((error) => {
if (leftRequestGeneration.current === generation) {
setLeftData(null);
setLeftCurve([]);
toast.error(error instanceof Error ? error.message : i18n.t("compare.loadError"));
}
}).finally(() => {
if (leftRequestGeneration.current === generation) setLeftLoading(false);
});
return () => {
if (leftRequestGeneration.current === generation) leftRequestGeneration.current += 1;
};
}, [leftId]);
useEffect(() => {
const generation = ++rightRequestGeneration.current;
setRightData(null);
setRightCurve([]);
if (!rightId) {
setRightLoading(false);
return;
}
setRightLoading(true);
api.getRun(rightId).then((d: RunData) => {
if (rightRequestGeneration.current === generation) {
setRightData(d.metrics || null);
setRightCurve(d.equity_curve || []);
}
}).catch((error) => {
if (rightRequestGeneration.current === generation) {
setRightData(null);
setRightCurve([]);
toast.error(error instanceof Error ? error.message : i18n.t("compare.loadError"));
}
}).finally(() => {
if (rightRequestGeneration.current === generation) setRightLoading(false);
});
return () => {
if (rightRequestGeneration.current === generation) rightRequestGeneration.current += 1;
};
}, [rightId]);
const leftRun = runs.find((r) => r.run_id === leftId);
const rightRun = runs.find((r) => r.run_id === rightId);
const loading = leftLoading || rightLoading;
const hasData = Boolean(leftData || rightData);
return (
<div className="mx-auto max-w-5xl p-8 space-y-6">
<h1 className="flex items-center gap-2 text-2xl font-semibold">
<GitCompare className="h-5 w-5" aria-hidden="true" /> {t("compare.title")}
</h1>
{/* Selectors */}
<div className="flex gap-4 items-end rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex-1">
<label className="text-xs text-muted-foreground block mb-1">{i18n.t("compare.baseline")}</label>
<select value={leftId} onChange={(e) => setLeftId(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" title={leftRun?.prompt || leftId}>
<option value="">{i18n.t("compare.select")}</option>
{runs.map((r) => <option key={r.run_id} value={r.run_id}>{runLabel(r)} ({r.status})</option>)}
</select>
</div>
<button
type="button"
onClick={() => { setLeftId(rightId); setRightId(leftId); }}
title={i18n.t("compare.swapRuns")}
className="mb-1 shrink-0 rounded-md border border-border/60 p-2 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
>
<ArrowLeftRight className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">{i18n.t("compare.swapRuns")}</span>
</button>
<div className="flex-1">
<label className="text-xs text-muted-foreground block mb-1">{i18n.t("compare.compare")}</label>
<select value={rightId} onChange={(e) => setRightId(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" title={rightRun?.prompt || rightId}>
<option value="">{i18n.t("compare.select")}</option>
{runs.map((r) => <option key={r.run_id} value={r.run_id}>{runLabel(r)} ({r.status})</option>)}
</select>
</div>
</div>
{/* Loading state — show skeletons while a selected run's data is in flight */}
{loading && !hasData && (
<div className="space-y-6">
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<h2 className="text-sm font-semibold text-muted-foreground mb-2">{i18n.t("compare.equityDrawdown")}</h2>
<SkeletonChart height={320} />
</div>
<div className="overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm">
<SkeletonMetrics />
</div>
</div>
)}
{/* Equity curve overlay */}
{(leftCurve.length > 0 || rightCurve.length > 0) && (
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-muted-foreground">{i18n.t("compare.equityOverlay")}</h2>
<div className="flex gap-1 text-xs" role="group">
{[
{ id: true, label: i18n.t("compare.rebasedMode") },
{ id: false, label: i18n.t("compare.rawMode") },
].map((mode) => (
<button
key={String(mode.id)}
type="button"
onClick={() => setRebase(mode.id)}
className={cn(
"rounded-full border px-2.5 py-1 transition-colors",
rebase === mode.id
? "border-primary/30 bg-primary/10 font-medium text-primary"
: "border-border/60 text-muted-foreground hover:bg-muted/60",
)}
>
{mode.label}
</button>
))}
</div>
</div>
<EquityChartOverlay
leftCurve={leftCurve}
rightCurve={rightCurve}
leftLabel={leftRun ? truncatePrompt(leftRun.prompt, 20) || t("compare.baseline") : t("compare.baseline")}
rightLabel={rightRun ? truncatePrompt(rightRun.prompt, 20) || t("compare.compare") : t("compare.compare")}
rebase={rebase}
/>
</div>
)}
{/* Metrics table */}
{(leftData || rightData) && (
<div className="overflow-x-auto rounded-xl border border-border/60 bg-card shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40 text-[11px] uppercase tracking-wide text-muted-foreground [&_th]:font-medium">
<th className="text-left px-4 py-2.5 font-medium">{i18n.t("compare.metric")}</th>
<th className="text-right px-4 py-2.5 font-medium">{i18n.t("compare.baselineCol")}</th>
<th className="text-right px-4 py-2.5 font-medium">{i18n.t("compare.compareCol")}</th>
<th className="text-right px-4 py-2.5 font-medium">{i18n.t("compare.delta")}</th>
</tr>
</thead>
<tbody>
{METRICS.map(({ key, label, type, higherIsBetter }) => {
const lv = resolveMetric(leftData, key);
const rv = resolveMetric(rightData, key);
const diff = diffClass(lv, rv, higherIsBetter);
// Proportional in-cell bars (colored to match the chart legend)
// make the winner readable at a glance without parsing numbers.
const la = Number.isFinite(Number(lv)) ? Math.abs(Number(lv)) : null;
const ra = Number.isFinite(Number(rv)) ? Math.abs(Number(rv)) : null;
const maxAbs = Math.max(la ?? 0, ra ?? 0);
const shareL = la != null && maxAbs > 0 ? la / maxAbs : 0;
const shareR = ra != null && maxAbs > 0 ? ra / maxAbs : 0;
const leftWins = diff.verdict === "worse";
const rightWins = diff.verdict === "better";
return (
<tr key={key} className="border-b last:border-0 hover:bg-muted/40">
<td className="px-4 py-2.5 font-medium">{label}</td>
<td className="relative px-4 py-2.5 text-right font-mono tabular-nums">
{shareL > 0 && (
<span
aria-hidden="true"
className="absolute inset-y-1.5 end-2 rounded-sm"
style={{ width: `${shareL * 55}%`, backgroundColor: "var(--chart-compare-a)", opacity: 0.14 }}
/>
)}
<span className={cn("relative", leftWins && "font-semibold")}>{fmt(lv, type)}</span>
</td>
<td className="relative px-4 py-2.5 text-right font-mono tabular-nums">
{shareR > 0 && (
<span
aria-hidden="true"
className="absolute inset-y-1.5 end-2 rounded-sm"
style={{ width: `${shareR * 55}%`, backgroundColor: "var(--chart-compare-b)", opacity: 0.14 }}
/>
)}
<span className={cn("relative", rightWins && "font-semibold")}>{fmt(rv, type)}</span>
</td>
<td className={cn("px-4 py-2.5 text-right font-mono tabular-nums font-semibold", diff.className)}>
{diff.verdict && (
<>
<span aria-hidden="true">{diff.glyph} </span>
<span className="sr-only">
{t(`compare.${diff.verdict}`)}
{": "}
</span>
</>
)}
{diffStr(lv, rv, type)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{!hasData && !loading && (
<div className="text-center py-16 text-muted-foreground">
<GitCompare className="h-12 w-12 mx-auto mb-3 opacity-20" />
<p className="text-sm">{i18n.t("compare.selectTwoRuns")}</p>
</div>
)}
</div>
);
}