spb/crypto-trade-bot
Public
Python 54.7%
TypeScript 42.5%
SQL 1.9%
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