UNPKG

tardis-dev

Version:

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

292 lines (225 loc) 9.23 kB
import { Writable } from 'stream' import { batch, httpClient, wait } from '../handy' import { Filter } from '../types' import { MultiConnectionRealTimeFeedBase, PoolingClientBase, RealTimeFeedBase } from './realtimefeed' const binanceHttpOptions = { timeout: 10 * 1000, retry: { limit: 10, statusCodes: [418, 429, 500, 403], maxRetryAfter: 120 * 1000 } } abstract class BinanceRealTimeFeedBase extends MultiConnectionRealTimeFeedBase { protected abstract wssURL: string protected abstract httpURL: string protected abstract suffixes: { [key: string]: string } protected abstract depthRequestRequestWeight: number protected *_getRealTimeFeeds(exchange: string, filters: Filter<string>[], timeoutIntervalMS?: number, onError?: (error: Error) => void) { const wsFilters = filters.filter((f) => f.channel !== 'openInterest' && f.channel !== 'recentTrades') if (wsFilters.length > 0) { yield new BinanceSingleConnectionRealTimeFeed( exchange, wsFilters, this.wssURL, this.httpURL, this.suffixes, this.depthRequestRequestWeight, timeoutIntervalMS, onError ) } const openInterestFilters = filters.filter((f) => f.channel === 'openInterest') if (openInterestFilters.length > 0) { const instruments = openInterestFilters.flatMap((s) => s.symbols!) yield new BinanceFuturesOpenInterestClient(exchange, this.httpURL, instruments) } } } class BinanceFuturesOpenInterestClient extends PoolingClientBase { constructor(exchange: string, private readonly _httpURL: string, private readonly _instruments: string[]) { super(exchange, 3) } protected async poolDataToStream(outputStream: Writable) { for (const instruments of batch(this._instruments, 10)) { await Promise.allSettled( instruments.map(async (instrument) => { if (outputStream.destroyed) { return } const openInterestResponse = (await httpClient .get(`${this._httpURL}/openInterest?symbol=${instrument.toUpperCase()}`, binanceHttpOptions) .json()) as any const openInterestMessage = { stream: `${instrument.toLocaleLowerCase()}@openInterest`, generated: true, data: openInterestResponse } if (outputStream.writable) { outputStream.write(openInterestMessage) } }) ) } } } class BinanceSingleConnectionRealTimeFeed extends RealTimeFeedBase { constructor( exchange: string, filters: Filter<string>[], protected wssURL: string, private readonly _httpURL: string, private readonly _suffixes: { [key: string]: string }, private readonly _depthRequestRequestWeight: number, timeoutIntervalMS: number | undefined, onError?: (error: Error) => void ) { super(exchange, filters, timeoutIntervalMS, onError) } protected mapToSubscribeMessages(filters: Filter<string>[]): any[] { const payload = filters .filter((f) => f.channel !== 'depthSnapshot') .map((filter, index) => { if (!filter.symbols || filter.symbols.length === 0) { throw new Error('BinanceRealTimeFeed requires explicitly specified symbols when subscribing to live feed') } const suffix = this._suffixes[filter.channel] const channel = suffix !== undefined ? `${filter.channel}@${suffix}` : filter.channel return { method: 'SUBSCRIBE', params: filter.symbols.map((symbol) => `${symbol}@${channel}`), id: index + 1 } }) return payload } protected messageIsError(message: any): boolean { // subscription confirmation message if (message.result === null) { return false } if (message.stream === undefined) { return true } if (message.error !== undefined) { return true } return false } protected async provideManualSnapshots(filters: Filter<string>[], shouldCancel: () => boolean) { const depthSnapshotFilter = filters.find((f) => f.channel === 'depthSnapshot') if (!depthSnapshotFilter) { return } let currentWeightLimit: number = 0 const exchangeInfoResponse = await httpClient.get(`${this._httpURL}/exchangeInfo`, binanceHttpOptions) const exchangeInfo = JSON.parse(exchangeInfoResponse.body) const REQUEST_WEIGHT_LIMIT_ENV = `${this._exchange.toUpperCase().replace(/-/g, '_')}_REQUEST_WEIGHT_LIMIT` const DELAY_ENV = `${this._exchange.toUpperCase().replace(/-/g, '_')}_SNAPSHOTS_DELAY_MS` if (process.env[REQUEST_WEIGHT_LIMIT_ENV] !== undefined) { currentWeightLimit = Number.parseInt(process.env[REQUEST_WEIGHT_LIMIT_ENV] as string) } if (!currentWeightLimit) { currentWeightLimit = exchangeInfo.rateLimits.find((d: any) => d.rateLimitType === 'REQUEST_WEIGHT').limit as number } let usedWeight = Number.parseInt(exchangeInfoResponse.headers['x-mbx-used-weight-1m'] as string) this.debug('current x-mbx-used-weight-1m limit: %s, already used weight: %s', currentWeightLimit, usedWeight) let concurrencyLimit = 4 const CONCURRENCY_LIMIT_WEIGHT_ENV = `${this._exchange.toUpperCase().replace(/-/g, '_')}_CONCURRENCY_LIMIT` if (process.env[CONCURRENCY_LIMIT_WEIGHT_ENV] !== undefined) { concurrencyLimit = Number.parseInt(process.env[CONCURRENCY_LIMIT_WEIGHT_ENV] as string) } this.debug('current snapshots requests concurrency limit: %s', concurrencyLimit) let minWeightBuffer = 2 * concurrencyLimit * this._depthRequestRequestWeight const MIN_WEIGHT_BUFFER_ENV = `${this._exchange.toUpperCase().replace(/-/g, '_')}_MIN_AVAILABLE_WEIGHT_BUFFER` if (process.env[MIN_WEIGHT_BUFFER_ENV] !== undefined) { minWeightBuffer = Number.parseInt(process.env[MIN_WEIGHT_BUFFER_ENV] as string) } for (const symbolsBatch of batch(depthSnapshotFilter.symbols!, concurrencyLimit)) { if (shouldCancel()) { return } this.debug('requesting manual snapshots for: %s', symbolsBatch) const usedWeights = await Promise.all( symbolsBatch.map(async (symbol) => { if (shouldCancel()) { return 0 } const isOverRateLimit = currentWeightLimit - usedWeight < minWeightBuffer if (isOverRateLimit) { const secondsToWait = 61 - new Date().getUTCSeconds() this.debug( 'reached rate limit (x-mbx-used-weight-1m limit: %s, used weight: %s, minimum available weight buffer: %s), waiting: %s seconds', currentWeightLimit, usedWeight, minWeightBuffer, secondsToWait ) await wait(secondsToWait * 1000) } const depthSnapshotResponse = await httpClient.get( `${this._httpURL}/depth?symbol=${symbol.toUpperCase()}&limit=1000`, binanceHttpOptions ) const snapshot = { stream: `${symbol}@depthSnapshot`, generated: true, data: JSON.parse(depthSnapshotResponse.body) } this.manualSnapshotsBuffer.push(snapshot) if (process.env[DELAY_ENV] !== undefined) { const msToWait = Number.parseInt(process.env[DELAY_ENV] as string) await wait(msToWait) } return Number.parseInt(depthSnapshotResponse.headers['x-mbx-used-weight-1m'] as string) }) ) usedWeight = Math.max(...usedWeights) this.debug('requested manual snapshots successfully for: %s, used weight: %s', symbolsBatch, usedWeight) } this.debug('requested all manual snapshots successfully') } } export class BinanceRealTimeFeed extends BinanceRealTimeFeedBase { protected wssURL = 'wss://stream.binance.com/stream?timeUnit=microsecond' protected httpURL = 'https://api.binance.com/api/v1' protected suffixes = { depth: '100ms' } protected depthRequestRequestWeight = 10 } export class BinanceJerseyRealTimeFeed extends BinanceRealTimeFeedBase { protected wssURL = 'wss://stream.binance.je:9443/stream' protected httpURL = 'https://api.binance.je/api/v1' protected suffixes = { depth: '100ms' } protected depthRequestRequestWeight = 10 } export class BinanceUSRealTimeFeed extends BinanceRealTimeFeedBase { protected wssURL = 'wss://stream.binance.us:9443/stream' protected httpURL = 'https://api.binance.us/api/v1' protected suffixes = { depth: '100ms' } protected depthRequestRequestWeight = 10 } export class BinanceFuturesRealTimeFeed extends BinanceRealTimeFeedBase { protected wssURL = 'wss://fstream.binance.com/stream' protected httpURL = 'https://fapi.binance.com/fapi/v1' protected suffixes = { depth: '0ms', markPrice: '1s' } protected depthRequestRequestWeight = 20 } export class BinanceDeliveryRealTimeFeed extends BinanceRealTimeFeedBase { protected wssURL = 'wss://dstream.binance.com/stream' protected httpURL = 'https://dapi.binance.com/dapi/v1' protected suffixes = { depth: '0ms', markPrice: '1s', indexPrice: '1s' } protected depthRequestRequestWeight = 20 }