From 8e1cc50e90e9b133c39ef35cac820c4fe6cec2f6 Mon Sep 17 00:00:00 2001 From: shunab Date: Fri, 26 Jun 2026 14:23:52 +0100 Subject: [PATCH] feat(hyperliquid): add spot market support --- config.json.example | 4 +- src/exchanges/hyperliquid.js | 117 +++++++++++++++++++++++++---------- src/services/catalog.js | 55 +++++++++++++++- 3 files changed, 139 insertions(+), 37 deletions(-) diff --git a/config.json.example b/config.json.example index 6147a97..5c92d5e 100644 --- a/config.json.example +++ b/config.json.example @@ -36,7 +36,9 @@ "KRAKEN:ETH/USD", "HUOBI:ethusdt", "HUOBI:ETH-USD", - "BINANCE_FUTURES:ethusd_perp" + "BINANCE_FUTURES:ethusd_perp", + "HYPERLIQUID:BTC", + "HYPERLIQUID:@142" ], "backupInterval": 5000, "collect": true, diff --git a/src/exchanges/hyperliquid.js b/src/exchanges/hyperliquid.js index 5ef3d8c..0a86ed7 100644 --- a/src/exchanges/hyperliquid.js +++ b/src/exchanges/hyperliquid.js @@ -1,7 +1,34 @@ const Exchange = require('../exchange') -const { readProducts, saveProducts } = require('../services/catalog') +const { + readProducts, + saveProducts, + setHyperliquidSpotPairLabels +} = require('../services/catalog') const axios = require('axios') +function buildSpotPairLabels(spotMeta) { + const labels = {} + + if (!spotMeta?.tokens?.length || !spotMeta?.universe?.length) { + return labels + } + + for (const product of spotMeta.universe) { + if (product.isDelisted) { + continue + } + + const base = spotMeta.tokens[product.tokens[0]] + const quote = spotMeta.tokens[product.tokens[1]] + + if (base && quote) { + labels[product.name] = `${base.name}/${quote.name}` + } + } + + return labels +} + class Hyperliquid extends Exchange { constructor() { super() @@ -16,11 +43,9 @@ class Hyperliquid extends Exchange { return 'wss://api.hyperliquid.xyz/ws' } - // Custom getProducts implementation for POST API async getProducts(forceRefreshProducts = false) { let formatedProducts - // Load from cache if not forcing refresh if (!forceRefreshProducts) { try { formatedProducts = await readProducts(this.id) @@ -29,27 +54,39 @@ class Hyperliquid extends Exchange { } } - // Fetch new products if no cache available - if (!formatedProducts) { + if (formatedProducts) { + if (formatedProducts.spotPairLabels) { + setHyperliquidSpotPairLabels(formatedProducts.spotPairLabels) + } + } else { try { console.log(`[${this.id}] fetching products via POST API`) - const response = await axios.post( - this.endpoints.PRODUCTS, - { type: 'meta' }, - { - headers: { 'Content-Type': 'application/json' } - } - ) - - formatedProducts = this.formatProducts(response.data) || [] + const [perpResponse, spotResponse] = await Promise.all([ + axios.post( + this.endpoints.PRODUCTS, + { type: 'meta' }, + { headers: { 'Content-Type': 'application/json' } } + ), + axios.post( + this.endpoints.PRODUCTS, + { type: 'spotMeta' }, + { headers: { 'Content-Type': 'application/json' } } + ) + ]) + + formatedProducts = this.formatProducts([ + perpResponse.data, + spotResponse.data + ]) + + if (formatedProducts?.spotPairLabels) { + setHyperliquidSpotPairLabels(formatedProducts.spotPairLabels) + } - // Save to cache await saveProducts(this.id, formatedProducts) console.log( - `[${this.id}] saved ${ - formatedProducts.products?.length || 0 - } products` + `[${this.id}] saved ${formatedProducts.products?.length || 0} products` ) } catch (error) { console.error( @@ -60,7 +97,6 @@ class Hyperliquid extends Exchange { } } - // Set products to instance if (formatedProducts && formatedProducts.products) { this.products = formatedProducts.products } @@ -68,24 +104,34 @@ class Hyperliquid extends Exchange { return this.products } - formatProducts(response) { + formatProducts(responses) { const products = [] + const perpResponse = Array.isArray(responses) ? responses[0] : responses + const spotResponse = Array.isArray(responses) ? responses[1] : null + let spotPairLabels = {} + + if (perpResponse?.universe?.length) { + for (const product of perpResponse.universe) { + if (!product.isDelisted) { + products.push(product.name) + } + } + } - if (response && response.universe && response.universe.length) { - for (const product of response.universe) { - products.push(product.name) + if (spotResponse?.universe?.length) { + spotPairLabels = buildSpotPairLabels(spotResponse) + + for (const product of spotResponse.universe) { + if (!product.isDelisted) { + products.push(product.name) + } } } console.log(`[${this.id}] formatted ${products.length} products`) - return { products } + return { products, spotPairLabels } } - /** - * Sub - * @param {WebSocket} api - * @param {string} pair - */ async subscribe(api, pair) { if (!(await super.subscribe.apply(this, arguments))) { return @@ -104,11 +150,6 @@ class Hyperliquid extends Exchange { return true } - /** - * Sub - * @param {WebSocket} api - * @param {string} pair - */ async unsubscribe(api, pair) { if (!(await super.unsubscribe.apply(this, arguments))) { return @@ -148,6 +189,14 @@ class Hyperliquid extends Exchange { side: trade.side === 'B' ? 'buy' : 'sell' } } + + onApiCreated(api) { + this.startKeepAlive(api, { method: 'ping' }, 20000) + } + + onApiRemoved(api) { + this.stopKeepAlive(api) + } } module.exports = Hyperliquid diff --git a/src/services/catalog.js b/src/services/catalog.js index c30f44d..35a6f05 100644 --- a/src/services/catalog.js +++ b/src/services/catalog.js @@ -50,6 +50,18 @@ const standardCurrencyPairLookup = new RegExp( require('../typedef') +const HYPERLIQUID_SPOT_REGEX = /^@|\// + +let hyperliquidSpotPairLabels = {} + +module.exports.setHyperliquidSpotPairLabels = function (labels) { + hyperliquidSpotPairLabels = labels || {} +} + +module.exports.getHyperliquidSpotPairLabels = function () { + return hyperliquidSpotPairLabels +} + module.exports.saveProducts = async function (exchangeId, data) { const path = 'products/' + exchangeId + '.json' const storage = { @@ -99,6 +111,10 @@ module.exports.readProducts = async function (exchangeId) { console.debug(`[${exchangeId}] using stored products`) + if (data.spotPairLabels) { + module.exports.setHyperliquidSpotPairLabels(data.spotPairLabels) + } + resolve(data) } catch (error) { reject(error) @@ -182,10 +198,11 @@ module.exports.parseMarket = function (exchangeId, symbol, noStable = true) { type = 'future' } else if ( exchangeId === 'BINANCE_FUTURES' || - exchangeId === 'DYDX' || - exchangeId === 'HYPERLIQUID' + exchangeId === 'DYDX' ) { type = 'perp' + } else if (exchangeId === 'HYPERLIQUID') { + type = HYPERLIQUID_SPOT_REGEX.test(symbol) ? 'spot' : 'perp' } else if (exchangeId === 'BITFINEX' && /F0$/.test(symbol)) { type = 'perp' } else if (exchangeId === 'HUOBI' && /_(CW|CQ|NW|NQ)$/.test(symbol)) { @@ -238,6 +255,10 @@ module.exports.parseMarket = function (exchangeId, symbol, noStable = true) { localSymbol = localSymbol.replace(/_(\w+)-PERPETUAL/i, '$1') } else if (exchangeId === 'KUCOIN') { localSymbol = localSymbol.replace(/M$/, '') + } else if (exchangeId === 'HYPERLIQUID' && type === 'spot') { + if (hyperliquidSpotPairLabels[symbol]) { + localSymbol = hyperliquidSpotPairLabels[symbol] + } } else if (exchangeId === 'HYPERLIQUID') { localSymbol = localSymbol.replace(/^k/, '') + 'USD' } else if (exchangeId === 'PHEMEX') { @@ -273,6 +294,36 @@ module.exports.parseMarket = function (exchangeId, symbol, noStable = true) { } } } + if (!match && exchangeId === 'HYPERLIQUID' && type === 'spot') { + const label = hyperliquidSpotPairLabels[symbol] + + if (label && label.includes('/')) { + const [base, quote] = label.split('/') + + return { + id, + base, + quote, + pair: symbol, + local: noStable ? stripStablePair(base + quote) : base + quote, + exchange: exchangeId, + type + } + } + + if (HYPERLIQUID_SPOT_REGEX.test(symbol)) { + return { + id, + base: symbol, + quote: 'USDC', + pair: symbol, + local: symbol, + exchange: exchangeId, + type + } + } + } + if (!match) { return null }