spb/crypto-trade-bot
Public
Python 54.7%
TypeScript 42.5%
SQL 1.9%
1"use client";2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";3import { Trash2 } from "lucide-react";4import Link from "next/link";5import { useMemo, useState } from "react";6import { StatusBadge } from "../page";7import { LinesChart, type LineSpec } from "@/components/charts";8import { Button, Card, Empty, ErrorBox, PageHeader, Spinner, Table, Td, Th } from "@/components/ui";9import { api } from "@/lib/api";10import { fmtIso, fmtNum, fmtPct, fmtPctPlain, fmtRatio, signClass } from "@/lib/format";11import type { EquityPoint, Metrics, RunRow } from "@/lib/types";1213const COLORS = ["#4f8cff", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#14b8a6", "#f472b6", "#eab308"];1415type CompareItem = { id: string; strategy: string; symbol: string; interval_min: number; params: Record<string, unknown>; metrics: Metrics; equity: EquityPoint[]; label: string | null };1617export default function BacktestsPage() {18 const qc = useQueryClient();19 const runs = useQuery({20 queryKey: ["runs", 200],21 queryFn: () => api<{ items: RunRow[] }>("/api/backtests?limit=200"),22 refetchInterval: (q) => (q.state.data?.items.some((r) => r.status === "queued" || r.status === "running") ? 2_000 : 15_000),23 });24 const [sel, setSel] = useState<string[]>([]);25 const compare = useQuery({ queryKey: ["compare", sel], queryFn: () => api<{ items: CompareItem[] }>("/api/backtests/compare", { method: "POST", json: sel }), enabled: sel.length >= 2 });26 const del = useMutation({ mutationFn: (id: string) => api(`/api/backtests/${id}`, { method: "DELETE" }), onSuccess: () => qc.invalidateQueries({ queryKey: ["runs"] }) });27 const toggle = (id: string) => setSel((s) => (s.includes(id) ? s.filter((x) => x !== id) : s.length < 8 ? [...s, id] : s));2829 const series: LineSpec[] = useMemo(() => (compare.data?.items ?? []).map((it, i) => {30 const e0 = it.equity[0]?.equity ?? 1;31 return { id: it.id, label: `${it.strategy} ${it.symbol} ${it.interval_min}m`, color: COLORS[i % COLORS.length], points: it.equity.map((p) => ({ ts: p.ts, value: (p.equity / e0 - 1) * 100 })) };32 }), [compare.data]);3334 return (35 <>36 <PageHeader title="Backtest Explorer" subtitle="Every run records provenance (commit, dataset hash, parameters, costs). Select 2–8 done runs to compare." />37 {sel.length >= 2 && (38 <Card title={`Comparison (${sel.length} runs)`} subtitle="Equity normalized to the start of each run, in percent." className="mb-4" actions={<Button size="sm" onClick={() => setSel([])}>Clear</Button>}>39 {compare.isLoading && <Spinner />}40 {compare.error && <ErrorBox error={compare.error} />}41 {compare.data && (42 <>43 <LinesChart series={series} priceFormat="percent" height={280} />44 <Table className="mt-3">45 <thead><tr><Th>Run</Th><Th>Params</Th><Th align="right">CAGR</Th><Th align="right">Vol</Th><Th align="right">Sharpe</Th><Th align="right">Sortino</Th><Th align="right">Max DD</Th><Th align="right">Calmar</Th><Th align="right">Turnover</Th><Th align="right">Trades</Th><Th align="right">Fee drag</Th></tr></thead>46 <tbody>{compare.data.items.map((it, i) => (47 <tr key={it.id}>48 <Td><span className="mr-1.5 inline-block h-2 w-2 rounded-full" style={{ background: COLORS[i % COLORS.length] }} /><Link href={`/backtests/${it.id}`} className="text-accent hover:underline">{it.strategy} {it.symbol} {it.interval_min}m</Link>{it.label && <span className="ml-1 text-fg-dim">({it.label})</span>}</Td>49 <Td className="max-w-56 truncate font-mono text-[11px] text-fg-muted">{JSON.stringify(it.params)}</Td>50 <Td align="right" num className={signClass(it.metrics.cagr)}>{fmtPct(it.metrics.cagr)}</Td><Td align="right" num>{fmtPctPlain(it.metrics.volatility)}</Td><Td align="right" num>{fmtRatio(it.metrics.sharpe)}</Td><Td align="right" num>{fmtRatio(it.metrics.sortino)}</Td>51 <Td align="right" num className="text-down">{fmtPct(it.metrics.max_drawdown)}</Td><Td align="right" num>{fmtRatio(it.metrics.calmar)}</Td><Td align="right" num>{fmtRatio(it.metrics.turnover_annual, 1)}×</Td><Td align="right" num>{it.metrics.n_trades}</Td><Td align="right" num>{fmtPctPlain(it.metrics.fee_drag, 2)}</Td>52 </tr>53 ))}</tbody>54 </Table>55 </>56 )}57 </Card>58 )}59 <Card title="Runs" actions={<Link href="/lab" className="text-xs text-accent hover:underline">New backtest →</Link>}>60 {runs.isLoading && <Spinner />}61 {runs.error && <ErrorBox error={runs.error} />}62 {runs.data && (runs.data.items.length ? (63 <Table>64 <thead><tr><Th><span className="sr-only">Compare</span></Th><Th>Status</Th><Th>Strategy</Th><Th>Symbol</Th><Th align="right">Interval</Th><Th align="right">Sharpe</Th><Th align="right">Return</Th><Th align="right">Max DD</Th><Th align="right">Trades</Th><Th>Label</Th><Th>Created</Th><Th><span className="sr-only">Delete</span></Th></tr></thead>65 <tbody>{runs.data.items.map((r) => (66 <tr key={r.id} className="hover:bg-bg-sub">67 <Td><input type="checkbox" aria-label={`Select run ${r.id}`} disabled={r.status !== "done"} checked={sel.includes(r.id)} onChange={() => toggle(r.id)} /></Td>68 <Td><Link href={`/backtests/${r.id}`} title={r.error ?? undefined}><StatusBadge status={r.status} /></Link></Td>69 <Td><Link href={`/backtests/${r.id}`} className="font-medium text-accent hover:underline">{r.strategy}</Link></Td>70 <Td>{r.symbol}</Td><Td align="right" num>{r.interval_min}m</Td>71 <Td align="right" num>{fmtNum(r.sharpe)}</Td><Td align="right" num className={signClass(r.total_return)}>{fmtPct(r.total_return)}</Td><Td align="right" num className="text-down">{fmtPct(r.max_drawdown)}</Td><Td align="right" num>{r.n_trades ?? "—"}</Td>72 <Td className="text-fg-muted">{r.label ?? ""}{r.error && <span className="block max-w-64 truncate text-[11px] text-down" title={r.error}>{r.error}</span>}</Td>73 <Td num className="text-fg-dim">{fmtIso(r.created_at)}</Td>74 <Td><button aria-label="Delete run" className="rounded p-1 text-fg-dim hover:bg-bg-sub hover:text-down" onClick={() => { if (confirm("Delete this run and its artifacts?")) del.mutate(r.id); }}><Trash2 className="h-3.5 w-3.5" /></button></Td>75 </tr>76 ))}</tbody>77 </Table>78 ) : <Empty title="No backtests yet">Launch one from the Strategy Lab.</Empty>)}79 </Card>80 </>81 );82}83