Skip to content

Commit dbfd462

Browse files
bokikoclaude
andcommitted
fix: Hyperliquid array parser, Aevo wrong channel, add ping keepalive
- Hyperliquid trades channel sends data as array, not single object. Parser now iterates the array so liquidations are actually captured. - Aevo subscription changed from orderbook:BTC-PERP to trades:BTC-PERP. Orderbook channel never carries trade/liquidation data. - Added configurable ping keepalive (30s) for Hyperliquid to prevent silent connection drops during quiet markets. - Fixed ID collision risk by adding random suffix to Hyperliquid IDs. - Parse errors now logged via console.debug instead of silently swallowed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 11e98c8 commit dbfd462

1 file changed

Lines changed: 58 additions & 25 deletions

File tree

src/hooks/useMultiExchangeWebSocket.ts

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ const RECONNECT_MAX_DELAY = 60000;
99
const MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB guard against oversized payloads
1010
// Close codes that indicate we should not reconnect
1111
const NO_RECONNECT_CODES = new Set([1008, 1011, 4000, 4001, 4003]);
12+
const PING_INTERVAL = 30000; // 30s keepalive for exchanges that need it
1213

1314
interface WebSocketConfig {
1415
exchange: Exchange;
1516
url: string;
1617
subscribe?: object;
18+
ping?: object; // Keepalive message to send periodically
1719
parse: (data: unknown, threshold: number) => Liquidation[];
1820
}
1921

@@ -142,46 +144,53 @@ const EXCHANGES: WebSocketConfig[] = [
142144
method: 'subscribe',
143145
subscription: { type: 'trades', coin: 'BTC' },
144146
},
147+
ping: { method: 'ping' },
145148
parse: (data: unknown, threshold: number) => {
146149
const msg = data as {
147150
channel?: string;
148-
data?: {
151+
data?: Array<{
149152
coin?: string;
150153
side?: string;
151154
px?: string;
152155
sz?: string;
153156
time?: number;
157+
tid?: number;
154158
liquidation?: boolean;
155159
startPosition?: boolean;
156160
dir?: string;
157161
closedPnl?: string;
158-
};
162+
}>;
159163
};
160164

161-
// Check if it's a fill with liquidation
162-
if (!msg.data || !msg.data.liquidation) return [];
163-
if (!msg.data.coin?.toUpperCase().includes('BTC')) return [];
165+
// Hyperliquid trades channel sends data as an array
166+
if (!Array.isArray(msg.data)) return [];
164167

165-
const quantity = parseFloat(msg.data.sz || '0');
166-
const price = parseFloat(msg.data.px || '0');
167-
if (!isFinite(quantity) || !isFinite(price)) return [];
168-
const valueUsd = quantity * price;
168+
const results: Liquidation[] = [];
169+
for (const trade of msg.data) {
170+
if (!trade.liquidation) continue;
171+
if (!trade.coin?.toUpperCase().includes('BTC')) continue;
169172

170-
if (valueUsd < threshold) return [];
173+
const quantity = parseFloat(trade.sz || '0');
174+
const price = parseFloat(trade.px || '0');
175+
if (!isFinite(quantity) || !isFinite(price)) continue;
176+
const valueUsd = quantity * price;
171177

172-
// Determine side based on direction
173-
const isLong = msg.data.side === 'A' || msg.data.dir?.includes('Long');
178+
if (valueUsd < threshold) continue;
174179

175-
return [{
176-
id: `hl-${msg.data.time}-${msg.data.side}`,
177-
exchange: 'Hyperliquid',
178-
symbol: msg.data.coin || 'BTC',
179-
side: isLong ? 'Long' : 'Short',
180-
quantity,
181-
price,
182-
valueUsd,
183-
timestamp: new Date(msg.data.time || Date.now()),
184-
}];
180+
const isLong = trade.side === 'A' || trade.dir?.includes('Long');
181+
182+
results.push({
183+
id: `hl-${trade.tid ?? trade.time}-${trade.side}-${Math.random().toString(36).slice(2, 6)}`,
184+
exchange: 'Hyperliquid',
185+
symbol: trade.coin || 'BTC',
186+
side: isLong ? 'Long' : 'Short',
187+
quantity,
188+
price,
189+
valueUsd,
190+
timestamp: new Date(trade.time || Date.now()),
191+
});
192+
}
193+
return results;
185194
},
186195
},
187196
// Aevo - Subscribe to trades and filter liquidations
@@ -190,7 +199,7 @@ const EXCHANGES: WebSocketConfig[] = [
190199
url: 'wss://ws.aevo.xyz',
191200
subscribe: {
192201
op: 'subscribe',
193-
data: ['orderbook:BTC-PERP'],
202+
data: ['trades:BTC-PERP'],
194203
},
195204
parse: (data: unknown, threshold: number) => {
196205
const msg = data as {
@@ -247,6 +256,7 @@ export function useMultiExchangeWebSocket(threshold: number = 10000) {
247256
const wsRefs = useRef<Map<Exchange, WebSocket>>(new Map());
248257
const reconnectTimeouts = useRef<Map<Exchange, NodeJS.Timeout>>(new Map());
249258
const reconnectAttempts = useRef<Map<Exchange, number>>(new Map());
259+
const pingIntervals = useRef<Map<Exchange, NodeJS.Timeout>>(new Map());
250260
const thresholdRef = useRef(threshold);
251261

252262
// Keep threshold ref updated
@@ -276,6 +286,19 @@ export function useMultiExchangeWebSocket(threshold: number = 10000) {
276286
if (config.subscribe) {
277287
ws.send(JSON.stringify(config.subscribe));
278288
}
289+
290+
// Start keepalive ping if configured
291+
if (config.ping) {
292+
const existingPing = pingIntervals.current.get(config.exchange);
293+
if (existingPing) clearInterval(existingPing);
294+
295+
const interval = setInterval(() => {
296+
if (ws.readyState === WebSocket.OPEN) {
297+
ws.send(JSON.stringify(config.ping));
298+
}
299+
}, PING_INTERVAL);
300+
pingIntervals.current.set(config.exchange, interval);
301+
}
279302
};
280303

281304
ws.onmessage = (event) => {
@@ -292,8 +315,8 @@ export function useMultiExchangeWebSocket(threshold: number = 10000) {
292315
return [...deduped, ...prev].slice(0, MAX_LIQUIDATIONS);
293316
});
294317
}
295-
} catch {
296-
// Silent parse errors
318+
} catch (e) {
319+
console.debug(`[${config.exchange}] parse error:`, e);
297320
}
298321
};
299322

@@ -304,6 +327,13 @@ export function useMultiExchangeWebSocket(threshold: number = 10000) {
304327
ws.onclose = (event) => {
305328
updateConnection(config.exchange, { isConnected: false });
306329

330+
// Clear ping interval on close
331+
const pingInterval = pingIntervals.current.get(config.exchange);
332+
if (pingInterval) {
333+
clearInterval(pingInterval);
334+
pingIntervals.current.delete(config.exchange);
335+
}
336+
307337
if (NO_RECONNECT_CODES.has(event.code)) {
308338
console.log(`${config.exchange} closed with code ${event.code}, not reconnecting`);
309339
updateConnection(config.exchange, { error: `Rejected (code ${event.code})` });
@@ -340,6 +370,9 @@ export function useMultiExchangeWebSocket(threshold: number = 10000) {
340370
reconnectTimeouts.current.clear();
341371
reconnectAttempts.current.clear();
342372

373+
pingIntervals.current.forEach((interval) => clearInterval(interval));
374+
pingIntervals.current.clear();
375+
343376
wsRefs.current.forEach((ws) => ws.close());
344377
wsRefs.current.clear();
345378
}, []);

0 commit comments

Comments
 (0)