spb/crypto-trade-bot
Public
Python 54.7%
TypeScript 42.5%
SQL 1.9%
1"use client";2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";3import { useState } from "react";4import { Badge, Button, Card, Dialog, Empty, ErrorBox, Field, Input, PageHeader, Select, Spinner, Table, Td, Th } from "@/components/ui";5import { api, ApiError } from "@/lib/api";6import { fmtIso } from "@/lib/format";7import type { AuditItem, RiskLimits, RiskStatus } from "@/lib/types";89const LIMIT_FIELDS: { key: keyof RiskLimits; label: string; hint: string; step?: number }[] = [10 { key: "max_order_notional", label: "Max order notional (USD)", hint: "Quote notional per order", step: 100 },11 { key: "max_position_notional", label: "Max position notional (USD)", hint: "Per-symbol absolute notional", step: 100 },12 { key: "max_gross_exposure", label: "Max gross exposure (×equity)", hint: "Σ|position notional| / equity", step: 0.05 },13 { key: "max_net_exposure", label: "Max net exposure (×equity)", hint: "Net long minus short / equity", step: 0.05 },14 { key: "max_concentration", label: "Max concentration", hint: "Largest |position| / equity", step: 0.05 },15 { key: "daily_loss_limit", label: "Daily loss limit", hint: "Fraction of start-of-day equity", step: 0.005 },16 { key: "max_drawdown_limit", label: "Max drawdown limit", hint: "Fraction from rolling peak equity", step: 0.01 },17 { key: "min_cash", label: "Minimum cash (USD)", hint: "Must remain after a buy", step: 10 },18 { key: "max_open_orders", label: "Max open orders", hint: "Across all symbols", step: 1 },19 { key: "max_participation", label: "Max participation", hint: "Order qty / recent bar volume", step: 0.01 },20 { key: "max_price_deviation", label: "Max price deviation", hint: "|limit − last| / last", step: 0.005 },21 { key: "stale_data_seconds", label: "Stale data cutoff (s)", hint: "Orders are refused when market data is older", step: 5 },22 { key: "max_clock_drift_seconds", label: "Max clock drift (s)", hint: "Local vs exchange time", step: 0.5 },23 { key: "var_limit_95", label: "VaR 95% limit (optional)", hint: "1-bar VaR as fraction of equity; blank = off", step: 0.005 },24];2526export default function RiskPage() {27 const qc = useQueryClient();28 const status = useQuery({ queryKey: ["risk-status"], queryFn: () => api<RiskStatus>("/api/risk/status"), refetchInterval: 10_000 });29 const audit = useQuery({ queryKey: ["audit"], queryFn: () => api<{ items: AuditItem[]; chain_valid: boolean; chain_length: number }>("/api/audit?limit=100"), refetchInterval: 15_000 });30 const [formState, setForm] = useState<RiskLimits | null>(null);31 const form = formState ?? status.data?.limits ?? null;32 const save = useMutation({ mutationFn: (b: RiskLimits) => api<RiskLimits>("/api/risk/limits", { method: "PUT", json: b }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["risk-status"] }); qc.invalidateQueries({ queryKey: ["audit"] }); } });33 const [ksOpen, setKsOpen] = useState(false);34 const [ksReason, setKsReason] = useState("");35 const kill = useMutation({ mutationFn: (active: boolean) => api("/api/risk/kill-switch", { method: "POST", json: { active, reason: ksReason || null } }), onSuccess: () => { setKsOpen(false); setKsReason(""); qc.invalidateQueries({ queryKey: ["risk-status"] }); qc.invalidateQueries({ queryKey: ["audit"] }); } });36 const ksActive = status.data?.kill_switch.active ?? false;3738 // what-if evaluator39 const [wi, setWi] = useState({ symbol: "BTC/USD", side: "buy", qty: 0.01, order_type: "market", limit_price: "", equity: 10000, cash: 10000, position: 0, last_price: 80000, age: 1, recent_vol: "", ordermin: 0.0001, costmin: 0.5 });40 const evalQ = useMutation({41 mutationFn: () => api<{ allowed: boolean; reasons: string[]; checks: Record<string, boolean> }>("/api/risk/evaluate", { method: "POST", json: {42 intent: { symbol: wi.symbol, side: wi.side, qty: Number(wi.qty), order_type: wi.order_type, limit_price: wi.limit_price === "" ? null : Number(wi.limit_price) },43 portfolio: { equity: Number(wi.equity), cash: Number(wi.cash), positions: Number(wi.position) ? { [wi.symbol]: Number(wi.position) } : {}, marks: { [wi.symbol]: Number(wi.last_price) }, open_orders: 0, day_start_equity: Number(wi.equity), peak_equity: Number(wi.equity), reconciled: true },44 market: { last_price: Number(wi.last_price), data_age_seconds: Number(wi.age), recent_bar_volume: wi.recent_vol === "" ? null : Number(wi.recent_vol), ordermin: Number(wi.ordermin), costmin: Number(wi.costmin) },45 } }),46 });4748 return (49 <>50 <PageHeader title="Risk Center" subtitle="Independent gateway every order must pass (backtest, paper and live). Default-deny on stale data, reconciliation failure, kill switch or internal error." actions={status.data && (ksActive ? <Badge tone="down">KILL SWITCH ACTIVE</Badge> : <Badge tone="up">kill switch armed</Badge>)} />51 <div className="grid gap-4 xl:grid-cols-3">52 <Card title="Kill switch" subtitle="Blocks every new order immediately. Phase 4+ also cancels eligible open orders; during an exchange outage cancellations cannot be guaranteed." className="xl:col-span-1">53 {status.isLoading && <Spinner />}54 {status.data && (55 <div className="flex flex-col gap-3 text-xs">56 <div>Status: {ksActive ? <span className="font-semibold text-down">ACTIVE</span> : <span className="text-up">armed (inactive)</span>}</div>57 {status.data.kill_switch.reason && <div className="text-fg-muted">Reason: {status.data.kill_switch.reason}</div>}58 {status.data.kill_switch.since && <div className="text-fg-dim">Since {fmtIso(new Date(status.data.kill_switch.since * 1000).toISOString())}</div>}59 <Button variant={ksActive ? "outline" : "danger"} onClick={() => setKsOpen(true)}>{ksActive ? "Disarm kill switch" : "Activate kill switch"}</Button>60 </div>61 )}62 <Dialog open={ksOpen} onClose={() => setKsOpen(false)} title={ksActive ? "Disarm kill switch?" : "Activate kill switch?"} footer={<><Button onClick={() => setKsOpen(false)}>Cancel</Button><Button variant={ksActive ? "primary" : "danger"} loading={kill.isPending} onClick={() => kill.mutate(!ksActive)}>Confirm</Button></>}>63 <p className="text-xs text-fg-muted">{ksActive ? "New orders will be allowed again, subject to all other limits." : "All new orders from every engine will be rejected until disarmed. This is recorded in the immutable audit log."}</p>64 <Field label="Reason (recorded in audit)"><Input value={ksReason} onChange={(e) => setKsReason(e.target.value)} placeholder="e.g. anomalous prices on BTC/USD" /></Field>65 {kill.error && <ErrorBox error={kill.error} />}66 </Dialog>67 </Card>68 <Card title="Risk limits" subtitle="Applied to paper and live orders; backtests may override per run." className="xl:col-span-2" actions={<Button variant="primary" size="sm" loading={save.isPending} disabled={!form} onClick={() => form && save.mutate(form)}>Save limits</Button>}>69 {form ? (70 <form onSubmit={(e) => { e.preventDefault(); save.mutate(form); }} className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">71 {LIMIT_FIELDS.map((f) => (72 <Field key={f.key} label={f.label} hint={f.hint}>73 <Input type="number" step={f.step} value={form[f.key] ?? ""} onChange={(e) => setForm({ ...form, [f.key]: e.target.value === "" ? (f.key === "var_limit_95" ? null : 0) : Number(e.target.value) })} />74 </Field>75 ))}76 {save.error && <div className="col-span-full"><ErrorBox error={save.error} /></div>}77 {save.isSuccess && <p className="col-span-full text-xs text-up">Limits saved and audited.</p>}78 </form>79 ) : <Spinner />}80 </Card>81 </div>82 <div className="mt-4 grid gap-4 xl:grid-cols-2">83 <Card title="What-if evaluator" subtitle="Dry-run the gateway with a hypothetical order; nothing is placed.">84 <form onSubmit={(e) => { e.preventDefault(); evalQ.mutate(); }} className="grid grid-cols-2 gap-3 md:grid-cols-4">85 <Field label="Symbol"><Input value={wi.symbol} onChange={(e) => setWi({ ...wi, symbol: e.target.value })} /></Field>86 <Field label="Side"><Select value={wi.side} onChange={(e) => setWi({ ...wi, side: e.target.value })}><option>buy</option><option>sell</option></Select></Field>87 <Field label="Qty (base)"><Input type="number" step="any" value={wi.qty} onChange={(e) => setWi({ ...wi, qty: Number(e.target.value) })} /></Field>88 <Field label="Order type"><Select value={wi.order_type} onChange={(e) => setWi({ ...wi, order_type: e.target.value })}><option>market</option><option>limit</option></Select></Field>89 <Field label="Limit price"><Input type="number" step="any" value={wi.limit_price} onChange={(e) => setWi({ ...wi, limit_price: e.target.value })} placeholder="market" /></Field>90 <Field label="Last price"><Input type="number" step="any" value={wi.last_price} onChange={(e) => setWi({ ...wi, last_price: Number(e.target.value) })} /></Field>91 <Field label="Equity"><Input type="number" step="any" value={wi.equity} onChange={(e) => setWi({ ...wi, equity: Number(e.target.value) })} /></Field>92 <Field label="Cash"><Input type="number" step="any" value={wi.cash} onChange={(e) => setWi({ ...wi, cash: Number(e.target.value) })} /></Field>93 <Field label="Existing position"><Input type="number" step="any" value={wi.position} onChange={(e) => setWi({ ...wi, position: Number(e.target.value) })} /></Field>94 <Field label="Data age (s)"><Input type="number" step="any" value={wi.age} onChange={(e) => setWi({ ...wi, age: Number(e.target.value) })} /></Field>95 <Field label="Recent bar volume"><Input type="number" step="any" value={wi.recent_vol} onChange={(e) => setWi({ ...wi, recent_vol: e.target.value })} placeholder="unknown" /></Field>96 <Field label="ordermin / costmin"><div className="flex gap-1"><Input type="number" step="any" value={wi.ordermin} onChange={(e) => setWi({ ...wi, ordermin: Number(e.target.value) })} /><Input type="number" step="any" value={wi.costmin} onChange={(e) => setWi({ ...wi, costmin: Number(e.target.value) })} /></div></Field>97 <div className="col-span-full"><Button type="submit" loading={evalQ.isPending}>Evaluate</Button></div>98 </form>99 {evalQ.error && <ErrorBox error={evalQ.error instanceof ApiError ? evalQ.error : String(evalQ.error)} />}100 {evalQ.data && (101 <div className="mt-3 rounded-md border border-line bg-bg p-3 text-xs">102 <div className="mb-2">{evalQ.data.allowed ? <Badge tone="up">ALLOWED</Badge> : <Badge tone="down">REJECTED</Badge>}{evalQ.data.reasons.length > 0 && <span className="ml-2 text-down">{evalQ.data.reasons.join("; ")}</span>}</div>103 <ul className="grid grid-cols-2 gap-x-4 gap-y-0.5 md:grid-cols-3">{Object.entries(evalQ.data.checks).map(([k, ok]) => <li key={k} className={ok ? "text-fg-muted" : "font-semibold text-down"}>{ok ? "✓" : "✗"} {k}</li>)}</ul>104 </div>105 )}106 </Card>107 <Card title="Recent risk events" subtitle="Limit changes, kill-switch flips and breaches from the audit trail.">108 {status.data?.recent_events.length ? (109 <Table><thead><tr><Th>Time</Th><Th>Action</Th><Th>Details</Th></tr></thead><tbody>{status.data.recent_events.map((e, i) => <tr key={i}><Td num className="text-fg-dim">{fmtIso(e.ts)}</Td><Td>{e.action}</Td><Td className="max-w-md truncate font-mono text-[11px] text-fg-muted" title={JSON.stringify(e.details)}>{JSON.stringify(e.details)}</Td></tr>)}</tbody></Table>110 ) : <Empty title="No risk events yet" />}111 </Card>112 </div>113 <Card title="Audit history" subtitle="Append-only, hash-chained log of every sensitive action." className="mt-4" actions={audit.data && <Badge tone={audit.data.chain_valid ? "up" : "down"}>chain {audit.data.chain_valid ? "valid" : "BROKEN"} · {audit.data.chain_length} entries</Badge>}>114 {audit.isLoading && <Spinner />}115 {audit.data && (audit.data.items.length ? (116 <Table><thead><tr><Th>#</Th><Th>Time</Th><Th>Actor</Th><Th>Action</Th><Th>Entity</Th><Th>Details</Th><Th>Hash</Th></tr></thead><tbody>{audit.data.items.map((a) => <tr key={a.id}><Td num>{a.id}</Td><Td num className="text-fg-dim">{fmtIso(a.ts)}</Td><Td>{a.actor}</Td><Td>{a.action}</Td><Td className="font-mono text-[11px]">{a.entity ?? ""}</Td><Td className="max-w-md truncate font-mono text-[11px] text-fg-muted" title={JSON.stringify(a.details)}>{JSON.stringify(a.details)}</Td><Td className="font-mono text-[10px] text-fg-dim">{a.hash.slice(0, 12)}</Td></tr>)}</tbody></Table>117 ) : <Empty title="Audit log empty" />)}118 </Card>119 </>120 );121}122