UNPKG

tardis-dev

Version:

Convenient access to tick-level historical and real-time cryptocurrency market data via Node.js

337 lines (295 loc) 10.7 kB
import { debug } from '../debug' import { CircularBuffer, upperCaseSymbols } from '../handy' import { BookChange, Exchange, BookTicker, Trade, BookPriceLevel } from '../types' import { Mapper } from './mapper' export class KucoinTradesMapper implements Mapper<'kucoin', Trade> { constructor(private readonly _exchange: Exchange) {} canHandle(message: KucoinTradeMessage) { return message.type === 'message' && message.topic.startsWith('/market/match') } getFilters(symbols?: string[]) { symbols = upperCaseSymbols(symbols) return [ { channel: 'market/match', symbols } as const ] } *map(message: KucoinTradeMessage, localTimestamp: Date): IterableIterator<Trade> { const kucoinTrade = message.data const timestamp = new Date(Number(kucoinTrade.time.slice(0, 13))) timestamp.μs = Number(kucoinTrade.time.slice(13, 16)) yield { type: 'trade', symbol: kucoinTrade.symbol, exchange: this._exchange, id: kucoinTrade.tradeId, price: Number(kucoinTrade.price), amount: Number(kucoinTrade.size), side: kucoinTrade.side === 'sell' ? 'sell' : 'buy', timestamp, localTimestamp } } } export class KucoinBookChangeMapper implements Mapper<'kucoin', BookChange> { protected readonly symbolToDepthInfoMapping: { [key: string]: LocalDepthInfo } = {} constructor(protected readonly _exchange: Exchange, private readonly ignoreBookSnapshotOverlapError: boolean) {} canHandle(message: KucoinLevel2SnapshotMessage | KucoinLevel2UpdateMessage) { return message.type === 'message' && message.topic.startsWith('/market/level2') } getFilters(symbols?: string[]) { symbols = upperCaseSymbols(symbols) return [ { channel: 'market/level2', symbols } as const, { channel: 'market/level2Snapshot', symbols } as const ] } *map(message: KucoinLevel2SnapshotMessage | KucoinLevel2UpdateMessage, localTimestamp: Date) { const symbol = message.topic.split(':')[1] if (this.symbolToDepthInfoMapping[symbol] === undefined) { this.symbolToDepthInfoMapping[symbol] = { bufferedUpdates: new CircularBuffer<KucoinLevel2UpdateMessage>(2000) } } const symbolDepthInfo = this.symbolToDepthInfoMapping[symbol] const snapshotAlreadyProcessed = symbolDepthInfo.snapshotProcessed // first check if received message is snapshot and process it as such if it is if (message.subject === 'trade.l2Snapshot') { // if we've already received 'manual' snapshot, ignore if there is another one if (snapshotAlreadyProcessed) { return } // produce snapshot book_change const kucoinSnapshotData = message.data if (!kucoinSnapshotData.asks) { kucoinSnapshotData.asks = [] } if (!kucoinSnapshotData.bids) { kucoinSnapshotData.bids = [] } // mark given symbol depth info that has snapshot processed symbolDepthInfo.lastUpdateId = Number(kucoinSnapshotData.sequence) symbolDepthInfo.snapshotProcessed = true // if there were any depth updates buffered, let's proccess those by adding to or updating the initial snapshot for (const update of symbolDepthInfo.bufferedUpdates.items()) { const bookChange = this.mapBookDepthUpdate(update, localTimestamp) if (bookChange !== undefined) { for (const bid of update.data.changes.bids) { if (bid[0] == '0') { continue } const matchingBid = kucoinSnapshotData.bids.find((b) => b[0] === bid[0]) if (matchingBid !== undefined) { matchingBid[1] = bid[1] } else { kucoinSnapshotData.bids.push([bid[0], bid[1]]) } } for (const ask of update.data.changes.asks) { if (ask[0] == '0') { continue } const matchingAsk = kucoinSnapshotData.asks.find((a) => a[0] === ask[0]) if (matchingAsk !== undefined) { matchingAsk[1] = ask[1] } else { kucoinSnapshotData.asks.push([ask[0], ask[1]]) } } } } // remove all buffered updates symbolDepthInfo.bufferedUpdates.clear() const bookChange: BookChange = { type: 'book_change', symbol, exchange: this._exchange, isSnapshot: true, bids: kucoinSnapshotData.bids.map(this.mapBookLevel), asks: kucoinSnapshotData.asks.map(this.mapBookLevel), timestamp: localTimestamp, localTimestamp } yield bookChange } else if (snapshotAlreadyProcessed) { // snapshot was already processed let's map the message as normal book_change const bookChange = this.mapBookDepthUpdate(message, localTimestamp) if (bookChange !== undefined) { yield bookChange } } else { symbolDepthInfo.bufferedUpdates.append(message) } } protected mapBookDepthUpdate(l2UpdateMessage: KucoinLevel2UpdateMessage, localTimestamp: Date): BookChange | undefined { // we can safely assume here that depthContext and lastUpdateId aren't null here as this is method only works // when we've already processed the snapshot const depthContext = this.symbolToDepthInfoMapping[l2UpdateMessage.data.symbol]! const lastUpdateId = depthContext.lastUpdateId! // Drop any event where sequenceEnd is <= lastUpdateId in the snapshot if (l2UpdateMessage.data.sequenceEnd <= lastUpdateId) { return } // The first processed event should have sequenceStart <= lastUpdateId+1 AND sequenceEnd >= lastUpdateId+1. if (!depthContext.validatedFirstUpdate) { // if there is new instrument added it can have empty book at first and that's normal const bookSnapshotIsEmpty = lastUpdateId == -1 || lastUpdateId == 0 if ( (l2UpdateMessage.data.sequenceStart <= lastUpdateId + 1 && l2UpdateMessage.data.sequenceEnd >= lastUpdateId + 1) || bookSnapshotIsEmpty ) { depthContext.validatedFirstUpdate = true } else { const message = `Book depth snapshot has no overlap with first update, update ${JSON.stringify( l2UpdateMessage )}, lastUpdateId: ${lastUpdateId}, exchange ${this._exchange}` if (this.ignoreBookSnapshotOverlapError) { depthContext.validatedFirstUpdate = true debug(message) } else { throw new Error(message) } } } const bids = l2UpdateMessage.data.changes.bids.map(this.mapBookLevel).filter(this.nonZeroLevels) const asks = l2UpdateMessage.data.changes.asks.map(this.mapBookLevel).filter(this.nonZeroLevels) if (bids.length === 0 && asks.length === 0) { return } const timestamp = l2UpdateMessage.data.time !== undefined ? new Date(l2UpdateMessage.data.time) : localTimestamp return { type: 'book_change', symbol: l2UpdateMessage.data.symbol, exchange: this._exchange, isSnapshot: false, bids, asks, timestamp: timestamp, localTimestamp: localTimestamp } } private mapBookLevel(level: [string, string, string?]) { const price = Number(level[0]) const amount = Number(level[1]) return { price, amount } } private nonZeroLevels(level: BookPriceLevel) { return level.price > 0 } } export class KucoinBookTickerMapper implements Mapper<'kucoin', BookTicker> { constructor(protected readonly _exchange: Exchange) {} canHandle(message: KucoinTickerMessage) { return message.type === 'message' && message.topic.startsWith('/market/ticker') } getFilters(symbols?: string[]) { symbols = upperCaseSymbols(symbols) return [ { channel: 'market/ticker', symbols } as const ] } *map(message: KucoinTickerMessage, localTimestamp: Date) { const symbol = message.topic.split(':')[1] const bookTicker: BookTicker = { type: 'book_ticker', symbol, exchange: this._exchange, askAmount: message.data.bestAskSize !== undefined && message.data.bestAskSize !== null ? Number(message.data.bestAskSize) : undefined, askPrice: message.data.bestAsk !== undefined && message.data.bestAsk !== null ? Number(message.data.bestAsk) : undefined, bidPrice: message.data.bestBid !== undefined && message.data.bestBid !== null ? Number(message.data.bestBid) : undefined, bidAmount: message.data.bestBidSize !== undefined && message.data.bestBidSize !== null ? Number(message.data.bestBidSize) : undefined, timestamp: new Date(message.data.time), localTimestamp: localTimestamp } yield bookTicker } } type KucoinTickerMessage = { type: 'message' topic: '/market/ticker:ADA-USDT' subject: 'trade.ticker' data: { bestAsk: '0.549931' bestAskSize: '966.4756' bestBid: '0.549824' bestBidSize: '1050' price: '0.549825' sequence: '1623526404099' size: '1' time: 1660608019871 } } type KucoinTradeMessage = { type: 'message' topic: '/market/match:BTC-USDT' subject: 'trade.l3match' data: { symbol: 'BTC-USDT' side: 'sell' type: 'match' makerOrderId: '62fadde41add68000167fb58' sequence: '1636276321894' size: '0.00001255' price: '24093.9' takerOrderId: '62faddfff0476c0001c86c71' time: '1660608000026914990' tradeId: '62fade002e113d292303a18b' } } type LocalDepthInfo = { bufferedUpdates: CircularBuffer<KucoinLevel2UpdateMessage> snapshotProcessed?: boolean lastUpdateId?: number validatedFirstUpdate?: boolean } type KucoinLevel2SnapshotMessage = { type: 'message' generated: true topic: '/market/level2Snapshot:BTC-USDT' subject: 'trade.l2Snapshot' code: '200000' data: { time: 1660608003710 sequence: '1636276324355' bids: [string, string][] | null asks: [string, string][] | null } } type KucoinLevel2UpdateMessage = | { type: 'message' topic: '/market/level2:BTC-USDT' subject: 'trade.l2update' data: { sequenceStart: 1636276324710 symbol: 'BTC-USDT' changes: { asks: [string, string, string][]; bids: [string, string, string][] } sequenceEnd: 1636276324710 time: undefined } } | { type: 'message' topic: '/market/level2:BTC-USDT' subject: 'trade.l2update' data: { changes: { asks: []; bids: [['27309.8', '0.35127929', '8005280396']] } sequenceEnd: 8005280396 sequenceStart: 8005280396 symbol: 'BTC-USDT' time: 1685578980002 } }