SPB Git forge
1commits 1branches 0releases
189.0 KBsize
maindefault branch
6 h agolast push
Python 54.7% TypeScript 42.5% SQL 1.9%
6.5 KB · 83 lines tsx
Raw Blame History
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