SPB Git forge
1commits 1branches 0releases
189.0 KBsize
maindefault branch
5 h agolast push
Python 54.7% TypeScript 42.5% SQL 1.9%
12.8 KB · 180 lines tsx
Raw Blame History
1"use client";2import { DrawdownChart, LinesChart, type LineSpec } from "@/components/charts";3import { Badge, Card, Empty, KV, Table, Td, Th, cx } from "@/components/ui";4import { fmtBps, fmtDate, fmtNum, fmtPct, fmtPctPlain, fmtRatio, fmtTs, fmtUsd, signClass } from "@/lib/format";5import type { Artifacts, Metrics, Run } from "@/lib/types";67const BENCH_COLORS: Record<string, string> = { buy_and_hold: "#f59e0b", cash: "#66718a", simple_momentum: "#a855f7", equal_risk: "#14b8a6" };89export function MetricsGrid({ m, compact = false }: { m: Metrics; compact?: boolean }) {10  const ci = m.bootstrap?.sharpe_ci;11  const items: [string, string, string?][] = [12    ["CAGR", fmtPct(m.cagr), signClass(m.cagr)],13    ["Total return", fmtPct(m.total_return), signClass(m.total_return)],14    ["Volatility (ann.)", fmtPctPlain(m.volatility)],15    ["Sharpe", `${fmtRatio(m.sharpe)}${ci && ci[0] != null ? ` [${fmtRatio(ci[0])}, ${fmtRatio(ci[1])}]` : ""}`],16    ["Deflated Sharpe (prob.)", m.deflated_sharpe?.dsr != null ? `${fmtPctPlain(m.deflated_sharpe.dsr)} (${m.deflated_sharpe.n_trials ?? 1} trials)` : "—"],17    ["Sortino", fmtRatio(m.sortino)],18    ["Max drawdown", fmtPct(m.max_drawdown), "text-down"],19    ["Calmar", fmtRatio(m.calmar)],20    ["Turnover (ann.)", `${fmtRatio(m.turnover_annual, 1)}×`],21    ["Exposure", fmtPctPlain(m.exposure)],22    ["Trades / round trips", `${m.n_trades ?? "—"} / ${m.n_round_trips ?? "—"}`],23    ["Hit rate", fmtPctPlain(m.hit_rate)],24    ["Profit factor", fmtRatio(m.profit_factor)],25    ["Avg round-trip P&L", fmtUsd(m.avg_trade_pnl)],26    ["Fee drag", fmtPctPlain(m.fee_drag, 2)],27    ["Slippage drag", fmtPctPlain(m.slippage_drag, 2)],28    ["VaR 95% (1 bar)", fmtPct(m.var_95_bar), "text-down"],29    ["CVaR 95% (1 bar)", fmtPct(m.cvar_95_bar), "text-down"],30    ["Underwater time", fmtPctPlain(m.underwater_bars_pct)],31    ["Skew / kurtosis", `${fmtRatio(m.skew)} / ${fmtRatio(m.kurtosis)}`],32    ["Bars / years", `${m.bars ?? "—"} / ${fmtRatio(m.years)}`],33    ["Final equity", fmtUsd(m.final_equity)],34  ];35  return (36    <div className={cx("grid gap-2", compact ? "grid-cols-2 md:grid-cols-4" : "grid-cols-2 md:grid-cols-4 xl:grid-cols-6")}>37      {items.map(([k, v, cls]) => (38        <div key={k} className="rounded-md border border-line bg-bg px-3 py-2">39          <div className="text-[10px] uppercase tracking-wide text-fg-dim">{k}</div>40          <div className={cx("num mt-0.5 text-sm font-semibold", cls)}>{v}</div>41        </div>42      ))}43    </div>44  );45}4647export function EquityVsBenchmarks({ a, label }: { a: Artifacts; label: string }) {48  const series: LineSpec[] = [{ id: "strategy", label, color: "#4f8cff", width: 2, points: a.equity.map((p) => ({ ts: p.ts, value: p.equity })) }];49  for (const [name, b] of Object.entries(a.benchmarks ?? {})) series.push({ id: name, label: name.replace(/_/g, " "), color: BENCH_COLORS[name] ?? "#888", width: 1, points: b.equity.map((p) => ({ ts: p.ts, value: p.equity })) });50  return (51    <>52      <LinesChart series={series} height={320} />53      <div className="mt-2 flex flex-wrap gap-3 text-[11px]">{series.map((s) => <span key={s.id} className="flex items-center gap-1.5"><span className="inline-block h-2 w-2 rounded-full" style={{ background: s.color }} />{s.label}</span>)}</div>54    </>55  );56}5758export function BenchmarkTable({ a, m }: { a: Artifacts; m: Metrics }) {59  const rows = [["strategy", m] as [string, Metrics], ...Object.entries(a.benchmarks ?? {}).map(([k, v]) => [k, v.metrics] as [string, Metrics])];60  return (61    <Table>62      <thead><tr><Th>Series</Th><Th align="right">CAGR</Th><Th align="right">Vol</Th><Th align="right">Sharpe</Th><Th align="right">Max DD</Th><Th align="right">Calmar</Th><Th align="right">Turnover</Th><Th align="right">Fee drag</Th></tr></thead>63      <tbody>{rows.map(([k, v]) => (64        <tr key={k} className={k === "strategy" ? "font-semibold" : ""}><Td>{k.replace(/_/g, " ")}</Td><Td align="right" num className={signClass(v.cagr)}>{fmtPct(v.cagr)}</Td><Td align="right" num>{fmtPctPlain(v.volatility)}</Td><Td align="right" num>{fmtRatio(v.sharpe)}</Td><Td align="right" num className="text-down">{fmtPct(v.max_drawdown)}</Td><Td align="right" num>{fmtRatio(v.calmar)}</Td><Td align="right" num>{fmtRatio(v.turnover_annual, 1)}×</Td><Td align="right" num>{fmtPctPlain(v.fee_drag, 2)}</Td></tr>65      ))}</tbody>66    </Table>67  );68}6970export function Underwater({ a }: { a: Artifacts }) {71  return <DrawdownChart points={a.equity} />;72}7374export function RollingSharpe({ a }: { a: Artifacts }) {75  if (!a.rolling_sharpe?.length) return <Empty title="Not enough bars for a rolling window" />;76  return <LinesChart series={[{ id: "rs", label: "rolling Sharpe", color: "#a855f7", points: a.rolling_sharpe.map((p) => ({ ts: p.ts, value: p.sharpe })) }]} height={180} />;77}7879export function WalkForward({ wf }: { wf: Artifacts["walk_forward"] }) {80  if (!wf) return <Empty title="Walk-forward not requested">Enable it in the Strategy Lab to see out-of-sample folds.</Empty>;81  if (wf.error) return <Empty title="Walk-forward skipped">{wf.error}</Empty>;82  return (83    <>84      <div className="mb-2 flex flex-wrap gap-2 text-xs">85        <Badge tone="muted">{wf.anchored ? "anchored" : "rolling"} train</Badge><Badge tone="muted">{wf.n_trials} grid points</Badge><Badge tone="muted">embargo {wf.embargo_bars ?? 0} bars</Badge>86        {wf.stitched_oos && <Badge tone={(wf.stitched_oos.sharpe ?? 0) > 0 ? "up" : "down"}>stitched OOS Sharpe {fmtRatio(wf.stitched_oos.sharpe)} · return {fmtPct(wf.stitched_oos.total_return)} · {wf.stitched_oos.bars} bars</Badge>}87      </div>88      <Table>89        <thead><tr><Th>Fold</Th><Th>Train</Th><Th>Test (OOS)</Th><Th>Best params (IS)</Th><Th align="right">IS Sharpe</Th><Th align="right">OOS Sharpe</Th><Th align="right">OOS return</Th><Th align="right">OOS max DD</Th></tr></thead>90        <tbody>{wf.folds.map((f) => (91          <tr key={f.fold}><Td num>{f.fold + 1}</Td><Td num className="text-fg-muted">{fmtDate(f.train[0])} → {fmtDate(f.train[1])}</Td><Td num>{fmtDate(f.test[0])} → {fmtDate(f.test[1])}</Td><Td className="font-mono text-[11px]">{JSON.stringify(f.best_params)}</Td><Td align="right" num>{fmtRatio(f.is_sharpe)}</Td><Td align="right" num className={signClass(f.oos_sharpe)}>{fmtRatio(f.oos_sharpe)}</Td><Td align="right" num className={signClass(f.oos_return)}>{fmtPct(f.oos_return)}</Td><Td align="right" num className="text-down">{fmtPct(f.oos_max_dd)}</Td></tr>92        ))}</tbody>93      </Table>94    </>95  );96}9798export function SensitivityHeatmap({ s }: { s: Artifacts["sensitivity"] }) {99  if (!s) return <Empty title="Sensitivity map not requested">Enable it in the Strategy Lab (two parameters × value lists).</Empty>;100  const flat = s.z.flat().filter((v): v is number => v != null);101  const min = Math.min(...flat), max = Math.max(...flat);102  const color = (v: number | null) => {103    if (v == null) return "transparent";104    const t = max === min ? 0.5 : (v - min) / (max - min);105    return v >= 0 ? `rgba(34,197,94,${0.15 + 0.7 * t})` : `rgba(239,68,68,${0.15 + 0.7 * (1 - t)})`;106  };107  return (108    <div className="overflow-x-auto">109      <table className="text-xs">110        <caption className="mb-1 text-left text-[11px] text-fg-dim">{s.metric} by {s.x.name} (columns) × {s.y.name} (rows)</caption>111        <thead><tr><Th>{s.y.name} \ {s.x.name}</Th>{s.x.values.map((x) => <Th key={String(x)} align="center">{String(x)}</Th>)}</tr></thead>112        <tbody>{s.y.values.map((y, j) => (113          <tr key={String(y)}><Th scope="row">{String(y)}</Th>{s.z[j].map((v, i) => <td key={i} className="num border border-line px-3 py-2 text-center" style={{ background: color(v) }}>{v == null ? "—" : v.toFixed(2)}</td>)}</tr>114        ))}</tbody>115      </table>116    </div>117  );118}119120export function StressTable({ stress, base }: { stress: Artifacts["stress"]; base: Metrics }) {121  const rows = Object.entries(stress ?? {});122  if (!rows.length) return <Empty title="No stress scenarios" />;123  return (124    <Table>125      <thead><tr><Th>Scenario</Th><Th align="right">Sharpe</Th><Th align="right">Δ Sharpe</Th><Th align="right">Return</Th><Th align="right">Max DD</Th><Th align="right">Fee drag</Th></tr></thead>126      <tbody>127        <tr className="font-semibold"><Td>base</Td><Td align="right" num>{fmtRatio(base.sharpe)}</Td><Td align="right" num>—</Td><Td align="right" num className={signClass(base.total_return)}>{fmtPct(base.total_return)}</Td><Td align="right" num className="text-down">{fmtPct(base.max_drawdown)}</Td><Td align="right" num>{fmtPctPlain(base.fee_drag, 2)}</Td></tr>128        {rows.map(([k, v]) => <tr key={k}><Td>{k.replace(/_/g, " ")}</Td><Td align="right" num>{fmtRatio(v.sharpe)}</Td><Td align="right" num className={signClass((v.sharpe ?? 0) - (base.sharpe ?? 0))}>{v.sharpe != null && base.sharpe != null ? fmtRatio(v.sharpe - base.sharpe) : "—"}</Td><Td align="right" num className={signClass(v.total_return)}>{fmtPct(v.total_return)}</Td><Td align="right" num className="text-down">{fmtPct(v.max_drawdown)}</Td><Td align="right" num>{v.fee_drag != null ? fmtPctPlain(v.fee_drag, 2) : "—"}</Td></tr>)}129      </tbody>130    </Table>131  );132}133134export function ShuffledTrades({ s }: { s: Artifacts["shuffled_trades"] }) {135  if (!s || !s.n) return <Empty title="Too few round trips for a Monte-Carlo reshuffle (needs ≥ 5)" />;136  return <KV items={[["Permutations", s.n], ["Total P&L (fixed)", fmtUsd(s.total_pnl)], ["Max drawdown — median path", fmtUsd(s.max_dd_p50)], ["Max drawdown — 95th pct", fmtUsd(s.max_dd_p95)], ["Max drawdown — worst path", fmtUsd(s.max_dd_worst)]]} />;137}138139export function TradeLedger({ trades, page, setPage, pageSize = 50 }: { trades: Artifacts["trades"]; page: number; setPage: (p: number) => void; pageSize?: number }) {140  if (!trades?.length) return <Empty title="No fills">The strategy never changed exposure enough to trade, or all orders were rejected.</Empty>;141  const pages = Math.ceil(trades.length / pageSize);142  const slice = trades.slice(page * pageSize, (page + 1) * pageSize);143  return (144    <>145      <Table>146        <thead><tr><Th>Time</Th><Th>Side</Th><Th align="right">Qty</Th><Th align="right">Fill</Th><Th align="right">Ref (open)</Th><Th align="right">Slip</Th><Th align="right">Notional</Th><Th align="right">Fee</Th><Th align="right">Target</Th><Th>Note</Th></tr></thead>147        <tbody>{slice.map((t, i) => (148          <tr key={`${t.ts}-${i}`}><Td num>{fmtTs(t.ts)}</Td><Td className={t.side === "buy" ? "text-up" : "text-down"}>{t.side}</Td><Td align="right" num>{fmtNum(t.qty, 6)}</Td><Td align="right" num>{fmtNum(t.price, 2)}</Td><Td align="right" num>{fmtNum(t.ref_price, 2)}</Td><Td align="right" num>{fmtBps(((t.price - t.ref_price) / t.ref_price) * 1e4 * (t.side === "buy" ? 1 : -1), 1)}</Td><Td align="right" num>{fmtUsd(t.notional)}</Td><Td align="right" num>{fmtUsd(t.fee)}</Td><Td align="right" num>{fmtPctPlain(t.strategy_target, 0)}</Td><Td className="text-fg-dim">{t.partial ? "partial" : ""}{t.reason !== "rebalance" ? ` ${t.reason}` : ""}</Td></tr>149        ))}</tbody>150      </Table>151      {pages > 1 && <div className="mt-2 flex items-center justify-end gap-2 text-xs"><button className="rounded border border-line-strong px-2 py-1 disabled:opacity-40" disabled={page === 0} onClick={() => setPage(page - 1)}>Prev</button><span>page {page + 1} / {pages}</span><button className="rounded border border-line-strong px-2 py-1 disabled:opacity-40" disabled={page >= pages - 1} onClick={() => setPage(page + 1)}>Next</button></div>}152    </>153  );154}155156export function Provenance({ run }: { run: Run }) {157  const p = run.provenance ?? {};158  const w = p.window as [number, number] | undefined;159  return (160    <KV items={[161      ["Run id", run.id], ["Code version", String(p.code_version ?? "—")], ["Git commit", String(p.git_commit ?? "not a git checkout")], ["Dataset hash", String(p.dataset_hash ?? "—")],162      ["Bars", String(p.n_bars ?? "—")], ["Window", w ? `${fmtTs(w[0])} → ${fmtTs(w[1])}` : "—"], ["Holdout fraction", String(p.holdout_frac ?? 0)],163      ["Params", JSON.stringify(p.params ?? run.params)], ["Cost model", JSON.stringify(p.cost_model ?? run.cost_model)], ["Risk limits", p.risk_limits ? JSON.stringify(p.risk_limits) : "engine defaults (size-unbounded, no shorting)"],164      ["Seed", String(p.seed ?? "—")], ["Initial cash", fmtUsd(p.initial_cash)], ["Runtime", `${p.runtime_s ?? "—"} s`], ["Data source", String(p.data_source ?? "—")], ["Evaluation", String(p.evaluation_note ?? "—")],165    ]} />166  );167}168169export function RejectedOrders({ rejected }: { rejected: Artifacts["rejected"] }) {170  if (!rejected?.length) return <p className="text-xs text-fg-dim">No orders were rejected by the risk gateway.</p>;171  return (172    <Table>173      <thead><tr><Th>Time</Th><Th>Side</Th><Th align="right">Qty</Th><Th>Reasons</Th></tr></thead>174      <tbody>{rejected.map((r, i) => <tr key={i}><Td num>{fmtTs(r.ts)}</Td><Td>{r.side}</Td><Td align="right" num>{fmtNum(r.qty, 6)}</Td><Td className="text-down">{r.reasons.join("; ")}</Td></tr>)}</tbody>175    </Table>176  );177}178179export { Card };180