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