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