tardis-dev
Version:
Convenient access to tick-level historical and real-time cryptocurrency market data via Node.js
551 lines (464 loc) • 17.4 kB
text/typescript
import { debug } from '../debug'
import { CircularBuffer, fromMicroSecondsToDate, lowerCaseSymbols } from '../handy'
import { BookChange, BookTicker, DerivativeTicker, Exchange, FilterForExchange, Liquidation, Trade } from '../types'
import { Mapper, PendingTickerInfoHelper } from './mapper'
// https://github.com/binance-exchange/binance-official-api-docs/blob/master/web-socket-streams.md
export class BinanceTradesMapper
implements Mapper<'binance' | 'binance-jersey' | 'binance-us' | 'binance-futures' | 'binance-delivery', Trade>
{
constructor(private readonly _exchange: Exchange) {}
canHandle(message: BinanceResponse<any>) {
if (message.stream === undefined) {
return false
}
return message.stream.endsWith('@trade')
}
getFilters(symbols?: string[]) {
symbols = lowerCaseSymbols(symbols)
return [
{
channel: 'trade',
symbols
} as const
]
}
*map(binanceTradeResponse: BinanceResponse<BinanceTradeData>, localTimestamp: Date) {
const binanceTrade = binanceTradeResponse.data
const isOffBookTrade = binanceTrade.X === 'INSURANCE_FUND' || binanceTrade.X === 'ADL' || binanceTrade.X === 'NA'
if (isOffBookTrade) {
return
}
const trade: Trade = {
type: 'trade',
symbol: binanceTrade.s,
exchange: this._exchange,
id: String(binanceTrade.t),
price: Number(binanceTrade.p),
amount: Number(binanceTrade.q),
side: binanceTrade.m ? 'sell' : 'buy',
timestamp: fromMicroSecondsToDate(binanceTrade.T),
localTimestamp: localTimestamp
}
yield trade
}
}
export class BinanceBookChangeMapper
implements Mapper<'binance' | 'binance-jersey' | 'binance-us' | 'binance-futures' | 'binance-delivery', BookChange>
{
protected readonly symbolToDepthInfoMapping: {
[key: string]: LocalDepthInfo
} = {}
constructor(protected readonly exchange: Exchange, protected readonly ignoreBookSnapshotOverlapError: boolean) {}
canHandle(message: BinanceResponse<any>) {
if (message.stream === undefined) {
return false
}
return message.stream.includes('@depth')
}
getFilters(symbols?: string[]) {
symbols = lowerCaseSymbols(symbols)
return [
{
channel: 'depth',
symbols
} as const,
{
channel: 'depthSnapshot',
symbols
} as const
]
}
*map(message: BinanceResponse<BinanceDepthData | BinanceDepthSnapshotData>, localTimestamp: Date) {
const symbol = message.stream.split('@')[0].toUpperCase()
if (this.symbolToDepthInfoMapping[symbol] === undefined) {
this.symbolToDepthInfoMapping[symbol] = {
bufferedUpdates: new CircularBuffer<BinanceDepthData>(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.data.lastUpdateId !== undefined) {
// if we've already received 'manual' snapshot, ignore if there is another one
if (snapshotAlreadyProcessed) {
return
}
// produce snapshot book_change
const binanceDepthSnapshotData = message.data
// mark given symbol depth info that has snapshot processed
symbolDepthInfo.lastUpdateId = binanceDepthSnapshotData.lastUpdateId
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.b) {
const matchingBid = binanceDepthSnapshotData.bids.find((b) => b[0] === bid[0])
if (matchingBid !== undefined) {
matchingBid[1] = bid[1]
} else {
binanceDepthSnapshotData.bids.push(bid)
}
}
for (const ask of update.a) {
const matchingAsk = binanceDepthSnapshotData.asks.find((a) => a[0] === ask[0])
if (matchingAsk !== undefined) {
matchingAsk[1] = ask[1]
} else {
binanceDepthSnapshotData.asks.push(ask)
}
}
}
}
// remove all buffered updates
symbolDepthInfo.bufferedUpdates.clear()
const bookChange: BookChange = {
type: 'book_change',
symbol,
exchange: this.exchange,
isSnapshot: true,
bids: binanceDepthSnapshotData.bids.map(this.mapBookLevel),
asks: binanceDepthSnapshotData.asks.map(this.mapBookLevel),
timestamp: binanceDepthSnapshotData.T !== undefined ? fromMicroSecondsToDate(binanceDepthSnapshotData.T) : 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.data as BinanceDepthData, localTimestamp)
if (bookChange !== undefined) {
yield bookChange
}
} else {
const binanceDepthUpdateData = message.data as BinanceDepthData
symbolDepthInfo.bufferedUpdates.append(binanceDepthUpdateData)
}
}
protected mapBookDepthUpdate(binanceDepthUpdateData: BinanceDepthData, 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[binanceDepthUpdateData.s]!
const lastUpdateId = depthContext.lastUpdateId!
// Drop any event where u is <= lastUpdateId in the snapshot
if (binanceDepthUpdateData.u <= lastUpdateId) {
return
}
// The first processed event should have U <= lastUpdateId+1 AND u >= 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
if ((binanceDepthUpdateData.U <= lastUpdateId + 1 && binanceDepthUpdateData.u >= lastUpdateId + 1) || bookSnapshotIsEmpty) {
depthContext.validatedFirstUpdate = true
} else {
const message = `Book depth snaphot has no overlap with first update, update ${JSON.stringify(
binanceDepthUpdateData
)}, lastUpdateId: ${lastUpdateId}, exchange ${this.exchange}`
if (this.ignoreBookSnapshotOverlapError) {
depthContext.validatedFirstUpdate = true
debug(message)
} else {
throw new Error(message)
}
}
}
return {
type: 'book_change',
symbol: binanceDepthUpdateData.s,
exchange: this.exchange,
isSnapshot: false,
bids: binanceDepthUpdateData.b.map(this.mapBookLevel),
asks: binanceDepthUpdateData.a.map(this.mapBookLevel),
timestamp: fromMicroSecondsToDate(binanceDepthUpdateData.E),
localTimestamp: localTimestamp
}
}
protected mapBookLevel(level: BinanceBookLevel) {
const price = Number(level[0])
const amount = Number(level[1])
return { price, amount }
}
}
export class BinanceFuturesBookChangeMapper
extends BinanceBookChangeMapper
implements Mapper<'binance-futures' | 'binance-delivery', BookChange>
{
constructor(protected readonly exchange: Exchange, protected readonly ignoreBookSnapshotOverlapError: boolean) {
super(exchange, ignoreBookSnapshotOverlapError)
}
protected mapBookDepthUpdate(binanceDepthUpdateData: BinanceFuturesDepthData, 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[binanceDepthUpdateData.s]!
const lastUpdateId = depthContext.lastUpdateId!
// based on https://binanceapitest.github.io/Binance-Futures-API-doc/wss/#how-to-manage-a-local-order-book-correctly
// Drop any event where u is < lastUpdateId in the snapshot
if (binanceDepthUpdateData.u < lastUpdateId) {
return
}
// The first processed should have U <= lastUpdateId AND u >= lastUpdateId
if (!depthContext.validatedFirstUpdate) {
if (binanceDepthUpdateData.U <= lastUpdateId && binanceDepthUpdateData.u >= lastUpdateId) {
depthContext.validatedFirstUpdate = true
} else {
const message = `Book depth snaphot has no overlap with first update, update ${JSON.stringify(
binanceDepthUpdateData
)}, lastUpdateId: ${lastUpdateId}, exchange ${this.exchange}`
if (this.ignoreBookSnapshotOverlapError) {
depthContext.validatedFirstUpdate = true
debug(message)
} else {
throw new Error(message)
}
}
}
return {
type: 'book_change',
symbol: binanceDepthUpdateData.s,
exchange: this.exchange,
isSnapshot: false,
bids: binanceDepthUpdateData.b.map(this.mapBookLevel),
asks: binanceDepthUpdateData.a.map(this.mapBookLevel),
timestamp: fromMicroSecondsToDate(binanceDepthUpdateData.E),
localTimestamp: localTimestamp
}
}
}
export class BinanceFuturesDerivativeTickerMapper implements Mapper<'binance-futures' | 'binance-delivery', DerivativeTicker> {
private readonly pendingTickerInfoHelper = new PendingTickerInfoHelper()
private readonly _indexPrices = new Map<string, number>()
constructor(protected readonly exchange: Exchange) {}
canHandle(message: BinanceResponse<any>) {
if (message.stream === undefined) {
return false
}
return (
message.stream.includes('@markPrice') ||
message.stream.endsWith('@ticker') ||
message.stream.endsWith('@openInterest') ||
message.stream.includes('@indexPrice')
)
}
getFilters(symbols?: string[]): FilterForExchange['binance-futures' | 'binance-delivery'][] {
symbols = lowerCaseSymbols(symbols)
const filters = [
{
channel: 'markPrice',
symbols
} as const,
{
channel: 'ticker',
symbols
} as const,
{
channel: 'openInterest',
symbols
} as const
]
if (this.exchange === 'binance-delivery') {
// index channel requires index symbol
filters.push({
channel: 'indexPrice' as any,
symbols: symbols !== undefined ? symbols.map((s) => s.split('_')[0]) : undefined
})
}
return filters
}
*map(
message: BinanceResponse<
BinanceFuturesMarkPriceData | BinanceFuturesTickerData | BinanceFuturesOpenInterestData | BinanceFuturesIndexPriceData
>,
localTimestamp: Date
): IterableIterator<DerivativeTicker> {
if (message.data.e === 'indexPriceUpdate') {
this._indexPrices.set(message.data.i, Number(message.data.p))
} else {
const symbol = 's' in message.data ? message.data.s : message.data.symbol
const pendingTickerInfo = this.pendingTickerInfoHelper.getPendingTickerInfo(symbol, this.exchange)
const lastIndexPrice = this._indexPrices.get(symbol.split('_')[0])
if (lastIndexPrice !== undefined) {
pendingTickerInfo.updateIndexPrice(lastIndexPrice)
}
if (message.data.e === 'markPriceUpdate') {
if ('r' in message.data && message.data.r !== '' && message.data.T !== 0) {
// only perpetual futures have funding rate info in mark price
// delivery futures sometimes send empty ('') r value
pendingTickerInfo.updateFundingRate(Number(message.data.r))
pendingTickerInfo.updateFundingTimestamp(new Date(message.data.T!))
}
if (message.data.i !== undefined) {
pendingTickerInfo.updateIndexPrice(Number(message.data.i))
}
pendingTickerInfo.updateMarkPrice(Number(message.data.p))
pendingTickerInfo.updateTimestamp(fromMicroSecondsToDate(message.data.E))
}
if (message.data.e === '24hrTicker') {
pendingTickerInfo.updateLastPrice(Number(message.data.c))
pendingTickerInfo.updateTimestamp(fromMicroSecondsToDate(message.data.E))
}
if ('openInterest' in message.data) {
pendingTickerInfo.updateOpenInterest(Number(message.data.openInterest))
}
if (pendingTickerInfo.hasChanged()) {
yield pendingTickerInfo.getSnapshot(localTimestamp)
}
}
}
}
export class BinanceLiquidationsMapper implements Mapper<'binance-futures' | 'binance-delivery', Liquidation> {
constructor(private readonly _exchange: Exchange) {}
canHandle(message: BinanceResponse<any>) {
if (message.stream === undefined) {
return false
}
return message.stream.endsWith('@forceOrder')
}
getFilters(symbols?: string[]) {
symbols = lowerCaseSymbols(symbols)
return [
{
channel: 'forceOrder',
symbols
} as const
]
}
*map(binanceTradeResponse: BinanceResponse<BinanceFuturesForceOrderData>, localTimestamp: Date) {
const binanceLiquidation = binanceTradeResponse.data.o
// not sure if order status can be different to 'FILLED' for liquidations in practice, but...
if (binanceLiquidation.X !== 'FILLED') {
return
}
const liquidation: Liquidation = {
type: 'liquidation',
symbol: binanceLiquidation.s,
exchange: this._exchange,
id: undefined,
price: Number(binanceLiquidation.p),
amount: Number(binanceLiquidation.z), // Order Filled Accumulated Quantity
side: binanceLiquidation.S === 'SELL' ? 'sell' : 'buy',
timestamp: fromMicroSecondsToDate(binanceLiquidation.T),
localTimestamp: localTimestamp
}
yield liquidation
}
}
export class BinanceBookTickerMapper implements Mapper<'binance-futures' | 'binance-delivery' | 'binance', BookTicker> {
constructor(private readonly _exchange: Exchange) {}
canHandle(message: BinanceResponse<any>) {
if (message.stream === undefined) {
return false
}
return message.stream.endsWith('@bookTicker')
}
getFilters(symbols?: string[]) {
symbols = lowerCaseSymbols(symbols)
return [
{
channel: 'bookTicker',
symbols
} as const
]
}
*map(binanceBookTickerResponse: BinanceResponse<BinanceBookTickerData>, localTimestamp: Date) {
const binanceBookTicker = binanceBookTickerResponse.data
const ticker: BookTicker = {
type: 'book_ticker',
symbol: binanceBookTicker.s,
exchange: this._exchange,
askAmount: binanceBookTicker.A !== undefined ? Number(binanceBookTicker.A) : undefined,
askPrice: binanceBookTicker.a !== undefined ? Number(binanceBookTicker.a) : undefined,
bidPrice: binanceBookTicker.b !== undefined ? Number(binanceBookTicker.b) : undefined,
bidAmount: binanceBookTicker.B !== undefined ? Number(binanceBookTicker.B) : undefined,
timestamp: binanceBookTicker.E !== undefined ? fromMicroSecondsToDate(binanceBookTicker.E) : localTimestamp,
localTimestamp: localTimestamp
}
yield ticker
}
}
type BinanceResponse<T> = {
stream: string
data: T
}
type BinanceTradeData = {
s: string
t: number
p: string
q: string
T: number
m: true
X?: 'INSURANCE_FUND' | 'MARKET' | 'ADL' | 'NA'
}
type BinanceBookLevel = [string, string]
type BinanceDepthData = {
lastUpdateId: undefined
E: number
s: string
U: number
u: number
b: BinanceBookLevel[]
a: BinanceBookLevel[]
}
// T is the time that updated in matching engine, while E is when pushing out from ws server
type BinanceFuturesDepthData = BinanceDepthData & {
pu: number
T: number
}
type BinanceDepthSnapshotData = {
lastUpdateId: number
bids: BinanceBookLevel[]
asks: BinanceBookLevel[]
T?: number
}
type LocalDepthInfo = {
bufferedUpdates: CircularBuffer<BinanceDepthData>
snapshotProcessed?: boolean
lastUpdateId?: number
validatedFirstUpdate?: boolean
}
type BinanceFuturesMarkPriceData = {
e: 'markPriceUpdate'
s: string // Symbol
E: number // Event time
p: string // Mark price
r?: string // Funding rate
T?: number // Next funding time
i?: string
}
type BinanceFuturesTickerData = {
e: '24hrTicker'
E: number // Event time
s: string // Symbol
c: string // Last price
}
type BinanceFuturesOpenInterestData = {
e: undefined
symbol: string
openInterest: string
}
type BinanceFuturesIndexPriceData = {
e: 'indexPriceUpdate' // Event type
E: 1591261236000 // Event time
i: string // Pair
p: string // Index Price
}
type BinanceFuturesForceOrderData = {
o: {
s: string // Symbol
S: string // Side
q: string // Original Quantity
p: string // Price
ap: string // Average Price
X: 'FILLED' // Order Status
l: '0.014' // Order Last Filled Quantity
T: 1568014460893 // Order Trade Time
z: string // Order Filled Accumulated Quantity
}
}
type BinanceBookTickerData = {
u: number // order book updateId
s: string // symbol
b: string // best bid price
B: string // best bid qty
a: string // best ask price
A: string // best ask qty
E?: number // transaction time
}