From 3caae2ef8a97e26a3ef66eee0fb6dfc13c052e2a Mon Sep 17 00:00:00 2001 From: Thaddeus Date: Fri, 22 Nov 2024 16:03:11 +0100 Subject: [PATCH] feat: add hyperliquid exchange support --- src/consts.ts | 9 +- src/mappers/hyperliquid.ts | 171 ++++++++++++++++++++++++ src/mappers/index.ts | 10 +- src/realtimefeeds/hyperliquid.ts | 38 ++++++ src/realtimefeeds/index.ts | 4 +- test/__snapshots__/mappers.test.ts.snap | 105 +++++++++++++++ test/mappers.test.ts | 140 ++++++++++++++++++- 7 files changed, 470 insertions(+), 7 deletions(-) create mode 100644 src/mappers/hyperliquid.ts create mode 100644 src/realtimefeeds/hyperliquid.ts diff --git a/src/consts.ts b/src/consts.ts index f87256a..89325e5 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -56,7 +56,8 @@ export const EXCHANGES = [ 'woo-x', 'blockchain-com', 'bitget', - 'bitget-futures' + 'bitget-futures', + 'hyperliquid' ] as const const BINANCE_CHANNELS = ['trade', 'aggTrade', 'ticker', 'depth', 'depthSnapshot', 'bookTicker', 'recentTrades', 'borrowInterest'] as const @@ -478,6 +479,9 @@ const KUCOIN_FUTURES_CHANNELS = [ const BITGET_CHANNELS = ['trade', 'books1', 'books15'] const BITGET_FUTURES_CHANNELS = ['trade', 'books1', 'books15', 'ticker'] const COINBASE_INTERNATIONAL_CHANNELS = ['INSTRUMENTS', 'MATCH', 'FUNDING', 'RISK', 'LEVEL1', 'LEVEL2', 'CANDLES_ONE_MINUTE'] + +const HYPERLIQUID_CHANNELS = ['l2Book', 'trades', 'activeAssetCtx', 'activeSpotAssetCtx'] + export const EXCHANGE_CHANNELS_INFO = { bitmex: BITMEX_CHANNELS, coinbase: COINBASE_CHANNELS, @@ -536,5 +540,6 @@ export const EXCHANGE_CHANNELS_INFO = { 'okex-spreads': OKEX_SPREADS_CHANNELS, 'kucoin-futures': KUCOIN_FUTURES_CHANNELS, bitget: BITGET_CHANNELS, - 'bitget-futures': BITGET_FUTURES_CHANNELS + 'bitget-futures': BITGET_FUTURES_CHANNELS, + hyperliquid: HYPERLIQUID_CHANNELS } diff --git a/src/mappers/hyperliquid.ts b/src/mappers/hyperliquid.ts new file mode 100644 index 0000000..3209f77 --- /dev/null +++ b/src/mappers/hyperliquid.ts @@ -0,0 +1,171 @@ +import { upperCaseSymbols } from '../handy' +import { BookChange, DerivativeTicker, Trade } from '../types' +import { Mapper, PendingTickerInfoHelper } from './mapper' + +export class HyperliquidTradesMapper implements Mapper<'hyperliquid', Trade> { + private readonly _seenSymbols = new Set() + + canHandle(message: HyperliquidTradeMessage) { + return message.channel === 'trades' + } + + getFilters(symbols?: string[]) { + symbols = upperCaseSymbols(symbols) + + return [ + { + channel: 'trades', + symbols + } + ] + } + + *map(message: HyperliquidTradeMessage, localTimestamp: Date): IterableIterator { + for (const hyperliquidTrade of message.data) { + if (this._seenSymbols.has(hyperliquidTrade.coin) === false) { + this._seenSymbols.add(hyperliquidTrade.coin) + break + } + yield { + type: 'trade', + symbol: hyperliquidTrade.coin, + exchange: 'hyperliquid', + id: hyperliquidTrade.tid.toString(), + price: Number(hyperliquidTrade.px), + amount: Number(hyperliquidTrade.sz), + side: hyperliquidTrade.side === 'B' ? 'buy' : 'sell', + timestamp: new Date(hyperliquidTrade.time), + localTimestamp: localTimestamp + } + } + } +} + +function mapHyperliquidLevel(level: HyperliquidWsLevel) { + return { + price: Number(level.px), + amount: Number(level.sz) + } +} +export class HyperliquidBookChangeMapper implements Mapper<'hyperliquid', BookChange> { + canHandle(message: HyperliquidWsBookMessage) { + return message.channel === 'l2Book' + } + + getFilters(symbols?: string[]) { + symbols = upperCaseSymbols(symbols) + + return [ + { + channel: 'l2Book', + symbols + } + ] + } + + *map(message: HyperliquidWsBookMessage, localTimestamp: Date): IterableIterator { + yield { + type: 'book_change', + symbol: message.data.coin, + exchange: 'hyperliquid', + isSnapshot: true, + bids: (message.data.levels[0] ? message.data.levels[0] : []).map(mapHyperliquidLevel), + asks: (message.data.levels[1] ? message.data.levels[1] : []).map(mapHyperliquidLevel), + timestamp: new Date(message.data.time), + localTimestamp + } + } +} + +export class HyperliquidDerivativeTickerMapper implements Mapper<'hyperliquid', DerivativeTicker> { + private readonly pendingTickerInfoHelper = new PendingTickerInfoHelper() + + canHandle(message: HyperliquidContextMessage) { + return message.channel === 'activeAssetCtx' + } + + getFilters(symbols?: string[]) { + symbols = upperCaseSymbols(symbols) + + return [ + { + channel: 'activeAssetCtx', + symbols + } + ] + } + + *map(message: HyperliquidContextMessage, localTimestamp: Date): IterableIterator { + const symbol = message.data.coin + + const pendingTickerInfo = this.pendingTickerInfoHelper.getPendingTickerInfo(symbol, 'hyperliquid') + + if (message.data.ctx.funding !== undefined) { + pendingTickerInfo.updateFundingRate(Number(message.data.ctx.funding)) + } + + if (message.data.ctx.markPx !== undefined) { + pendingTickerInfo.updateMarkPrice(Number(message.data.ctx.markPx)) + } + + if (message.data.ctx.openInterest !== undefined) { + pendingTickerInfo.updateOpenInterest(Number(message.data.ctx.openInterest)) + } + + if (message.data.ctx.oraclePx !== undefined) { + pendingTickerInfo.updateIndexPrice(Number(message.data.ctx.oraclePx)) + } + + if (pendingTickerInfo.hasChanged()) { + yield pendingTickerInfo.getSnapshot(localTimestamp) + } + } +} + +type HyperliquidTradeMessage = { + channel: 'trades' + data: [ + { + coin: string + side: string + px: string + sz: string + hash: string + time: number + tid: number // ID unique across all assets + } + ] +} + +type HyperliquidWsBookMessage = { + channel: 'l2Book' + data: { + coin: 'ATOM' + time: 1730160007687 + levels: [HyperliquidWsLevel[], HyperliquidWsLevel[]] + } +} + +type HyperliquidWsLevel = { + px: string // price + sz: string // size + n: number // number of orders +} + +type HyperliquidContextMessage = { + channel: 'activeAssetCtx' + data: { + coin: 'RENDER' + ctx: { + funding: '0.0000125' + openInterest: '231067.2' + prevDayPx: '4.8744' + dayNtlVlm: '387891.57092' + premium: '0.0' + oraclePx: '4.9185' + markPx: '4.919' + midPx: '4.9183' + impactPxs: ['4.9176', '4.9191'] + } + } +} diff --git a/src/mappers/index.ts b/src/mappers/index.ts index ad95c94..7483f6c 100644 --- a/src/mappers/index.ts +++ b/src/mappers/index.ts @@ -100,6 +100,7 @@ import { HuobiBookTickerMapper, HuobiTradesMapper } from './huobi' +import { HyperliquidBookChangeMapper, HyperliquidDerivativeTickerMapper, HyperliquidTradesMapper } from './hyperliquid' import { krakenBookChangeMapper, krakenBookTickerMapper, krakenTradesMapper } from './kraken' import { KucoinBookChangeMapper, KucoinBookTickerMapper, KucoinTradesMapper } from './kucoin' import { @@ -281,7 +282,8 @@ const tradesMappers = { 'okex-spreads': () => new OkexSpreadsTradesMapper(), bitget: () => new BitgetTradesMapper('bitget'), 'bitget-futures': () => new BitgetTradesMapper('bitget-futures'), - 'coinbase-international': () => coinbaseInternationalTradesMapper + 'coinbase-international': () => coinbaseInternationalTradesMapper, + hyperliquid: () => new HyperliquidTradesMapper() } const bookChangeMappers = { @@ -372,7 +374,8 @@ const bookChangeMappers = { 'okex-spreads': () => new OkexSpreadsBookChangeMapper(), bitget: () => new BitgetBookChangeMapper('bitget'), 'bitget-futures': () => new BitgetBookChangeMapper('bitget-futures'), - 'coinbase-international': () => new CoinbaseInternationalBookChangMapper() + 'coinbase-international': () => new CoinbaseInternationalBookChangMapper(), + hyperliquid: () => new HyperliquidBookChangeMapper() } const derivativeTickersMappers = { @@ -408,7 +411,8 @@ const derivativeTickersMappers = { 'woo-x': () => new WooxDerivativeTickerMapper(), 'kucoin-futures': () => new KucoinFuturesDerivativeTickerMapper(), 'bitget-futures': () => new BitgetDerivativeTickerMapper(), - 'coinbase-international': () => new CoinbaseInternationalDerivativeTickerMapper() + 'coinbase-international': () => new CoinbaseInternationalDerivativeTickerMapper(), + hyperliquid: () => new HyperliquidDerivativeTickerMapper() } const optionsSummaryMappers = { diff --git a/src/realtimefeeds/hyperliquid.ts b/src/realtimefeeds/hyperliquid.ts new file mode 100644 index 0000000..940f7a4 --- /dev/null +++ b/src/realtimefeeds/hyperliquid.ts @@ -0,0 +1,38 @@ +import { Filter } from '../types' +import { RealTimeFeedBase } from './realtimefeed' + +export class HyperliquidRealTimeFeed extends RealTimeFeedBase { + protected wssURL = 'wss://api.hyperliquid.xyz/ws' + + protected mapToSubscribeMessages(filters: Filter[]): any[] { + return filters + .map((filter) => { + if (!filter.symbols || filter.symbols.length === 0) { + throw new Error('HyperliquidRealTimeFeed requires explicitly specified symbols when subscribing to live feed') + } + + return filter.symbols.map((symbol) => { + return { + method: 'subscribe', + subscription: { + coin: symbol, + type: filter.channel + } + } + }) + }) + .flatMap((f) => f) + } + + protected messageIsError(message: any): boolean { + return message.channel === 'error' + } + + protected messageIsHeartbeat(message: any): boolean { + return message.channel === 'pong' + } + + protected sendCustomPing = () => { + this.send({ method: 'ping' }) + } +} diff --git a/src/realtimefeeds/index.ts b/src/realtimefeeds/index.ts index 7e51c56..2f06b9d 100644 --- a/src/realtimefeeds/index.ts +++ b/src/realtimefeeds/index.ts @@ -52,6 +52,7 @@ import { KucoinFuturesRealTimeFeed } from './kucoinfutures' import { DydxV4RealTimeFeed } from './dydx_v4' import { BitgetFuturesRealTimeFeed, BitgetRealTimeFeed } from './bitget' import { CoinbaseInternationalRealTimeFeed } from './coinbaseinternational' +import { HyperliquidRealTimeFeed } from './hyperliquid' export * from './realtimefeed' @@ -115,7 +116,8 @@ const realTimeFeedsMap: { 'dydx-v4': DydxV4RealTimeFeed, bitget: BitgetRealTimeFeed, 'bitget-futures': BitgetFuturesRealTimeFeed, - 'coinbase-international': CoinbaseInternationalRealTimeFeed + 'coinbase-international': CoinbaseInternationalRealTimeFeed, + hyperliquid: HyperliquidRealTimeFeed } export function getRealTimeFeedFactory(exchange: Exchange): RealTimeFeed { diff --git a/test/__snapshots__/mappers.test.ts.snap b/test/__snapshots__/mappers.test.ts.snap index b398b90..4786899 100644 --- a/test/__snapshots__/mappers.test.ts.snap +++ b/test/__snapshots__/mappers.test.ts.snap @@ -1428,6 +1428,111 @@ Array [ ] `; +exports[`map hyperliquid messages 1`] = `Array []`; + +exports[`map hyperliquid messages 2`] = ` +Array [ + Object { + "amount": 9.1, + "exchange": "hyperliquid", + "id": "809065877559592", + "localTimestamp": 2024-08-23T00:00:00.498Z, + "price": 1.9014, + "side": "sell", + "symbol": "FXS", + "timestamp": 2024-10-29T00:00:59.558Z, + "type": "trade", + }, +] +`; + +exports[`map hyperliquid messages 3`] = `Array []`; + +exports[`map hyperliquid messages 4`] = ` +Array [ + Object { + "amount": 0.00714, + "exchange": "hyperliquid", + "id": "696560709859024", + "localTimestamp": 2024-08-23T00:00:00.498Z, + "price": 69998, + "side": "buy", + "symbol": "BTC", + "timestamp": 2024-10-29T00:00:07.572Z, + "type": "trade", + }, +] +`; + +exports[`map hyperliquid messages 5`] = ` +Array [ + Object { + "asks": Array [], + "bids": Array [], + "exchange": "hyperliquid", + "isSnapshot": true, + "localTimestamp": 2024-08-23T00:00:00.498Z, + "symbol": "SHIA", + "timestamp": 2024-10-29T00:00:07.687Z, + "type": "book_change", + }, +] +`; + +exports[`map hyperliquid messages 6`] = ` +Array [ + Object { + "asks": Array [ + Object { + "amount": 2.9, + "price": 59.811, + }, + Object { + "amount": 17.6, + "price": 59.832, + }, + ], + "bids": Array [ + Object { + "amount": 3.4, + "price": 59.8, + }, + Object { + "amount": 1.6, + "price": 59.778, + }, + ], + "exchange": "hyperliquid", + "isSnapshot": true, + "localTimestamp": 2024-08-23T00:00:00.498Z, + "symbol": "BANANA", + "timestamp": 2024-10-29T00:00:07.687Z, + "type": "book_change", + }, +] +`; + +exports[`map hyperliquid messages 7`] = ` +Array [ + Object { + "exchange": "hyperliquid", + "fundingRate": 0.00004863, + "fundingTimestamp": undefined, + "indexPrice": 0.1995, + "lastPrice": undefined, + "localTimestamp": 2024-08-23T00:00:00.498Z, + "markPrice": 0.19963, + "openInterest": 18617460, + "predictedFundingRate": undefined, + "symbol": "MOODENG", + "timestamp": 2024-08-23T00:00:00.498Z, + "type": "derivative_ticker", + }, +] +`; + +exports[`map hyperliquid messages 8`] = `Array []`; + exports[`map kucoin messages 1`] = `Array []`; exports[`map kucoin messages 2`] = `Array []`; diff --git a/test/mappers.test.ts b/test/mappers.test.ts index 37b2412..f558fae 100644 --- a/test/mappers.test.ts +++ b/test/mappers.test.ts @@ -35,7 +35,8 @@ const exchangesWithDerivativeInfo: Exchange[] = [ 'woo-x', 'kucoin-futures', 'bitget-futures', - 'coinbase-international' + 'coinbase-international', + 'hyperliquid' ] const exchangesWithBookTickerInfo: Exchange[] = [ @@ -9422,3 +9423,140 @@ test('map coinbase-international messages', () => { expect(mappedMessages).toMatchSnapshot() } }) + +test('map hyperliquid messages', () => { + const messages = [ + { + channel: 'trades', + data: [ + { + coin: 'FXS', + side: 'A', + px: '1.9008', + sz: '10.5', + time: 1730160015113, + hash: '0xdde3d6b67214c97cb88704156eee8e016e010eb52dcf2a855bdaea517a5b45a9', + tid: 611279506717453 + } + ] + }, + { + channel: 'trades', + data: [ + { + coin: 'FXS', + side: 'A', + px: '1.9014', + sz: '9.1', + time: 1730160059558, + hash: '0xebc3cc3fd23dac2eb64204156ef14a01e2005ddadaba6efe617eef218f1accc0', + tid: 809065877559592 + } + ] + }, + { + channel: 'trades', + data: [ + { + coin: 'BTC', + side: 'B', + px: '69997.0', + sz: '0.38111', + time: 1730160006192, + hash: '0x40d169aa50ad8a32b44a04156eee0201d8002e5c6456b7c1ae175e381f73f9dd', + tid: 1016375172059570 + }, + { + coin: 'BTC', + side: 'B', + px: '69997.0', + sz: '0.33458', + time: 1730160006192, + hash: '0x40d169aa50ad8a32b44a04156eee0201d8002e5c6456b7c1ae175e381f73f9dd', + tid: 479312521152049 + }, + { + coin: 'BTC', + side: 'B', + px: '69997.0', + sz: '0.83613', + time: 1730160006192, + hash: '0x40d169aa50ad8a32b44a04156eee0201d8002e5c6456b7c1ae175e381f73f9dd', + tid: 229986151112628 + } + ] + }, + { + channel: 'trades', + data: [ + { + coin: 'BTC', + side: 'B', + px: '69998.0', + sz: '0.00714', + time: 1730160007572, + hash: '0xb2628bf07a30ffa6267904156eee1801610085114d2758aec6a6784d14463055', + tid: 696560709859024 + } + ] + }, + { channel: 'l2Book', data: { coin: 'SHIA', time: 1730160007687, levels: [[], []] } }, + + { + channel: 'l2Book', + data: { + coin: 'BANANA', + time: 1730160007687, + levels: [ + [ + { px: '59.8', sz: '3.4', n: 1 }, + { px: '59.778', sz: '1.6', n: 1 } + ], + [ + { px: '59.811', sz: '2.9', n: 1 }, + { px: '59.832', sz: '17.6', n: 1 } + ] + ] + } + }, + + { + channel: 'activeAssetCtx', + data: { + coin: 'MOODENG', + ctx: { + funding: '0.00004863', + openInterest: '18617460.0', + prevDayPx: '0.20296', + dayNtlVlm: '7665220.55613', + premium: '0.00008521', + oraclePx: '0.1995', + markPx: '0.19963', + midPx: '0.19957', + impactPxs: ['0.199517', '0.199725'] + } + } + }, + { + channel: 'activeSpotAssetCtx', + data: { + coin: '@2', + ctx: { + prevDayPx: '0.000031', + dayNtlVlm: '24629.86429498', + markPx: '0.00005299', + midPx: '0.00005307', + circulatingSupply: '6879553815.3455801', + coin: '@2' + } + } + } + ] + + const mapper = createMapper('hyperliquid', new Date('2024-08-23T00:00:00.4985250Z')) + + for (const message of messages) { + const mappedMessages = mapper.map(message, new Date('2024-08-23T00:00:00.4985250Z')) + expect(mappedMessages).toMatchSnapshot() + } +})