From 965774dfbdacdf4d5ffa4ccb872d92687d62fe7c Mon Sep 17 00:00:00 2001 From: u1 Date: Sat, 10 Jan 2026 22:56:25 +0000 Subject: [PATCH] feat(dlob): add L2 subscription hook --- .../src/features/market/useDlobL2.ts | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 apps/visualizer/src/features/market/useDlobL2.ts diff --git a/apps/visualizer/src/features/market/useDlobL2.ts b/apps/visualizer/src/features/market/useDlobL2.ts new file mode 100644 index 0000000..436f6bb --- /dev/null +++ b/apps/visualizer/src/features/market/useDlobL2.ts @@ -0,0 +1,158 @@ +import { useEffect, useMemo, useState } from 'react'; +import { subscribeGraphqlWs } from '../../lib/graphqlWs'; + +export type OrderbookRow = { + price: number; + size: number; + total: number; +}; + +export type DlobL2 = { + marketName: string; + bids: OrderbookRow[]; + asks: OrderbookRow[]; + bestBid: number | null; + bestAsk: number | null; + mid: number | null; + updatedAt: string | null; +}; + +function envNumber(name: string): number | undefined { + const raw = (import.meta as any).env?.[name]; + if (raw == null) return undefined; + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; +} + +function toNum(v: unknown): number | null { + if (v == null) return null; + if (typeof v === 'number') return Number.isFinite(v) ? v : null; + if (typeof v === 'string') { + const s = v.trim(); + if (!s) return null; + const n = Number(s); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function normalizeJson(v: unknown): unknown { + if (typeof v !== 'string') return v; + const s = v.trim(); + if (!s) return null; + try { + return JSON.parse(s); + } catch { + return v; + } +} + +function parseLevels(raw: unknown, pricePrecision: number, basePrecision: number): Array<{ price: number; size: number }> { + const v = normalizeJson(raw); + if (!Array.isArray(v)) return []; + + const out: Array<{ price: number; size: number }> = []; + for (const item of v) { + const priceInt = toNum((item as any)?.price); + const sizeInt = toNum((item as any)?.size); + if (priceInt == null || sizeInt == null) continue; + + const price = priceInt / pricePrecision; + const size = sizeInt / basePrecision; + if (!Number.isFinite(price) || !Number.isFinite(size)) continue; + out.push({ price, size }); + } + + return out; +} + +function withTotals(levels: Array<{ price: number; size: number }>): OrderbookRow[] { + let total = 0; + return levels.map((l) => { + total += l.size; + return { ...l, total }; + }); +} + +type HasuraDlobL2Row = { + market_name: string; + bids?: unknown; + asks?: unknown; + updated_at?: string | null; +}; + +type SubscriptionData = { + dlob_l2_latest: HasuraDlobL2Row[]; +}; + +export function useDlobL2( + marketName: string, + opts?: { levels?: number } +): { l2: DlobL2 | null; connected: boolean; error: string | null } { + const [l2, setL2] = useState(null); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); + + const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]); + const levels = useMemo(() => Math.max(1, opts?.levels ?? 14), [opts?.levels]); + + const pricePrecision = useMemo(() => envNumber('VITE_DLOB_PRICE_PRECISION') ?? 1_000_000, []); + const basePrecision = useMemo(() => envNumber('VITE_DLOB_BASE_PRECISION') ?? 1_000_000_000, []); + + useEffect(() => { + if (!normalizedMarket) { + setL2(null); + setError(null); + setConnected(false); + return; + } + + setError(null); + + const query = ` + subscription DlobL2($market: String!) { + dlob_l2_latest(where: {market_name: {_eq: $market}}, limit: 1) { + market_name + bids + asks + updated_at + } + } + `; + + const sub = subscribeGraphqlWs({ + query, + variables: { market: normalizedMarket }, + onStatus: ({ connected }) => setConnected(connected), + onError: (e) => setError(e), + onData: (data) => { + const row = data?.dlob_l2_latest?.[0]; + if (!row?.market_name) return; + + const bidsRaw = parseLevels(row.bids, pricePrecision, basePrecision).slice(0, levels); + const asksRaw = parseLevels(row.asks, pricePrecision, basePrecision).slice(0, levels); + + const bids = withTotals(bidsRaw); + const asks = withTotals(asksRaw).slice().reverse(); + + const bestBid = bidsRaw.length ? bidsRaw[0].price : null; + const bestAsk = asksRaw.length ? asksRaw[0].price : null; + const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null; + + setL2({ + marketName: row.market_name, + bids, + asks, + bestBid, + bestAsk, + mid, + updatedAt: row.updated_at ?? null, + }); + }, + }); + + return () => sub.unsubscribe(); + }, [normalizedMarket, levels, pricePrecision, basePrecision]); + + return { l2, connected, error }; +}