diff --git a/src/consts.ts b/src/consts.ts index 6954f7a..9b75cf8 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -1,5 +1,6 @@ export const EXCHANGES = [ 'aster', + 'aster-futures', 'bitmex', 'deribit', 'binance-futures', @@ -67,6 +68,7 @@ export const EXCHANGES = [ const BINANCE_CHANNELS = ['trade', 'aggTrade', 'ticker', 'depth', 'depthSnapshot', 'bookTicker', 'recentTrades', 'borrowInterest'] as const const ASTER_CHANNELS = ['trade', 'ticker', 'depth', 'depthSnapshot', 'bookTicker'] as const +const ASTER_FUTURES_CHANNELS = ['aggTrade', 'ticker', 'depth', 'depthSnapshot', 'bookTicker', 'markPrice', 'forceOrder'] as const const BINANCE_DEX_CHANNELS = ['trades', 'marketDiff', 'depthSnapshot', 'ticker'] as const const BITFINEX_CHANNELS = ['trades', 'book', 'raw_book', 'ticker'] as const @@ -561,6 +563,7 @@ const POLYMARKET_CHANNELS = [ export const EXCHANGE_CHANNELS_INFO = { aster: ASTER_CHANNELS, + 'aster-futures': ASTER_FUTURES_CHANNELS, bitmex: BITMEX_CHANNELS, coinbase: COINBASE_CHANNELS, 'coinbase-international': COINBASE_INTERNATIONAL_CHANNELS, diff --git a/src/mappers/asterfutures.ts b/src/mappers/asterfutures.ts new file mode 100644 index 0000000..dc94950 --- /dev/null +++ b/src/mappers/asterfutures.ts @@ -0,0 +1,376 @@ +import { debug } from '../debug.ts' +import { asNumberOrUndefined, CircularBuffer, lowerCaseSymbols } from '../handy.ts' +import { BookChange, BookTicker, DerivativeTicker, Liquidation, Trade } from '../types.ts' +import { Mapper, PendingTickerInfoHelper } from './mapper.ts' +import { exchangeMappers, isRealTime } from './registry.ts' + +export const asterFuturesMappers = exchangeMappers({ + 'aster-futures': { + trades: () => new AsterFuturesTradesMapper(), + bookChanges: (localTimestamp) => + new AsterFuturesBookChangeMapper({ + ignoreBookSnapshotOverlapError: shouldIgnoreBookSnapshotOverlap(localTimestamp) + }), + derivativeTickers: () => new AsterFuturesDerivativeTickerMapper(), + liquidations: () => new AsterFuturesLiquidationsMapper(), + bookTickers: () => new AsterFuturesBookTickerMapper() + } +}) + +function shouldIgnoreBookSnapshotOverlap(date?: Date) { + if (process.env.IGNORE_BOOK_SNAPSHOT_OVERLAP_ERROR) { + return true + } + + return isRealTime(date) === false +} + +class AsterFuturesTradesMapper implements Mapper<'aster-futures', Trade> { + canHandle(message: AsterFuturesMessage) { + return message.stream?.endsWith('@aggTrade') === true + } + + getFilters(symbols?: string[]) { + return [{ channel: 'aggTrade', symbols: lowerCaseSymbols(symbols) } as const] + } + + *map({ data }: AsterFuturesMessage, localTimestamp: Date) { + const trade: Trade = { + type: 'trade', + symbol: data.s, + exchange: 'aster-futures', + id: String(data.a), + price: Number(data.p), + amount: Number(data.q), + side: data.m ? 'sell' : 'buy', + timestamp: new Date(data.T), + localTimestamp + } + + yield trade + } +} + +class AsterFuturesBookChangeMapper implements Mapper<'aster-futures', BookChange> { + private readonly symbolToDepthInfoMapping: { [key: string]: LocalDepthInfo } = {} + private readonly ignoreBookSnapshotOverlapError: boolean + + constructor({ ignoreBookSnapshotOverlapError }: { ignoreBookSnapshotOverlapError: boolean }) { + this.ignoreBookSnapshotOverlapError = ignoreBookSnapshotOverlapError + } + + canHandle(message: AsterFuturesMessage) { + return message.stream?.includes('@depth') === true + } + + getFilters(symbols?: string[]) { + return [ + { channel: 'depth', symbols: lowerCaseSymbols(symbols) } as const, + { channel: 'depthSnapshot', symbols: lowerCaseSymbols(symbols) } as const + ] + } + + *map({ stream, data }: AsterFuturesMessage, localTimestamp: Date) { + const symbol = stream.split('@')[0].toUpperCase() + + if (this.symbolToDepthInfoMapping[symbol] === undefined) { + this.symbolToDepthInfoMapping[symbol] = { + bufferedUpdates: new CircularBuffer(2000) + } + } + + const symbolDepthInfo = this.symbolToDepthInfoMapping[symbol] + if (data.lastUpdateId !== undefined) { + if (symbolDepthInfo.snapshotProcessed) { + return + } + + symbolDepthInfo.lastUpdateId = data.lastUpdateId + symbolDepthInfo.snapshotProcessed = true + + for (const update of symbolDepthInfo.bufferedUpdates.items()) { + const bookChange = this.mapBookDepthUpdate(update, localTimestamp) + if (bookChange !== undefined) { + for (const bid of update.b) { + const matchingBid = data.bids.find((b) => b[0] === bid[0]) + if (matchingBid !== undefined) { + matchingBid[1] = bid[1] + } else { + data.bids.push(bid) + } + } + + for (const ask of update.a) { + const matchingAsk = data.asks.find((a) => a[0] === ask[0]) + if (matchingAsk !== undefined) { + matchingAsk[1] = ask[1] + } else { + data.asks.push(ask) + } + } + } + } + + symbolDepthInfo.bufferedUpdates.clear() + + const bookChange: BookChange = { + type: 'book_change', + symbol, + exchange: 'aster-futures', + isSnapshot: true, + bids: data.bids.map(this.mapBookLevel), + asks: data.asks.map(this.mapBookLevel), + timestamp: data.T !== undefined ? new Date(data.T) : localTimestamp, + localTimestamp + } + + yield bookChange + } else if (symbolDepthInfo.snapshotProcessed) { + const bookChange = this.mapBookDepthUpdate(data as AsterFuturesDepthData, localTimestamp) + if (bookChange !== undefined) { + yield bookChange + } + } else { + symbolDepthInfo.bufferedUpdates.append(data as AsterFuturesDepthData) + } + } + + private mapBookDepthUpdate(depthUpdateData: AsterFuturesDepthData, localTimestamp: Date): BookChange | undefined { + const depthContext = this.symbolToDepthInfoMapping[depthUpdateData.s]! + + if (depthUpdateData.u < depthContext.lastUpdateId!) { + return + } + + if (!depthContext.validatedFirstUpdate) { + if (depthUpdateData.U <= depthContext.lastUpdateId! && depthUpdateData.u >= depthContext.lastUpdateId!) { + depthContext.validatedFirstUpdate = true + } else if (this.ignoreBookSnapshotOverlapError) { + depthContext.validatedFirstUpdate = true + debug( + `Book depth snaphot has no overlap with first update, update ${JSON.stringify( + depthUpdateData + )}, lastUpdateId: ${depthContext.lastUpdateId!}, exchange aster-futures` + ) + } else { + throw new Error( + `Book depth snaphot has no overlap with first update, update ${JSON.stringify( + depthUpdateData + )}, lastUpdateId: ${depthContext.lastUpdateId!}, exchange aster-futures` + ) + } + } else if (depthUpdateData.pu !== depthContext.lastUpdateId!) { + throw new Error( + `Book depth update has a sequence gap, update ${JSON.stringify( + depthUpdateData + )}, lastUpdateId: ${depthContext.lastUpdateId!}, exchange aster-futures` + ) + } + + depthContext.lastUpdateId = depthUpdateData.u + + return { + type: 'book_change', + symbol: depthUpdateData.s, + exchange: 'aster-futures', + isSnapshot: false, + bids: depthUpdateData.b.map(this.mapBookLevel), + asks: depthUpdateData.a.map(this.mapBookLevel), + timestamp: new Date(depthUpdateData.E), + localTimestamp + } + } + + private mapBookLevel(level: AsterFuturesBookLevel) { + return { + price: Number(level[0]), + amount: Number(level[1]) + } + } +} + +class AsterFuturesDerivativeTickerMapper implements Mapper<'aster-futures', DerivativeTicker> { + private readonly pendingTickerInfoHelper = new PendingTickerInfoHelper() + + canHandle(message: AsterFuturesMessage) { + return message.stream?.includes('@markPrice') === true || message.stream?.endsWith('@ticker') === true + } + + getFilters(symbols?: string[]) { + return [ + { channel: 'markPrice', symbols: lowerCaseSymbols(symbols) } as const, + { channel: 'ticker', symbols: lowerCaseSymbols(symbols) } as const + ] + } + + *map( + message: AsterFuturesMessage, + localTimestamp: Date + ): IterableIterator { + const pendingTickerInfo = this.pendingTickerInfoHelper.getPendingTickerInfo(message.data.s, 'aster-futures') + + if (message.data.e === 'markPriceUpdate') { + pendingTickerInfo.updateMarkPrice(Number(message.data.p)) + if (message.data.i !== undefined) { + pendingTickerInfo.updateIndexPrice(Number(message.data.i)) + } + if (message.data.r !== '' && message.data.T !== 0) { + pendingTickerInfo.updateFundingRate(Number(message.data.r)) + pendingTickerInfo.updateFundingTimestamp(new Date(message.data.T)) + } + pendingTickerInfo.updateTimestamp(new Date(message.data.E)) + } + + if (message.data.e === '24hrTicker') { + pendingTickerInfo.updateLastPrice(Number(message.data.c)) + pendingTickerInfo.updateTimestamp(new Date(message.data.E)) + } + + if (pendingTickerInfo.hasChanged()) { + yield pendingTickerInfo.getSnapshot(localTimestamp) + } + } +} + +class AsterFuturesLiquidationsMapper implements Mapper<'aster-futures', Liquidation> { + canHandle(message: AsterFuturesMessage) { + return message.stream?.endsWith('@forceOrder') === true + } + + getFilters(symbols?: string[]) { + return [{ channel: 'forceOrder', symbols: lowerCaseSymbols(symbols) } as const] + } + + *map({ data }: AsterFuturesMessage, localTimestamp: Date) { + const order = data.o + if (order.X !== 'FILLED') { + return + } + + const liquidation: Liquidation = { + type: 'liquidation', + symbol: order.s, + exchange: 'aster-futures', + id: undefined, + price: Number(order.p), + amount: Number(order.z), + side: order.S === 'SELL' ? 'sell' : 'buy', + timestamp: new Date(order.T), + localTimestamp + } + + yield liquidation + } +} + +class AsterFuturesBookTickerMapper implements Mapper<'aster-futures', BookTicker> { + canHandle(message: AsterFuturesMessage) { + return message.stream?.endsWith('@bookTicker') === true + } + + getFilters(symbols?: string[]) { + return [{ channel: 'bookTicker', symbols: lowerCaseSymbols(symbols) } as const] + } + + *map({ data }: AsterFuturesMessage, localTimestamp: Date) { + const ticker: BookTicker = { + type: 'book_ticker', + symbol: data.s, + exchange: 'aster-futures', + askAmount: asNumberOrUndefined(data.A), + askPrice: asNumberOrUndefined(data.a), + bidPrice: asNumberOrUndefined(data.b), + bidAmount: asNumberOrUndefined(data.B), + timestamp: data.E !== undefined ? new Date(data.E) : localTimestamp, + localTimestamp + } + + yield ticker + } +} + +type AsterFuturesMessage = { + stream: string + data: T +} + +type AsterFuturesAggTradeData = { + e: 'aggTrade' + E: number + s: string + a: number + p: string + q: string + f: number + l: number + T: number + m: boolean +} + +type AsterFuturesDepthData = { + lastUpdateId: undefined + E: number + T: number + s: string + U: number + u: number + pu: number + b: AsterFuturesBookLevel[] + a: AsterFuturesBookLevel[] +} + +type AsterFuturesDepthSnapshotData = { + lastUpdateId: number + bids: AsterFuturesBookLevel[] + asks: AsterFuturesBookLevel[] + T?: number +} + +type AsterFuturesBookLevel = [string, string] + +type LocalDepthInfo = { + bufferedUpdates: CircularBuffer + snapshotProcessed?: boolean + lastUpdateId?: number + validatedFirstUpdate?: boolean +} + +type AsterFuturesTickerData = { + e: '24hrTicker' + E: number + s: string + c: string +} + +type AsterFuturesMarkPriceData = { + e: 'markPriceUpdate' + E: number + s: string + p: string + i?: string + r: string + T: number +} + +type AsterFuturesForceOrderData = { + e: 'forceOrder' + E: number + o: { + s: string + S: 'BUY' | 'SELL' + p: string + X: string + z: string + T: number + } +} + +type AsterFuturesBookTickerData = { + u: number + s: string + b: string + B: string + a: string + A: string + E?: number +} diff --git a/src/mappers/index.ts b/src/mappers/index.ts index 1f7e4e6..d89343a 100644 --- a/src/mappers/index.ts +++ b/src/mappers/index.ts @@ -1,5 +1,6 @@ import { BookChange, BookTicker, DerivativeTicker, Exchange, Liquidation, NormalizedData, OptionSummary, Trade } from '../types.ts' import { ascendexMappers } from './ascendex.ts' +import { asterFuturesMappers } from './asterfutures.ts' import { asterMappers } from './aster.ts' import { binanceMappers } from './binance.ts' import { binanceDexMappers } from './binancedex.ts' @@ -56,6 +57,7 @@ type Normalizer = (() private readonly bufferedDepthUpdates = new Map>() protected readonly wssURL: string = 'wss://sstream.asterdex.com/stream' protected readonly httpURL: string = 'https://sapi.asterdex.com/api/v3' - private readonly channels = new Set([ + protected readonly channels = new Set([ 'trade', 'ticker', AsterRealTimeFeed.depthChannel, AsterRealTimeFeed.depthSnapshotChannel, 'bookTicker' ]) - private readonly channelMappings: { [key: string]: string | undefined } = { + protected readonly channelMappings: { [key: string]: string | undefined } = { [AsterRealTimeFeed.depthChannel]: AsterRealTimeFeed.depthStream } @@ -75,6 +75,7 @@ export class AsterRealTimeFeed extends RealTimeFeedBase { return } + const firstUpdateId = Number(message.data.U) const lastUpdateId = Number(message.data.u) const previousFinalUpdateId = Number(message.data.pu) if (Number.isFinite(lastUpdateId) === false || Number.isFinite(previousFinalUpdateId) === false) { @@ -83,6 +84,7 @@ export class AsterRealTimeFeed extends RealTimeFeedBase { const bufferedUpdates = this.bufferedDepthUpdates.get(symbol) ?? new CircularBuffer(2000) bufferedUpdates.append({ + firstUpdateId: Number.isFinite(firstUpdateId) ? firstUpdateId : undefined, lastUpdateId, previousFinalUpdateId }) @@ -264,6 +266,37 @@ type AsterDepthSnapshotMessage = { } type AsterDepthUpdateData = { + firstUpdateId?: number lastUpdateId: number previousFinalUpdateId: number } + +export class AsterFuturesRealTimeFeed extends AsterRealTimeFeed { + protected readonly wssURL: string = 'wss://fstream.asterdex.com/stream' + protected readonly httpURL: string = 'https://fapi.asterdex.com/fapi/v1' + protected readonly channels = new Set([ + 'aggTrade', + 'ticker', + AsterRealTimeFeed.depthChannel, + AsterRealTimeFeed.depthSnapshotChannel, + 'markPrice', + 'forceOrder', + 'bookTicker' + ]) + protected readonly channelMappings: { [key: string]: string | undefined } = { + [AsterRealTimeFeed.depthChannel]: AsterRealTimeFeed.depthStream, + markPrice: 'markPrice@1s' + } + + protected override validateSnapshotOverlap(bufferedUpdates: CircularBuffer | undefined, lastUpdateId: number) { + for (const update of bufferedUpdates?.items() ?? []) { + if (update.lastUpdateId < lastUpdateId) { + continue + } + + return update.firstUpdateId !== undefined && update.firstUpdateId <= lastUpdateId && update.lastUpdateId >= lastUpdateId + } + + return undefined + } +} diff --git a/src/realtimefeeds/index.ts b/src/realtimefeeds/index.ts index 1f48dca..d6772eb 100644 --- a/src/realtimefeeds/index.ts +++ b/src/realtimefeeds/index.ts @@ -1,5 +1,5 @@ import { Exchange, Filter } from '../types.ts' -import { AsterRealTimeFeed } from './aster.ts' +import { AsterFuturesRealTimeFeed, AsterRealTimeFeed } from './aster.ts' import { BinanceFuturesRealTimeFeed, BinanceJerseyRealTimeFeed, @@ -64,6 +64,7 @@ const realTimeFeedsMap: { [key in Exchange]?: RealTimeFeed } = { aster: AsterRealTimeFeed, + 'aster-futures': AsterFuturesRealTimeFeed, bitmex: BitmexRealTimeFeed, binance: BinanceRealTimeFeed, 'binance-jersey': BinanceJerseyRealTimeFeed, diff --git a/test/aster-realtimefeed.test.ts b/test/aster-realtimefeed.test.ts index c93ad8c..86797df 100644 --- a/test/aster-realtimefeed.test.ts +++ b/test/aster-realtimefeed.test.ts @@ -1,6 +1,6 @@ import type { AddressInfo } from 'net' import { createServer } from 'http' -import { AsterRealTimeFeed } from '../src/realtimefeeds/aster.ts' +import { AsterFuturesRealTimeFeed, AsterRealTimeFeed } from '../src/realtimefeeds/aster.ts' import { getRealTimeFeedFactory } from '../src/realtimefeeds/index.ts' import { Filter } from '../src/types.ts' @@ -31,8 +31,36 @@ class TestAsterRealTimeFeed extends AsterRealTimeFeed { } } +class TestAsterFuturesRealTimeFeed extends AsterFuturesRealTimeFeed { + protected readonly httpURL: string + + constructor( + exchange: 'aster-futures', + filters: Filter[], + timeoutIntervalMS: number | undefined, + httpURL = 'https://fapi.asterdex.com/fapi/v1' + ) { + super(exchange, filters, timeoutIntervalMS) + this.httpURL = httpURL + } + + map(filters: Filter[]) { + return this.mapToSubscribeMessages(filters) + } + + observe(message: any) { + this.onMessage(message) + } + + async provideSnapshots(filters: Filter[], shouldCancel = () => false) { + await this.provideManualSnapshots(filters, shouldCancel) + return this.manualSnapshotsBuffer + } +} + test('register aster realtime feeds', () => { expect(getRealTimeFeedFactory('aster')).toBeDefined() + expect(getRealTimeFeedFactory('aster-futures')).toBeDefined() }) test('map aster realtime subscriptions', () => { @@ -106,6 +134,47 @@ test('aster realtime rejects unsupported channels', () => { ).toThrow('AsterRealTimeFeed unsupported channel unsupported') }) +test('map aster futures realtime subscriptions', () => { + const feed = new TestAsterFuturesRealTimeFeed('aster-futures', [], undefined) + + expect( + feed.map([ + { + channel: 'depth', + symbols: ['btcusdt'] + }, + { + channel: 'depthSnapshot', + symbols: ['btcusdt'] + }, + { + channel: 'markPrice', + symbols: ['btcusdt'] + }, + { + channel: 'forceOrder', + symbols: ['btcusdt'] + } + ]) + ).toEqual([ + { + method: 'SUBSCRIBE', + params: ['btcusdt@depth@100ms'], + id: 1 + }, + { + method: 'SUBSCRIBE', + params: ['btcusdt@markPrice@1s'], + id: 2 + }, + { + method: 'SUBSCRIBE', + params: ['btcusdt@forceOrder'], + id: 3 + } + ]) +}) + test('provide aster manual depth snapshots after buffered update overlaps', async () => { const server = await startSnapshotServer([{ lastUpdateId: 100, asks: [['100.1', '1.2']], bids: [['99.9', '0.5']] }]) const feed = new TestAsterRealTimeFeed('aster', [], undefined, server.url) @@ -184,12 +253,55 @@ test('retry aster manual depth snapshots until buffered update overlaps', async } }) +test('retry aster futures manual depth snapshots until first update overlaps', async () => { + const server = await startSnapshotServer([ + { lastUpdateId: 104, asks: [['100.1', '1.2']], bids: [['99.9', '0.5']] }, + { lastUpdateId: 105, asks: [['100.2', '1.2']], bids: [['99.8', '0.5']] } + ]) + const feed = new TestAsterFuturesRealTimeFeed('aster-futures', [], undefined, server.url) + + try { + const filters = [ + { + channel: 'depth', + symbols: ['BTCUSDT'] + }, + { + channel: 'depthSnapshot', + symbols: ['BTCUSDT'] + } + ] + + feed.map(filters) + feed.observe(createDepthUpdate({ symbol: 'BTCUSDT', firstUpdateId: 105, lastUpdateId: 105, previousFinalUpdateId: 106 })) + + const snapshots = await feed.provideSnapshots(filters) + + expect(server.requestsCount).toBe(2) + expect(snapshots).toEqual([ + { + stream: 'btcusdt@depthSnapshot', + generated: true, + data: { + lastUpdateId: 105, + asks: [['100.2', '1.2']], + bids: [['99.8', '0.5']] + } + } + ]) + } finally { + await server.close() + } +}) + function createDepthUpdate({ symbol, + firstUpdateId, lastUpdateId, previousFinalUpdateId }: { symbol: string + firstUpdateId?: number lastUpdateId: number previousFinalUpdateId: number }) { @@ -200,7 +312,7 @@ function createDepthUpdate({ E: 1785230774524, T: 1785230774522, s: symbol, - U: previousFinalUpdateId + 1, + U: firstUpdateId ?? previousFinalUpdateId + 1, u: lastUpdateId, pu: previousFinalUpdateId, b: [], diff --git a/test/mappers.test.ts b/test/mappers.test.ts index c9bf0fb..ce22fa9 100644 --- a/test/mappers.test.ts +++ b/test/mappers.test.ts @@ -10,6 +10,7 @@ import { } from '../dist/index.js' const exchangesWithDerivativeInfo: Exchange[] = [ + 'aster-futures', 'bitmex', 'binance-futures', 'bitfinex-derivatives', @@ -43,6 +44,7 @@ const exchangesWithDerivativeInfo: Exchange[] = [ const exchangesWithBookTickerInfo: Exchange[] = [ 'aster', + 'aster-futures', 'ascendex', 'binance', 'binance-futures', @@ -100,6 +102,7 @@ const exchangesWithOptionsSummary: Exchange[] = [ ] const exchangesWithLiquidationsSupport: Exchange[] = [ + 'aster-futures', 'ftx', 'bitmex', 'deribit', @@ -2803,6 +2806,298 @@ describe('mappers', () => { } ]) }) + + test('map aster futures messages', () => { + const asterFuturesMapper = createMapper('aster-futures', new Date()) + const localTimestamp = new Date('2026-08-03T10:00:00.000Z') + + expect( + asterFuturesMapper.map( + { + stream: 'btcusdt@aggTrade', + data: { + e: 'aggTrade', + E: 1568693103463, + s: 'BTCUSDT', + a: 181349, + p: '10223.74', + q: '0.236', + f: 181349, + l: 181349, + T: 1568693103463, + m: false + } + }, + localTimestamp + ) + ).toEqual([ + { + type: 'trade', + symbol: 'BTCUSDT', + exchange: 'aster-futures', + id: '181349', + price: 10223.74, + amount: 0.236, + side: 'buy', + timestamp: new Date('2019-09-17T04:05:03.463Z'), + localTimestamp + } + ]) + + expect( + asterFuturesMapper.map( + { + stream: 'btcusdt@depth@100ms', + data: { + e: 'depthUpdate', + E: 1573948821952, + T: 1573948821948, + s: 'BTCUSDT', + U: 687687944, + u: 687687946, + pu: 687687943, + b: [['8493.78', '0.162']], + a: [['4096.00000000', '2.42541900']] + } + }, + localTimestamp + ) + ).toEqual([]) + + expect( + asterFuturesMapper.map( + { + stream: 'btcusdt@depthSnapshot', + generated: true, + data: { + lastUpdateId: 687687945, + bids: [['8488.36', '1.501']], + asks: [['4096.00000000', '1.42541900']] + } + }, + localTimestamp + ) + ).toEqual([ + { + type: 'book_change', + symbol: 'BTCUSDT', + exchange: 'aster-futures', + isSnapshot: true, + bids: [ + { price: 8488.36, amount: 1.501 }, + { price: 8493.78, amount: 0.162 } + ], + asks: [{ price: 4096, amount: 2.425419 }], + timestamp: localTimestamp, + localTimestamp + } + ]) + + const asterFuturesOverlapEdgeMapper = createMapper('aster-futures', new Date()) + expect( + asterFuturesOverlapEdgeMapper.map( + { + stream: 'ethusdt@depthSnapshot', + generated: true, + data: { + lastUpdateId: 200, + bids: [['10', '1']], + asks: [['11', '2']] + } + }, + localTimestamp + ) + ).toHaveLength(1) + expect( + asterFuturesOverlapEdgeMapper.map( + { + stream: 'ethusdt@depth@100ms', + data: { + e: 'depthUpdate', + E: 1573948821952, + T: 1573948821948, + s: 'ETHUSDT', + U: 199, + u: 200, + pu: 198, + b: [['10', '3']], + a: [] + } + }, + localTimestamp + ) + ).toEqual([ + { + type: 'book_change', + symbol: 'ETHUSDT', + exchange: 'aster-futures', + isSnapshot: false, + bids: [{ price: 10, amount: 3 }], + asks: [], + timestamp: new Date('2019-11-17T00:00:21.952Z'), + localTimestamp + } + ]) + + expect( + asterFuturesMapper.map( + { + stream: 'btcusdt@depth@100ms', + data: { + e: 'depthUpdate', + E: 1573948822952, + T: 1573948822948, + s: 'BTCUSDT', + U: 687687947, + u: 687687948, + pu: 687687946, + b: [['8493.78', '0']], + a: [] + } + }, + localTimestamp + ) + ).toEqual([ + { + type: 'book_change', + symbol: 'BTCUSDT', + exchange: 'aster-futures', + isSnapshot: false, + bids: [{ price: 8493.78, amount: 0 }], + asks: [], + timestamp: new Date('2019-11-17T00:00:22.952Z'), + localTimestamp + } + ]) + + expect( + asterFuturesMapper.map( + { + stream: 'btcusdt@markPrice@1s', + data: { + e: 'markPriceUpdate', + E: 1597536008000, + s: 'BTCUSDT', + p: '11857.56000000', + i: '11851.86949091', + r: '0.00015640', + T: 1597564800000 + } + }, + localTimestamp + ) + ).toEqual([ + { + type: 'derivative_ticker', + symbol: 'BTCUSDT', + exchange: 'aster-futures', + lastPrice: undefined, + openInterest: undefined, + fundingRate: 0.0001564, + fundingTimestamp: new Date('2020-08-16T08:00:00.000Z'), + predictedFundingRate: undefined, + indexPrice: 11851.86949091, + markPrice: 11857.56, + timestamp: new Date('2020-08-16T00:00:08.000Z'), + localTimestamp + } + ]) + + expect( + asterFuturesMapper.map( + { + stream: 'btcusdt@ticker', + data: { e: '24hrTicker', E: 1568693103467, s: 'BTCUSDT', c: '10223.74' } + }, + localTimestamp + ) + ).toEqual([ + { + type: 'derivative_ticker', + symbol: 'BTCUSDT', + exchange: 'aster-futures', + lastPrice: 10223.74, + openInterest: undefined, + fundingRate: 0.0001564, + fundingTimestamp: new Date('2020-08-16T08:00:00.000Z'), + predictedFundingRate: undefined, + indexPrice: 11851.86949091, + markPrice: 11857.56, + timestamp: new Date('2020-08-16T00:00:08.000Z'), + localTimestamp + } + ]) + + expect( + asterFuturesMapper.map( + { + stream: 'btcusdt@forceOrder', + data: { + e: 'forceOrder', + E: 1584059031426, + o: { + s: 'BTCUSDT', + S: 'BUY', + o: 'LIMIT', + f: 'IOC', + q: '0.014', + p: '4793.91', + ap: '4706.04', + X: 'FILLED', + l: '0.015', + z: '0.014', + T: 1584059031421 + } + } + }, + localTimestamp + ) + ).toEqual([ + { + type: 'liquidation', + symbol: 'BTCUSDT', + exchange: 'aster-futures', + id: undefined, + price: 4793.91, + amount: 0.014, + side: 'buy', + timestamp: new Date('2020-03-13T00:23:51.421Z'), + localTimestamp + } + ]) + + expect( + asterFuturesMapper.map( + { + stream: 'btcusdt@bookTicker', + data: { + e: 'bookTicker', + u: 185130926750, + s: 'BTCUSDT', + b: '33134.42', + B: '0.170', + a: '33139.39', + A: '0.380', + E: 1612137603571 + } + }, + localTimestamp + ) + ).toEqual([ + { + type: 'book_ticker', + symbol: 'BTCUSDT', + exchange: 'aster-futures', + askAmount: 0.38, + askPrice: 33139.39, + bidPrice: 33134.42, + bidAmount: 0.17, + timestamp: new Date('2021-02-01T00:00:03.571Z'), + localTimestamp + } + ]) + }) + test('map bitfinex derivatives book ticker messages with trailing null placeholder', () => { const bitfinexDerivativesMapper = createMapper('bitfinex-derivatives') const mappedMessages = bitfinexDerivativesMapper.map(