spb/crypto-trade-bot
Public
Python 54.7%
TypeScript 42.5%
SQL 1.9%
1"use client";2import { useMutation, useQuery } from "@tanstack/react-query";3import Link from "next/link";4import { useRouter } from "next/navigation";5import { useMemo, useState } from "react";6import { StatusBadge } from "../page";7import { useSystemStatus } from "@/components/shell";8import { Badge, Button, Card, Empty, ErrorBox, Field, Input, PageHeader, Select, Spinner, Table, Td, Textarea, Th, cx } from "@/components/ui";9import { api, ApiError } from "@/lib/api";10import { fmtIso, fmtNum, fmtPct, signClass } from "@/lib/format";11import type { ParamSpec, RunRow, StrategyMeta } from "@/lib/types";1213const DEFAULT_GRIDS: Record<string, Record<string, unknown[]>> = {14 tsmom: { lookback: [24, 48, 96], ema_fast: [10, 20], ema_slow: [60, 120] },15 ma_ensemble: { slow_mult: [2, 3, 5], target_vol: [0.3, 0.5] },16 zscore_mr: { window: [24, 48, 96], entry_z: [1.5, 2.0, 2.5] },17 simple_momentum: { lookback: [12, 24, 48] },18 equal_risk: { vol_lookback: [24, 48, 96] },19};2021function defaultParams(s: StrategyMeta) {22 return Object.fromEntries(s.params.map((p) => [p.name, p.default]));23}2425export default function LabPage() {26 const sys = useSystemStatus();27 const strategies = useQuery({ queryKey: ["strategies"], queryFn: () => api<{ items: StrategyMeta[] }>("/api/strategies") });28 const runs = useQuery({ queryKey: ["runs", 12], queryFn: () => api<{ items: RunRow[] }>("/api/backtests?limit=12"), refetchInterval: 5_000 });29 const [selected, setSelected] = useState<string | null>(null);30 const strat = strategies.data?.items.find((s) => s.name === selected) ?? null;31 return (32 <>33 <PageHeader title="Strategy Lab" subtitle="Catalog of strategy plugins. Every run passes the same cost model and risk gateway used by paper/live engines." />34 <div className="grid gap-4 xl:grid-cols-[360px_1fr]">35 <div className="flex flex-col gap-3">36 {strategies.isLoading && <Spinner />}37 {strategies.error && <ErrorBox error={strategies.error} />}38 {strategies.data?.items.map((s) => (39 <button key={s.name} onClick={() => setSelected(s.name)} aria-pressed={selected === s.name} className={cx("rounded-lg border p-3 text-left transition", selected === s.name ? "border-accent bg-accent-soft/40" : "border-line bg-bg-elev hover:border-line-strong")}>40 <div className="flex items-center justify-between gap-2">41 <span className="text-sm font-semibold">{s.label}</span>42 <span className="flex gap-1"><Badge tone="muted">{s.family}</Badge>{s.experimental && <Badge tone="warn">experimental</Badge>}</span>43 </div>44 <p className="mt-1 line-clamp-3 text-xs text-fg-muted">{s.hypothesis}</p>45 {s.failure_modes.length > 0 && <p className="mt-1.5 text-[11px] text-fg-dim">Failure modes: {s.failure_modes.join(" · ")}</p>}46 </button>47 ))}48 </div>49 <div className="flex flex-col gap-4">50 {strat ? (51 <ConfigForm key={strat.name} strat={strat} universe={sys.data?.universe ?? []} intervals={sys.data?.intervals ?? [60]} />52 ) : (53 <Card title="Configure a strategy"><Empty title="Select a strategy from the catalog">Parameters, cost model, holdout, walk-forward and sensitivity options appear here.</Empty></Card>54 )}55 <Card title="Recent runs" actions={<Link href="/backtests" className="text-xs text-accent hover:underline">Backtest Explorer →</Link>}>56 {runs.data?.items.length ? (57 <Table>58 <thead><tr><Th>Status</Th><Th>Strategy</Th><Th>Symbol</Th><Th align="right">Sharpe</Th><Th align="right">Return</Th><Th>Label</Th><Th>Created</Th></tr></thead>59 <tbody>{runs.data.items.map((r) => (60 <tr key={r.id} className="hover:bg-bg-sub">61 <Td><Link href={`/backtests/${r.id}`}><StatusBadge status={r.status} /></Link></Td><Td>{r.strategy}</Td><Td>{r.symbol} {r.interval_min}m</Td>62 <Td align="right" num>{fmtNum(r.sharpe)}</Td><Td align="right" num className={signClass(r.total_return)}>{fmtPct(r.total_return)}</Td><Td className="text-fg-muted">{r.label ?? ""}</Td><Td num className="text-fg-dim">{fmtIso(r.created_at)}</Td>63 </tr>64 ))}</tbody>65 </Table>66 ) : <Empty title="No runs yet" />}67 </Card>68 </div>69 </div>70 </>71 );72}7374function ConfigForm({ strat, universe, intervals }: { strat: StrategyMeta; universe: string[]; intervals: number[] }) {75 const router = useRouter();76 const [params, setParams] = useState<Record<string, unknown>>(() => defaultParams(strat));77 const [symbol, setSymbol] = useState(universe[0] ?? "");78 const [interval, setInterval_] = useState(60);79 const [cash, setCash] = useState(10000);80 const [cost, setCost] = useState({ taker_fee: 0.004, maker_fee: 0.0025, spread_bps: 5, slippage_bps: 2, max_participation: 0.05 });81 const [holdout, setHoldout] = useState(0.2);82 const [wfOn, setWfOn] = useState(false);83 const [wfGrid, setWfGrid] = useState(() => JSON.stringify(DEFAULT_GRIDS[strat.name] ?? Object.fromEntries(strat.params.filter((p) => p.type !== "bool").slice(0, 2).map((p) => [p.name, [p.default]])), null, 1));84 const [wfFolds, setWfFolds] = useState(4);85 const [wfAnchored, setWfAnchored] = useState(true);86 const [sensOn, setSensOn] = useState(false);87 const [sens, setSens] = useState(() => {88 const nums = strat.params.filter((p) => p.type === "int" || p.type === "float");89 return { x: nums[0]?.name ?? "", xv: nums[0] ? String(nums[0].default) : "", y: nums[1]?.name ?? "", yv: nums[1] ? String(nums[1].default) : "" };90 });91 const [label, setLabel] = useState("");92 const [err, setErr] = useState<string | null>(null);939495 const validation = useMemo(() => {96 const out: string[] = [];97 for (const p of strat.params) {98 const v = params[p.name];99 if ((p.type === "int" || p.type === "float") && typeof v === "number") {100 if (p.min != null && v < p.min) out.push(`${p.name} must be ≥ ${p.min}`);101 if (p.max != null && v > p.max) out.push(`${p.name} must be ≤ ${p.max}`);102 if (p.type === "int" && !Number.isInteger(v)) out.push(`${p.name} must be an integer`);103 }104 }105 return out;106 }, [strat, params]);107108 const launch = useMutation({109 mutationFn: async () => {110 const body: Record<string, unknown> = { strategy: strat.name, symbol, interval_min: interval, params, cost_model: cost, initial_cash: cash, holdout_frac: holdout, label: label || null, stress: true };111 if (wfOn) {112 let grid: unknown;113 try { grid = JSON.parse(wfGrid); } catch { throw new Error("walk-forward grid is not valid JSON"); }114 body.walk_forward = { grid, n_folds: wfFolds, anchored: wfAnchored, train_frac: 0.6 };115 body.n_trials = Object.values(grid as Record<string, unknown[]>).reduce((a, v) => a * (Array.isArray(v) ? v.length : 1), 1);116 }117 if (sensOn && sens.x && sens.y) {118 const parse = (s: string) => s.split(",").map((x) => x.trim()).filter(Boolean).map((x) => (Number.isFinite(Number(x)) ? Number(x) : x));119 body.sensitivity = { x: sens.x, x_values: parse(sens.xv), y: sens.y, y_values: parse(sens.yv), metric: "sharpe" };120 }121 return api<{ id: string }>("/api/backtests", { method: "POST", json: body });122 },123 onSuccess: (r) => router.push(`/backtests/${r.id}`),124 onError: (e) => setErr(e instanceof ApiError ? e.message : String(e)),125 });126127 return (128 <Card title={`Configure: ${strat.label}`} subtitle={<span>{strat.hypothesis} <span className="text-fg-dim">Needs ≥ {strat.min_bars} closed bars. {strat.allows_short ? "May short." : "Long-only (spot)."}</span></span>}>129 <form onSubmit={(e) => { e.preventDefault(); setErr(null); launch.mutate(); }} className="flex flex-col gap-4">130 <div className="grid grid-cols-2 gap-3 md:grid-cols-4">131 <Field label="Symbol"><Select value={symbol} onChange={(e) => setSymbol(e.target.value)}>{universe.map((s) => <option key={s}>{s}</option>)}</Select></Field>132 <Field label="Interval"><Select value={interval} onChange={(e) => setInterval_(Number(e.target.value))}>{intervals.map((i) => <option key={i} value={i}>{i} min</option>)}</Select></Field>133 <Field label="Initial cash (USD)"><Input type="number" min={10} step={100} value={cash} onChange={(e) => setCash(Number(e.target.value))} /></Field>134 <Field label="Holdout fraction" hint="Final slice never used for selection"><Input type="number" min={0} max={0.5} step={0.05} value={holdout} onChange={(e) => setHoldout(Number(e.target.value))} /></Field>135 </div>136 <fieldset className="rounded-md border border-line p-3">137 <legend className="px-1 text-xs font-semibold text-fg-muted">Strategy parameters</legend>138 <div className="grid grid-cols-2 gap-3 md:grid-cols-3">139 {strat.params.map((p) => <ParamInput key={p.name} spec={p} value={params[p.name]} onChange={(v) => setParams((prev) => ({ ...prev, [p.name]: v }))} />)}140 {strat.params.length === 0 && <p className="text-xs text-fg-dim">No parameters.</p>}141 </div>142 {validation.length > 0 && <ul className="mt-2 text-xs text-down">{validation.map((v) => <li key={v}>{v}</li>)}</ul>}143 </fieldset>144 <fieldset className="rounded-md border border-line p-3">145 <legend className="px-1 text-xs font-semibold text-fg-muted">Cost model</legend>146 <div className="grid grid-cols-2 gap-3 md:grid-cols-5">147 <Field label="Taker fee" hint="fraction (0.004 = 0.40%)"><Input type="number" step={0.0001} min={0} value={cost.taker_fee} onChange={(e) => setCost({ ...cost, taker_fee: Number(e.target.value) })} /></Field>148 <Field label="Maker fee"><Input type="number" step={0.0001} min={0} value={cost.maker_fee} onChange={(e) => setCost({ ...cost, maker_fee: Number(e.target.value) })} /></Field>149 <Field label="Spread (bps)"><Input type="number" step={0.5} min={0} value={cost.spread_bps} onChange={(e) => setCost({ ...cost, spread_bps: Number(e.target.value) })} /></Field>150 <Field label="Slippage (bps)"><Input type="number" step={0.5} min={0} value={cost.slippage_bps} onChange={(e) => setCost({ ...cost, slippage_bps: Number(e.target.value) })} /></Field>151 <Field label="Max participation" hint="fraction of bar volume"><Input type="number" step={0.01} min={0.001} max={1} value={cost.max_participation} onChange={(e) => setCost({ ...cost, max_participation: Number(e.target.value) })} /></Field>152 </div>153 </fieldset>154 <fieldset className="rounded-md border border-line p-3">155 <legend className="px-1 text-xs font-semibold text-fg-muted"><label className="flex items-center gap-2"><input type="checkbox" checked={wfOn} onChange={(e) => setWfOn(e.target.checked)} /> Walk-forward evaluation</label></legend>156 {wfOn && (157 <div className="grid gap-3 md:grid-cols-[1fr_140px_140px]">158 <Field label="Parameter grid (JSON)" hint="Best in-sample Sharpe per fold is then evaluated out-of-sample; trial count feeds the deflated Sharpe."><Textarea rows={5} value={wfGrid} onChange={(e) => setWfGrid(e.target.value)} /></Field>159 <Field label="Folds"><Input type="number" min={2} max={10} value={wfFolds} onChange={(e) => setWfFolds(Number(e.target.value))} /></Field>160 <Field label="Train window"><Select value={wfAnchored ? "anchored" : "rolling"} onChange={(e) => setWfAnchored(e.target.value === "anchored")}><option value="anchored">Anchored</option><option value="rolling">Rolling</option></Select></Field>161 </div>162 )}163 </fieldset>164 <fieldset className="rounded-md border border-line p-3">165 <legend className="px-1 text-xs font-semibold text-fg-muted"><label className="flex items-center gap-2"><input type="checkbox" checked={sensOn} onChange={(e) => setSensOn(e.target.checked)} /> Sensitivity map (Sharpe)</label></legend>166 {sensOn && (167 <div className="grid grid-cols-2 gap-3 md:grid-cols-4">168 <Field label="X parameter"><Select value={sens.x} onChange={(e) => setSens({ ...sens, x: e.target.value })}>{strat.params.map((p) => <option key={p.name}>{p.name}</option>)}</Select></Field>169 <Field label="X values" hint="comma-separated"><Input value={sens.xv} onChange={(e) => setSens({ ...sens, xv: e.target.value })} /></Field>170 <Field label="Y parameter"><Select value={sens.y} onChange={(e) => setSens({ ...sens, y: e.target.value })}>{strat.params.map((p) => <option key={p.name}>{p.name}</option>)}</Select></Field>171 <Field label="Y values"><Input value={sens.yv} onChange={(e) => setSens({ ...sens, yv: e.target.value })} /></Field>172 </div>173 )}174 </fieldset>175 <div className="flex flex-wrap items-end gap-3">176 <Field label="Label (optional)"><Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. tsmom 1h baseline" /></Field>177 <Button type="submit" variant="primary" loading={launch.isPending} disabled={validation.length > 0 || !symbol}>Run backtest</Button>178 {err && <span role="alert" className="text-xs text-down">{err}</span>}179 </div>180 </form>181 </Card>182 );183}184185function ParamInput({ spec, value, onChange }: { spec: ParamSpec; value: unknown; onChange: (v: unknown) => void }) {186 const hint = [spec.description, spec.min != null || spec.max != null ? `range ${spec.min ?? "−∞"} … ${spec.max ?? "∞"}` : null].filter(Boolean).join(" · ");187 if (spec.type === "bool") return <Field label={spec.name} hint={hint}><label className="flex h-9 items-center gap-2 text-sm"><input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} /> enabled</label></Field>;188 if (spec.type === "choice") return <Field label={spec.name} hint={hint}><Select value={String(value)} onChange={(e) => onChange(e.target.value)}>{(spec.choices ?? []).map((c) => <option key={String(c)} value={String(c)}>{String(c)}</option>)}</Select></Field>;189 return <Field label={spec.name} hint={hint}><Input type="number" step={spec.step ?? (spec.type === "int" ? 1 : 0.01)} min={spec.min ?? undefined} max={spec.max ?? undefined} value={value as number} onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))} /></Field>;190}191