UNPKG

tardis-dev

Version:

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

777 lines (659 loc) 22.3 kB
import { asNumberIfValid, CircularBuffer, upperCaseSymbols } from '../handy' import { BookChange, BookTicker, DerivativeTicker, Exchange, FilterForExchange, Liquidation, OptionSummary, Trade } from '../types' import { Mapper, PendingTickerInfoHelper } from './mapper' // https://huobiapi.github.io/docs/spot/v1/en/#websocket-market-data // https://github.com/huobiapi/API_Docs_en/wiki/WS_api_reference_en export class HuobiTradesMapper implements Mapper<'huobi' | 'huobi-dm' | 'huobi-dm-swap' | 'huobi-dm-linear-swap' | 'huobi-dm-options', Trade> { constructor(private readonly _exchange: Exchange) {} canHandle(message: HuobiDataMessage) { if (message.ch === undefined) { return false } return message.ch.endsWith('.trade.detail') } getFilters(symbols?: string[]) { symbols = normalizeSymbols(symbols) return [ { channel: 'trade', symbols } as const ] } *map(message: HuobiTradeDataMessage, localTimestamp: Date): IterableIterator<Trade> { const symbol = message.ch.split('.')[1].toUpperCase() for (const huobiTrade of message.tick.data) { yield { type: 'trade', symbol, exchange: this._exchange, id: String(huobiTrade.tradeId !== undefined ? huobiTrade.tradeId : huobiTrade.id), price: huobiTrade.price, amount: huobiTrade.amount, side: huobiTrade.direction === 'buy' ? 'buy' : huobiTrade.direction === 'sell' ? 'sell' : 'unknown', timestamp: new Date(huobiTrade.ts), localTimestamp: localTimestamp } } } } export class HuobiBookChangeMapper implements Mapper<'huobi' | 'huobi-dm' | 'huobi-dm-swap' | 'huobi-dm-linear-swap' | 'huobi-dm-options', BookChange> { constructor(protected readonly _exchange: Exchange) {} canHandle(message: HuobiDataMessage) { if (message.ch === undefined) { return false } return message.ch.includes('.depth.') } getFilters(symbols?: string[]) { symbols = normalizeSymbols(symbols) return [ { channel: 'depth', symbols } as const ] } *map(message: HuobiDepthDataMessage, localTimestamp: Date) { const symbol = message.ch.split('.')[1].toUpperCase() const isSnapshot = 'event' in message.tick ? message.tick.event === 'snapshot' : 'update' in message ? false : true const data = message.tick const bids = Array.isArray(data.bids) ? data.bids : [] const asks = Array.isArray(data.asks) ? data.asks : [] if (bids.length === 0 && asks.length === 0) { return } yield { type: 'book_change', symbol, exchange: this._exchange, isSnapshot, bids: bids.map(this._mapBookLevel), asks: asks.map(this._mapBookLevel), timestamp: new Date(message.ts), localTimestamp: localTimestamp } as const } private _mapBookLevel(level: HuobiBookLevel) { return { price: level[0], amount: level[1] } } } function isSnapshot(message: HuobiMBPDataMessage | HuobiMBPSnapshot): message is HuobiMBPSnapshot { return 'rep' in message } export class HuobiMBPBookChangeMapper implements Mapper<'huobi', BookChange> { protected readonly symbolToMBPInfoMapping: { [key: string]: MBPInfo } = {} constructor(protected readonly _exchange: Exchange) {} canHandle(message: any) { const channel = message.ch || message.rep if (channel === undefined) { return false } return channel.includes('.mbp.') } getFilters(symbols?: string[]) { symbols = normalizeSymbols(symbols) return [ { channel: 'mbp', symbols } as const ] } *map(message: HuobiMBPDataMessage | HuobiMBPSnapshot, localTimestamp: Date) { const symbol = (isSnapshot(message) ? message.rep : message.ch).split('.')[1].toUpperCase() if (this.symbolToMBPInfoMapping[symbol] === undefined) { this.symbolToMBPInfoMapping[symbol] = { bufferedUpdates: new CircularBuffer<HuobiMBPDataMessage>(20) } } const mbpInfo = this.symbolToMBPInfoMapping[symbol] const snapshotAlreadyProcessed = mbpInfo.snapshotProcessed if (isSnapshot(message)) { if (message.data == null) { return } const snapshotBids = message.data.bids.map(this._mapBookLevel) const snapshotAsks = message.data.asks.map(this._mapBookLevel) // if there were any depth updates buffered, let's proccess those by adding to or updating the initial snapshot // when prevSeqNum >= snapshot seqNum for (const update of mbpInfo.bufferedUpdates.items()) { if (update.tick.prevSeqNum < message.data.seqNum) { continue } const bookChange = this._mapMBPUpdate(update, symbol, localTimestamp) if (bookChange !== undefined) { for (const bid of bookChange.bids) { const matchingBid = snapshotBids.find((b) => b.price === bid.price) if (matchingBid !== undefined) { matchingBid.amount = bid.amount } else { snapshotBids.push(bid) } } for (const ask of bookChange.asks) { const matchingAsk = snapshotAsks.find((a) => a.price === ask.price) if (matchingAsk !== undefined) { matchingAsk.amount = ask.amount } else { snapshotAsks.push(ask) } } } } mbpInfo.snapshotProcessed = true yield { type: 'book_change', symbol, exchange: this._exchange, isSnapshot: true, bids: snapshotBids, asks: snapshotAsks, timestamp: new Date(message.ts), localTimestamp } as const } else { mbpInfo.bufferedUpdates.append(message) if (snapshotAlreadyProcessed) { // snapshot was already processed let's map the mbp message as normal book_change const update = this._mapMBPUpdate(message, symbol, localTimestamp) if (update !== undefined) { yield update } } } } private _mapMBPUpdate(message: HuobiMBPDataMessage, symbol: string, localTimestamp: Date) { const bids = Array.isArray(message.tick.bids) ? message.tick.bids : [] const asks = Array.isArray(message.tick.asks) ? message.tick.asks : [] if (bids.length === 0 && asks.length === 0) { return } return { type: 'book_change', symbol, exchange: this._exchange, isSnapshot: false, bids: bids.map(this._mapBookLevel), asks: asks.map(this._mapBookLevel), timestamp: new Date(message.ts), localTimestamp: localTimestamp } as const } private _mapBookLevel(level: HuobiBookLevel) { return { price: level[0], amount: level[1] } } } function normalizeSymbols(symbols?: string[]) { if (symbols !== undefined) { return symbols.map((s) => { // huobi-dm and huobi-dm-swap expect symbols to be upper cased if (s.includes('_') || s.includes('-')) { return s.toUpperCase() } // huobi global expects lower cased symbols return s.toLowerCase() }) } return } export class HuobiDerivativeTickerMapper implements Mapper<'huobi-dm' | 'huobi-dm-swap' | 'huobi-dm-linear-swap', DerivativeTicker> { private readonly pendingTickerInfoHelper = new PendingTickerInfoHelper() constructor(private readonly _exchange: Exchange) {} canHandle(message: any) { if (message.ch !== undefined) { return message.ch.includes('.basis.') || message.ch.endsWith('.open_interest') } if (message.op === 'notify' && message.topic !== undefined) { return message.topic.endsWith('.funding_rate') } return false } getFilters(symbols?: string[]) { symbols = upperCaseSymbols(symbols) const filters: FilterForExchange['huobi-dm-swap'][] = [ { channel: 'basis', symbols }, { channel: 'open_interest', symbols } ] if (this._exchange === 'huobi-dm-swap' || this._exchange === 'huobi-dm-linear-swap') { filters.push({ channel: 'funding_rate', symbols }) } return filters } *map( message: HuobiBasisDataMessage | HuobiFundingRateNotification | HuobiOpenInterestDataMessage, localTimestamp: Date ): IterableIterator<DerivativeTicker> { if ('op' in message) { // handle funding_rate notification message const fundingInfo = message.data[0] const pendingTickerInfo = this.pendingTickerInfoHelper.getPendingTickerInfo(fundingInfo.contract_code, this._exchange) pendingTickerInfo.updateFundingRate(Number(fundingInfo.funding_rate)) pendingTickerInfo.updateFundingTimestamp(new Date(Number(fundingInfo.settlement_time))) pendingTickerInfo.updatePredictedFundingRate(Number(fundingInfo.estimated_rate)) pendingTickerInfo.updateTimestamp(new Date(message.ts)) if (pendingTickerInfo.hasChanged()) { yield pendingTickerInfo.getSnapshot(localTimestamp) } } else { const symbol = message.ch.split('.')[1] const pendingTickerInfo = this.pendingTickerInfoHelper.getPendingTickerInfo(symbol, this._exchange) // basis message if ('tick' in message) { pendingTickerInfo.updateIndexPrice(Number(message.tick.index_price)) pendingTickerInfo.updateLastPrice(Number(message.tick.contract_price)) } else { // open interest message const openInterest = message.data[0] pendingTickerInfo.updateOpenInterest(Number(openInterest.volume)) } pendingTickerInfo.updateTimestamp(new Date(message.ts)) if (pendingTickerInfo.hasChanged()) { yield pendingTickerInfo.getSnapshot(localTimestamp) } } } } export class HuobiLiquidationsMapper implements Mapper<'huobi-dm' | 'huobi-dm-swap' | 'huobi-dm-linear-swap', Liquidation> { private readonly _contractCodeToSymbolMap: Map<string, string> = new Map() private readonly _contractTypesSuffixes = { this_week: 'CW', next_week: 'NW', quarter: 'CQ', next_quarter: 'NQ' } constructor(private readonly _exchange: Exchange) {} canHandle(message: HuobiLiquidationOrder | HuobiContractInfo) { if (message.op !== 'notify') { return false } if (this._exchange === 'huobi-dm' && message.topic.endsWith('.contract_info')) { this._updateContractCodeToSymbolMap(message as HuobiContractInfo) } return message.topic.endsWith('.liquidation_orders') } getFilters(symbols?: string[]) { symbols = upperCaseSymbols(symbols) if (this._exchange === 'huobi-dm') { // huobi-dm for liquidations requires prividing different symbols which are indexes names for example 'BTC' or 'ETH' // not futures names like 'BTC_NW' // see https://huobiapi.github.io/docs/dm/v1/en/#subscribe-liquidation-order-data-no-authentication-sub if (symbols !== undefined) { symbols = symbols.map((s) => s.split('_')[0]) } // we also need to subscribe to contract_info which will provide us information that will allow us to map // liquidation message symbol and contract code to symbols we expect (BTC_NW etc) return [ { channel: 'liquidation_orders', symbols } as const, { channel: 'contract_info', symbols } as const ] } else { // huobi dm swap liquidations messages provide correct symbol & contract code return [ { channel: 'liquidation_orders', symbols } as const ] } } private _updateContractCodeToSymbolMap(message: HuobiContractInfo) { for (const item of message.data) { this._contractCodeToSymbolMap.set(item.contract_code, `${item.symbol}_${this._contractTypesSuffixes[item.contract_type]}`) } } *map(message: HuobiLiquidationOrder, localTimestamp: Date): IterableIterator<Liquidation> { for (const huobiLiquidation of message.data) { let symbol = huobiLiquidation.contract_code // huobi-dm returns index name as a symbol, not future alias, so we need to map it here if (this._exchange === 'huobi-dm') { const futureAliasSymbol = this._contractCodeToSymbolMap.get(huobiLiquidation.contract_code) if (futureAliasSymbol === undefined) { continue } symbol = futureAliasSymbol } yield { type: 'liquidation', symbol, exchange: this._exchange, id: undefined, price: huobiLiquidation.price, amount: huobiLiquidation.volume, side: huobiLiquidation.direction === 'buy' ? 'buy' : huobiLiquidation.direction === 'sell' ? 'sell' : 'unknown', timestamp: new Date(huobiLiquidation.created_at), localTimestamp: localTimestamp } } } } export class HuobiOptionsSummaryMapper implements Mapper<'huobi-dm-options', OptionSummary> { private readonly _indexPrices = new Map<string, number>() private readonly _openInterest = new Map<string, number>() canHandle(message: HuobiOpenInterestDataMessage | HuobiOptionsIndexMessage | HuobiOptionsMarketIndexMessage) { if (message.ch === undefined) { return false } return message.ch.endsWith('.open_interest') || message.ch.endsWith('.option_index') || message.ch.endsWith('.option_market_index') } getFilters(symbols?: string[]) { const indexes = symbols !== undefined ? symbols.map((s) => { const symbolParts = s.split('-') return `${symbolParts[0]}-${symbolParts[1]}` }) : undefined return [ { channel: `open_interest`, symbols } as const, { channel: `option_index`, symbols: indexes } as const, { channel: 'option_market_index', symbols } as const ] } *map( message: HuobiOpenInterestDataMessage | HuobiOptionsIndexMessage | HuobiOptionsMarketIndexMessage, localTimestamp: Date ): IterableIterator<OptionSummary> | undefined { if (message.ch.endsWith('.option_index')) { const indexUpdateMessage = message as HuobiOptionsIndexMessage this._indexPrices.set(indexUpdateMessage.data.symbol, indexUpdateMessage.data.index_price) return } if (message.ch.endsWith('.open_interest')) { const openInterestMessage = message as HuobiOptionsOpenInterestMessage for (const ioMessage of openInterestMessage.data) { this._openInterest.set(ioMessage.contract_code, ioMessage.volume) } return } const marketIndexMessage = message as HuobiOptionsMarketIndexMessage const symbolParts = marketIndexMessage.data.contract_code.split('-') const expirationDate = new Date(`20${symbolParts[2].slice(0, 2)}-${symbolParts[2].slice(2, 4)}-${symbolParts[2].slice(4, 6)}Z`) expirationDate.setUTCHours(8) const underlying = `${symbolParts[0]}-${symbolParts[1]}` const lastUnderlyingPrice = this._indexPrices.get(underlying) const openInterest = this._openInterest.get(marketIndexMessage.data.contract_code) const optionSummary: OptionSummary = { type: 'option_summary', symbol: marketIndexMessage.data.contract_code, exchange: 'huobi-dm-options', optionType: marketIndexMessage.data.option_right_type === 'P' ? 'put' : 'call', strikePrice: Number(symbolParts[4]), expirationDate, bestBidPrice: asNumberIfValid(marketIndexMessage.data.bid_one), bestBidAmount: undefined, bestBidIV: asNumberIfValid(marketIndexMessage.data.iv_bid_one), bestAskPrice: asNumberIfValid(marketIndexMessage.data.ask_one), bestAskAmount: undefined, bestAskIV: asNumberIfValid(marketIndexMessage.data.iv_ask_one), lastPrice: asNumberIfValid(marketIndexMessage.data.last_price), openInterest, markPrice: marketIndexMessage.data.mark_price > 0 ? asNumberIfValid(marketIndexMessage.data.mark_price) : undefined, markIV: asNumberIfValid(marketIndexMessage.data.iv_mark_price), delta: asNumberIfValid(marketIndexMessage.data.delta), gamma: asNumberIfValid(marketIndexMessage.data.gamma), vega: asNumberIfValid(marketIndexMessage.data.vega), theta: asNumberIfValid(marketIndexMessage.data.theta), rho: undefined, underlyingPrice: lastUnderlyingPrice, underlyingIndex: underlying, timestamp: new Date(marketIndexMessage.ts), localTimestamp: localTimestamp } yield optionSummary } } export class HuobiBookTickerMapper implements Mapper<'huobi' | 'huobi-dm' | 'huobi-dm-swap' | 'huobi-dm-linear-swap', BookTicker> { constructor(private readonly _exchange: Exchange) {} canHandle(message: HuobiDataMessage) { if (message.ch === undefined) { return false } return message.ch.endsWith('.bbo') } getFilters(symbols?: string[]) { symbols = normalizeSymbols(symbols) return [ { channel: 'bbo', symbols } as const ] } *map(message: HuobiBBOMessage, localTimestamp: Date): IterableIterator<BookTicker> { const symbol = message.ch.split('.')[1].toUpperCase() if ('quoteTime' in message.tick) { if (message.tick.quoteTime === 0) { return } yield { type: 'book_ticker', symbol, exchange: this._exchange, askAmount: asNumberIfValid(message.tick.askSize), askPrice: asNumberIfValid(message.tick.ask), bidPrice: asNumberIfValid(message.tick.bid), bidAmount: asNumberIfValid(message.tick.bidSize), timestamp: new Date(message.tick.quoteTime), localTimestamp: localTimestamp } } else { yield { type: 'book_ticker', symbol, exchange: this._exchange, askAmount: message.tick.ask !== undefined && message.tick.ask !== null ? asNumberIfValid(message.tick.ask[1]) : undefined, askPrice: message.tick.ask !== undefined && message.tick.ask !== null ? asNumberIfValid(message.tick.ask[0]) : undefined, bidPrice: message.tick.bid !== undefined && message.tick.bid !== null ? asNumberIfValid(message.tick.bid[0]) : undefined, bidAmount: message.tick.bid !== undefined && message.tick.bid !== null ? asNumberIfValid(message.tick.bid[1]) : undefined, timestamp: new Date(message.tick.ts), localTimestamp: localTimestamp } } } } type HuobiDataMessage = { ch: string } type HuobiTradeDataMessage = HuobiDataMessage & { tick: { data: { id: number tradeId?: number price: number amount: number direction: 'buy' | 'sell' ts: number }[] } } type HuobiBookLevel = [number, number] type HuobiDepthDataMessage = HuobiDataMessage & ( | { update?: boolean ts: number tick: { bids: HuobiBookLevel[] | null asks: HuobiBookLevel[] | null } } | { ts: number tick: { bids?: HuobiBookLevel[] | null asks?: HuobiBookLevel[] | null event: 'snapshot' | 'update' } } ) type HuobiBasisDataMessage = HuobiDataMessage & { ts: number tick: { index_price: string contract_price: string } } type HuobiFundingRateNotification = { op: 'notify' topic: string ts: number data: { settlement_time: string funding_rate: string estimated_rate: string contract_code: string }[] } type HuobiOpenInterestDataMessage = HuobiDataMessage & { ts: number data: { volume: number }[] } type HuobiMBPDataMessage = HuobiDataMessage & { ts: number tick: { bids?: HuobiBookLevel[] | null asks?: HuobiBookLevel[] | null seqNum: number prevSeqNum: number } } type HuobiMBPSnapshot = { ts: number rep: string data?: { bids: HuobiBookLevel[] asks: HuobiBookLevel[] seqNum: number } } type MBPInfo = { bufferedUpdates: CircularBuffer<HuobiMBPDataMessage> snapshotProcessed?: boolean } type HuobiLiquidationOrder = { op: 'notify' topic: string ts: number data: { symbol: string contract_code: string direction: 'buy' | 'sell' offset: string volume: number price: number created_at: number }[] } type HuobiContractInfo = { op: 'notify' topic: string ts: number data: { symbol: string contract_code: string contract_type: 'this_week' | 'next_week' | 'quarter' | 'next_quarter' }[] } type HuobiOptionsOpenInterestMessage = { ch: 'market.BTC-USDT-210521-C-42000.open_interest' generated: true data: [ { volume: 684.0 amount: 0.684 symbol: 'BTC' contract_type: 'this_week' contract_code: 'BTC-USDT-210521-C-42000' trade_partition: 'USDT' trade_amount: 0.792 trade_volume: 792 trade_turnover: 3237.37806 } ] ts: 1621296002336 } type HuobiOptionsIndexMessage = { ch: 'market.BTC-USDT.option_index' generated: true data: { symbol: 'BTC-USDT'; index_price: 43501.21; index_ts: 1621295997270 } ts: 1621296002825 } type HuobiOptionsMarketIndexMessage = { ch: 'market.BTC-USDT-210521-P-42000.option_market_index' generated: true data: { contract_code: 'BTC-USDT-210521-P-42000' symbol: 'BTC' iv_last_price: 1.62902357 iv_ask_one: 1.64869787 iv_bid_one: 1.13185884 iv_mark_price: 1.39190675 delta: -0.3704996546766173 gamma: 0.00006528 theta: -327.85540508 vega: 15.70293917 ask_one: 2000 bid_one: 1189.49 last_price: 1968.83 mark_price: 1594.739777491571343067 trade_partition: 'USDT' contract_type: 'this_week' option_right_type: 'P' } ts: 1621296002820 } type HuobiBBOMessage = | { ch: 'market.BTC-USDT.bbo' ts: 1630454400495 tick: { mrid: 64797873746 id: 1630454400 bid: [47176.5, 1] | undefined ask: [47176.6, 9249] | undefined ts: 1630454400495 version: 64797873746 ch: 'market.BTC-USDT.bbo' } } | { ch: 'market.btcusdt.bbo' ts: 1575158404058 tick: { seqId: 103273695595 ask: 7543.59 askSize: 2.323241 bid: 7541.16 bidSize: 0.002329 quoteTime: number symbol: 'btcusdt' } }