feat(chart): candle build indicator as direction line #1
158
apps/visualizer/src/features/market/useDlobL2.ts
Normal file
158
apps/visualizer/src/features/market/useDlobL2.ts
Normal file
@@ -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<DlobL2 | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<SubscriptionData>({
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user