diff --git a/apps/visualizer/src/features/market/useDlobStats.ts b/apps/visualizer/src/features/market/useDlobStats.ts new file mode 100644 index 0000000..dbce7ff --- /dev/null +++ b/apps/visualizer/src/features/market/useDlobStats.ts @@ -0,0 +1,123 @@ +import { useEffect, useMemo, useState } from 'react'; +import { subscribeGraphqlWs } from '../../lib/graphqlWs'; + +export type DlobStats = { + marketName: string; + markPrice: number | null; + oraclePrice: number | null; + bestBid: number | null; + bestAsk: number | null; + mid: number | null; + spreadAbs: number | null; + spreadBps: number | null; + depthBidBase: number | null; + depthAskBase: number | null; + depthBidUsd: number | null; + depthAskUsd: number | null; + imbalance: number | null; + updatedAt: string | null; +}; + +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; +} + +type HasuraDlobStatsRow = { + market_name: string; + mark_price?: string | null; + oracle_price?: string | null; + best_bid_price?: string | null; + best_ask_price?: string | null; + mid_price?: string | null; + spread_abs?: string | null; + spread_bps?: string | null; + depth_bid_base?: string | null; + depth_ask_base?: string | null; + depth_bid_usd?: string | null; + depth_ask_usd?: string | null; + imbalance?: string | null; + updated_at?: string | null; +}; + +type SubscriptionData = { + dlob_stats_latest: HasuraDlobStatsRow[]; +}; + +export function useDlobStats(marketName: string): { stats: DlobStats | null; connected: boolean; error: string | null } { + const [stats, setStats] = useState(null); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); + + const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]); + + useEffect(() => { + if (!normalizedMarket) { + setStats(null); + setError(null); + setConnected(false); + return; + } + + setError(null); + + const query = ` + subscription DlobStats($market: String!) { + dlob_stats_latest(where: {market_name: {_eq: $market}}, limit: 1) { + market_name + mark_price + oracle_price + best_bid_price + best_ask_price + mid_price + spread_abs + spread_bps + depth_bid_base + depth_ask_base + depth_bid_usd + depth_ask_usd + imbalance + updated_at + } + } + `; + + const sub = subscribeGraphqlWs({ + query, + variables: { market: normalizedMarket }, + onStatus: ({ connected }) => setConnected(connected), + onError: (e) => setError(e), + onData: (data) => { + const row = data?.dlob_stats_latest?.[0]; + if (!row?.market_name) return; + setStats({ + marketName: row.market_name, + markPrice: toNum(row.mark_price), + oraclePrice: toNum(row.oracle_price), + bestBid: toNum(row.best_bid_price), + bestAsk: toNum(row.best_ask_price), + mid: toNum(row.mid_price), + spreadAbs: toNum(row.spread_abs), + spreadBps: toNum(row.spread_bps), + depthBidBase: toNum(row.depth_bid_base), + depthAskBase: toNum(row.depth_ask_base), + depthBidUsd: toNum(row.depth_bid_usd), + depthAskUsd: toNum(row.depth_ask_usd), + imbalance: toNum(row.imbalance), + updatedAt: row.updated_at ?? null, + }); + }, + }); + + return () => sub.unsubscribe(); + }, [normalizedMarket]); + + return { stats, connected, error }; +}