bot18
Version:
A high-frequency cryptocurrency trading bot by Zenbot creator @carlos8f
1,932 lines (1,651 loc) • 52.1 kB
JavaScript
'use strict'
const { EventEmitter } = require('events')
const debug = require('debug')('bitfinex:ws')
const WebSocket = require('ws')
const Promise = require('bluebird')
const CbQ = require('cbq')
const { isFinite } = require('lodash')
const _Throttle = require('lodash.throttle')
const { genAuthSig, nonce } = require('../util')
const getMessagePayload = require('../util/ws2')
const {
BalanceInfo,
FundingCredit,
FundingInfo,
FundingLoan,
FundingOffer,
FundingTrade,
MarginInfo,
Notification,
Order,
Position,
Trade,
PublicTrade,
Wallet,
OrderBook,
Candle,
TradingTicker,
FundingTicker
} = require('../models')
const WS_URL = 'wss://api.bitfinex.com/ws/2'
const MAX_CALC_OPS = 8
const INFO_CODES = {
SERVER_RESTART: 20051,
MAINTENANCE_START: 20060,
MAINTENANCE_END: 20061
}
const FLAGS = {
DEC_S: 8, // enables all decimals as strings
TIME_S: 32, // enables all timestamps as strings
TIMESTAMP: 32768, // timestamps in milliseconds
SEQ_ALL: 65536, // enable sequencing
CHECKSUM: 131072 // enable checksum per OB change, top 25 levels per-side
}
/**
* Communicates with v2 of the Bitfinex WebSocket API
*/
class WSv2 extends EventEmitter {
/**
* Instantiate a new ws2 transport. Does not auto-open
*
* @param {string} opts.apiKey
* @param {string} opts.apiSecret
* @param {string} opts.url - ws connection url
* @param {number} opts.orderOpBufferDelay - multi-order op batching timeout
* @param {boolean} opts.transform - if true, packets are converted to models
* @param {Object} opts.agent - optional node agent for ws connection (proxy)
* @param {boolean} opts.manageOrderBooks - enable local OB persistence
* @param {boolean} opts.manageCandles - enable local candle persistence
* @param {boolean} opts.seqAudit - enable sequence numbers & verification
* @param {boolean} opts.autoReconnect - if true, we will reconnect on close
* @param {number} opts.reconnectDelay - optional, defaults to 1000 (ms)
* @param {number} opts.packetWDDelay - watch-dog forced reconnection delay
*/
constructor (opts = { apiKey: '', apiSecret: '', url: WS_URL }) {
super()
this._apiKey = opts.apiKey || ''
this._apiSecret = opts.apiSecret || ''
this._agent = opts.agent
this._url = opts.url || WS_URL
this._transform = opts.transform === true
this._orderOpBufferDelay = opts.orderOpBufferDelay || -1
this._orderOpBuffer = []
this._orderOpTimeout = null
this._seqAudit = opts.seqAudit === true
this._autoReconnect = opts.autoReconnect === true
this._reconnectDelay = opts.reconnectDelay || 1000
this._manageOrderBooks = opts.manageOrderBooks === true
this._manageCandles = opts.manageCandles === true
this._packetWDDelay = opts.packetWDDelay
this._packetWDTimeout = null
this._packetWDLastTS = 0
this._orderBooks = {}
this._candles = {}
/**
* {
* [groupID]: {
* [eventName]: [{
* modelClass: ..,
* filter: { symbol: 'tBTCUSD' }, // only works w/ serialize
* cb: () => {}
* }]
* }
* }
* @private
*/
this._listeners = {}
this._infoListeners = {} // { [code]: <listeners> }
this._subscriptionRefs = {}
this._channelMap = {}
this._orderBooks = {}
this._enabledFlags = 0
this._eventCallbacks = new CbQ()
this._isAuthenticated = false
this._wasEverAuthenticated = false // used for auto-auth on reconnect
this._lastPubSeq = -1
this._lastAuthSeq = -1
this._isOpen = false
this._ws = null
this._isClosing = false // used to block reconnect on direct close() call
this._isReconnecting = false
this._onWSOpen = this._onWSOpen.bind(this)
this._onWSClose = this._onWSClose.bind(this)
this._onWSError = this._onWSError.bind(this)
this._onWSMessage = this._onWSMessage.bind(this)
this._triggerPacketWD = this._triggerPacketWD.bind(this)
this._sendCalc = _Throttle(this._sendCalc.bind(this), 1000 / MAX_CALC_OPS)
}
/**
* Opens a connection to the API server. Rejects with an error if a
* connection is already open. Resolves on success
*
* @return {Promise} p
*/
open () {
if (this._isOpen || this._ws !== null) {
return Promise.reject(new Error('already open'))
}
debug('connecting to %s...', this._url)
this._ws = new WebSocket(this._url, {
agent: this._agent
})
this._subscriptionRefs = {}
this._candles = {}
this._orderBooks = {}
if (this._seqAudit) {
this._ws.once('open', this.enableSequencing.bind(this))
}
this._ws.on('message', this._onWSMessage)
this._ws.on('open', this._onWSOpen)
this._ws.on('error', this._onWSError)
this._ws.on('close', this._onWSClose)
return new Promise((resolve, reject) => {
this._ws.once('open', () => {
debug('connected')
resolve()
})
})
}
/**
* Closes the active connection. If there is none, rejects with a promise.
* Resolves on success
*
* @param {number} code - passed to ws
* @param {string} reason - passed to ws
* @return {Promise}
*/
close (code, reason) {
if (!this._isOpen || this._ws === null) {
return Promise.reject(new Error('not open'))
}
debug('disconnecting...')
return new Promise((resolve, reject) => {
this._ws.once('close', () => {
this._isOpen = false
this._ws = null
debug('disconnected')
resolve()
})
if (!this._isClosing) {
this._isClosing = true
this._ws.close(code, reason)
}
})
}
/**
* Generates & sends an authentication packet to the server; if already
* authenticated, rejects with an error. Resolves on success
*
* @param {number} calc - optional, default is 0
* @return {Promise} p
*/
auth (calc = 0) {
if (!this._isOpen) return Promise.reject(new Error('not open'))
if (this._isAuthenticated) {
return Promise.reject(new Error('already authenticated'))
}
const authNonce = nonce()
const authPayload = `AUTH${authNonce}${authNonce}`
const { sig } = genAuthSig(this._apiSecret, authPayload)
return new Promise((resolve, reject) => {
this.once('auth', () => {
debug('authenticated')
resolve()
})
this.send({
event: 'auth',
apiKey: this._apiKey,
authSig: sig,
authPayload,
authNonce,
calc
})
})
}
/**
* Utility method to close & re-open the ws connection. Re-authenticates if
* previously authenticated
*
* @return {Promise} p - resolves on completion
*/
reconnect () {
if (!this._ws) return this.open()
this._isReconnecting = true
return new Promise((resolve, reject) => {
this.close().then(() => {
this.open()
if (!this._wasEverAuthenticated) {
return resolve()
}
this._ws.once('open', this.auth.bind(this))
this._ws.once('auth', () => resolve())
})
})
}
/**
* Returns an error if the message has an invalid (out of order) sequence #
* The last-seen sequence #s are updated internally.
*
* @param {Array} msg
* @return {Error} err - null if no error or sequencing not enabled
*/
_validateMessageSeq (msg = []) {
if (!this._seqAudit) return null
if (!Array.isArray(msg)) return null
if (msg.length === 0) return null
// The auth sequence # is the last value in channel 0 non-heartbeat packets.
const authSeq = msg[0] === 0 && msg[1] !== 'hb'
? msg[msg.length - 1]
: NaN
// All other packets provide a public sequence # as the last value. For chan
// 0 packets, these are included as the 2nd to last value
const seq = msg[0] === 0 && msg[1] !== 'hb'
? msg[msg.length - 2]
: msg[msg.length - 1]
if (!isFinite(seq)) return null
if (this._lastPubSeq === -1) { // first pub seq received
this._lastPubSeq = seq
return null
}
if (seq !== this._lastPubSeq + 1) { // check pub seq
return new Error(`invalid seq #; last ${this._lastPubSeq}, got ${seq}`)
}
this._lastPubSeq = seq
if (!isFinite(authSeq)) return null
if (authSeq === 0) return null // still syncing
if (authSeq === this._lastAuthSeq) return null // seq didn't advance
// check
if (this._lastAuthSeq !== -1 && authSeq !== this._lastAuthSeq + 1) {
return new Error(
`invalid auth seq #; last ${this._lastAuthSeq}, got ${authSeq}`
)
}
this._lastAuthSeq = authSeq
return null
}
/**
* Trigger the packet watch-dog; called when we haven't seen a new WS packet
* for longer than our WD duration (if provided)
* @private
*/
_triggerPacketWD () {
if (!this._packetWDDelay || !this._isOpen) return
debug(
'packet delay watchdog triggered [last packet %dms ago]',
Date.now() - this._packetWDLastTS
)
this._packetWDTimeout = null
this.reconnect()
}
/**
* Reset the packet watch-dog timeout. Should be called on every new WS packet
* if the watch-dog is enabled
* @private
*/
_resetPacketWD () {
if (!this._packetWDDelay) return
if (this._packetWDTimeout !== null) {
clearTimeout(this._packetWDTimeout)
}
if (!this._isOpen) return
this._packetWDTimeout = setTimeout(
this._triggerPacketWD,
this._packetWDDelay
)
}
/**
* @private
*/
_onWSOpen () {
// TODO: Add _resetState() method for this, see _onWSClose
this._isOpen = true
this._isReconnecting = false
this._packetWDLastTS = Date.now()
this._enabledFlags = 0
this._lastAuthSeq = -1
this._lastPubSeq = -1
this.emit('open')
debug('connection open')
}
/**
* @private
*/
_onWSClose () {
this._isOpen = false
this._isAuthenticated = false
this._lastAuthSeq = -1
this._lastPubSeq = -1
this._enabledFlags = 0
this._ws = null
this._subscriptionRefs = {}
this._channelMap = {}
this.emit('close')
debug('connection closed')
if (this._autoReconnect && !this._isClosing) {
setTimeout(this.reconnect.bind(this), this._reconnectDelay)
}
this._isClosing = false
}
/**
* @private
*/
_onWSError (err) {
this.emit('error', err)
debug('error: %j', err)
}
/**
* @param {Array} arrN - notification in ws array format
* @private
*/
_onWSNotification (arrN) {
const status = arrN[6]
const msg = arrN[7]
if (!arrN[4]) return
if (arrN[1] === 'on-req') {
const [,, cid] = arrN[4]
const k = `order-new-${cid}`
if (status === 'SUCCESS') {
return this._eventCallbacks.trigger(k, null, arrN[4])
}
this._eventCallbacks.trigger(k, new Error(`${status}: ${msg}`), arrN[4])
} else if (arrN[1] === 'oc-req') {
const [id] = arrN[4]
const k = `order-cancel-${id}`
if (status === 'SUCCESS') {
return this._eventCallbacks.trigger(k, null, arrN[4])
}
this._eventCallbacks.trigger(k, new Error(`${status}: ${msg}`), arrN[4])
} else if (arrN[1] === 'ou-req') {
const [id] = arrN[4]
const k = `order-update-${id}`
if (status === 'SUCCESS') {
return this._eventCallbacks.trigger(k, null, arrN[4])
}
this._eventCallbacks.trigger(k, new Error(`${status}: ${msg}`), arrN[4])
}
}
/**
* @param {string} msgJSON
* @param {string} flags
* @private
*/
_onWSMessage (msgJSON, flags) {
this._packetWDLastTS = Date.now()
this._resetPacketWD()
let msg
try {
msg = JSON.parse(msgJSON)
} catch (e) {
this.emit('error', `invalid message JSON: ${msgJSON}`)
return
}
if (this._seqAudit) {
const seqErr = this._validateMessageSeq(msg)
if (seqErr !== null) {
return this.emit('error', seqErr)
}
}
this.emit('message', msg, flags)
if (Array.isArray(msg)) {
this._handleChannelMessage(msg)
} else if (msg.event) {
this._handleEventMessage(msg)
} else {
debug('recv unidentified message: %j', msg)
}
}
/**
* @param {array} msg
* @private
*/
_handleChannelMessage (msg) {
const [chanId, type] = msg
const channelData = this._channelMap[chanId]
if (!channelData) {
debug('recv msg from unknown channel %d: %j', chanId, msg)
return
}
debug('recv msg: %j', msg)
if (msg.length < 2) return
if (msg[1] === 'hb') return // TODO: optionally track seq
if (channelData.channel === 'book') {
if (type === 'cs') {
return this._handleOBChecksumMessage(msg, channelData)
}
return this._handleOBMessage(msg, channelData)
} else if (channelData.channel === 'trades') {
return this._handleTradeMessage(msg, channelData)
} else if (channelData.channel === 'ticker') {
return this._handleTickerMessage(msg, channelData)
} else if (channelData.channel === 'candles') {
return this._handleCandleMessage(msg, channelData)
} else if (channelData.channel === 'auth') {
return this._handleAuthMessage(msg, channelData)
} else {
this._propagateMessageToListeners(msg, channelData)
this.emit(channelData.channel, msg)
}
}
_handleOBChecksumMessage (msg, chanData) {
this.emit('cs', msg)
if (!this._manageOrderBooks) {
return
}
const { symbol, prec } = chanData
const cs = msg[2]
const err = this._verifyManagedOBChecksum(symbol, prec, cs)
if (err) {
this.emit('error', err)
return
}
const internalMessage = [chanData.chanId, 'ob_checksum', cs]
internalMessage.filterOverride = [
chanData.symbol,
chanData.prec,
chanData.len
]
this._propagateMessageToListeners(internalMessage, false)
this.emit('cs', symbol, cs)
}
/**
* Called for messages from the 'book' channel. Might be an update or a
* snapshot
*
* @param {Array|Array[]} msg
* @param {Object} chanData - entry from _channelMap
* @private
*/
_handleOBMessage (msg, chanData) {
const { symbol, prec } = chanData
const raw = prec === 'R0'
let data = getMessagePayload(msg)
if (this._manageOrderBooks) {
const err = this._updateManagedOB(symbol, data, raw)
if (err) {
this.emit('error', err)
return
}
data = this._orderBooks[symbol]
}
// Always transform an array of entries
if (this._transform) {
data = new OrderBook((Array.isArray(data[0]) ? data : [data]), raw)
}
const internalMessage = [chanData.chanId, 'orderbook', data]
internalMessage.filterOverride = [
chanData.symbol,
chanData.prec,
chanData.len
]
this._propagateMessageToListeners(internalMessage, chanData, false)
this.emit('orderbook', symbol, data)
}
/**
* @param {string} symbol
* @param {number[]|number[][]} data
* @param {boolean} raw
* @return {Error} err - null on success
* @private
*/
_updateManagedOB (symbol, data, raw) {
// Snapshot, new OB. Note that we don't protect against duplicates, as they
// could come in on re-sub
if (Array.isArray(data[0])) {
this._orderBooks[symbol] = data
return null
}
// entry, needs to be applied to OB
if (!this._orderBooks[symbol]) {
return new Error(`recv update for unknown OB: ${symbol}`)
}
const success = OrderBook.updateArrayOBWith(
this._orderBooks[symbol], data, raw
)
if (!success) {
return new Error(
`ob update for unknown price level: ${JSON.stringify(data)}`
)
}
return null
}
/**
* @param {string} symbol
* @param {string} prec - precision
* @param {number} cs - expected checksum
* @return {Error} err - null if none
*/
_verifyManagedOBChecksum (symbol, prec, cs) {
const ob = this._orderBooks[symbol]
if (!ob) return null
const localCS = ob instanceof OrderBook
? ob.checksum()
: OrderBook.checksumArr(ob, prec === 'R0')
return localCS !== cs
? new Error(`OB checksum mismatch: got ${localCS}, want ${cs}`)
: null
}
/**
* Returns an up-to-date copy of the order book for the specified symbol, or
* null if no OB is managed for that symbol.
* Set `manageOrderBooks: true` in the constructor to use.
*
* @param {string} symbol
* @return {OrderBook} ob - null if not found
*/
getOB (symbol) {
if (!this._orderBooks[symbol]) return null
return new OrderBook(this._orderBooks[symbol])
}
/**
* @param {Array} msg
* @param {Object} chanData
* @private
*/
_handleTradeMessage (msg, chanData) {
const eventName = msg.length === 3 ? msg[1] : 'trades'
let payload = getMessagePayload(msg)
if (!Array.isArray(payload[0])) {
payload = [payload]
}
const model = msg[0] === 0 ? Trade : PublicTrade // auth trades have more data
const data = this._transform ? model.unserialize(payload) : payload
const internalMessage = [chanData.chanId, eventName, data]
internalMessage.filterOverride = [chanData.pair]
this._propagateMessageToListeners(internalMessage, chanData, false)
this.emit('trades', chanData.pair, data)
}
/**
* @param {Array} msg
* @param {Object} chanData
* @private
*/
_handleTickerMessage (msg = [], chanData = {}) {
let data = getMessagePayload(msg)
if (this._transform) {
msg[1].splice(0, 0, chanData.symbol)
data = (chanData.symbol || '')[0] === 't'
? new TradingTicker(msg[1])
: new FundingTicker(msg[1])
}
const internalMessage = [chanData.chanId, 'ticker', data]
internalMessage.filterOverride = [chanData.symbol]
this._propagateMessageToListeners(internalMessage, chanData, false)
this.emit('ticker', chanData.symbol, data)
}
/**
* Called for messages from a 'candles' channel. Might be an update or
* snapshot.
*
* @param {Array|Array[]} msg
* @param {Object} chanData - entry from _channelMap
* @private
*/
_handleCandleMessage (msg, chanData) {
const { key } = chanData
let data = getMessagePayload(msg)
if (this._manageCandles) {
const err = this._updateManagedCandles(key, data)
if (err) {
this.emit('error', err)
return
}
data = this._candles[key]
} else if (data.length > 0 && !Array.isArray(data[0])) {
data = [data] // always pass on an array of candles
}
if (this._transform) {
data = Candle.unserialize(data)
}
const internalMessage = [chanData.chanId, 'candle', data]
internalMessage.filterOverride = [chanData.key]
this._propagateMessageToListeners(internalMessage, chanData, false)
this.emit('candle', data, key)
}
/**
* @param {string} symbol
* @param {number[]|number[][]} data
* @return {Error} err - null on success
* @private
*/
_updateManagedCandles (key, data) {
if (Array.isArray(data[0])) { // snapshot, new candles
data.sort((a, b) => b[0] - a[0])
this._candles[key] = data
return null
}
// entry, needs to be applied to candle set
if (!this._candles[key]) {
return new Error(`recv update for unknown candles: ${key}`)
}
const candles = this._candles[key]
let updated = false
for (let i = 0; i < candles.length; i++) {
if (data[0] === candles[i][0]) {
candles[i] = data
updated = true
break
}
}
if (!updated) {
candles.unshift(data)
}
return null
}
/**
* Fetch a reference to the full set of synced candles for the specified key.
* Set `manageCandles: true` in the constructor to use.
*
* @param {string} key
* @return {Array} candles - empty array if none exist
*/
getCandles (key) {
return this._candles[key] || []
}
/**
* @param {Array} msg
* @param {Object} chanData
* @private
*/
_handleAuthMessage (msg, chanData) {
if (msg[1] === 'n') {
const payload = getMessagePayload(msg)
if (payload) {
this._onWSNotification(payload)
}
}
this._propagateMessageToListeners(msg, chanData)
}
/**
* @param {Array} msg
* @param {Object} chan - channel data
* @param {boolean} transform - defaults to internal flag
* @private
*/
_propagateMessageToListeners (msg, chan, transform = this._transform) {
const listenerGroups = Object.values(this._listeners)
for (let i = 0; i < listenerGroups.length; i++) {
WSv2._notifyListenerGroup(listenerGroups[i], msg, transform, this, chan)
}
}
/**
* Applies filtering & transform to a packet before sending it out to matching
* listeners in the group.
*
* @param {Object} lGroup - listener group to parse & notify
* @param {Object} msg - passed to each matched listener
* @param {boolean} transform - whether or not to instantiate a model
* @param {WSv2} ws - instance to pass to models if transforming
* @param {Object} chanData - channel data
* @private
*/
static _notifyListenerGroup (lGroup, msg, transform, ws, chanData) {
const [, eventName, data = []] = msg
let filterByData
// Catch-all can't filter/transform
WSv2._notifyCatchAllListeners(lGroup, msg)
if (!lGroup[eventName] || lGroup[eventName].length === 0) return
const listeners = lGroup[eventName].filter((listener) => {
const { filter } = listener
if (!filter) return true
// inspect snapshots for matching packets
if (Array.isArray(data[0])) {
const matchingData = data.filter((item) => {
filterByData = msg.filterOverride ? msg.filterOverride : item
return WSv2._payloadPassesFilter(filterByData, filter)
})
return matchingData.length !== 0
}
// inspect single packet
filterByData = msg.filterOverride ? msg.filterOverride : data
return WSv2._payloadPassesFilter(filterByData, filter)
})
if (listeners.length === 0) return
listeners.forEach(({ cb, modelClass }) => {
const ModelClass = modelClass
if (!transform || data.length === 0) {
cb(data, chanData)
} else if (Array.isArray(data[0])) {
cb(data.map((entry) => {
return new ModelClass(entry, ws)
}), chanData)
} else {
cb(new ModelClass(data, ws), chanData)
}
})
}
/**
* @param {Array} payload
* @param {Object} filter
* @return {boolean} pass
* @private
*/
static _payloadPassesFilter (payload, filter) {
const filterIndices = Object.keys(filter)
for (let k = 0; k < filterIndices.length; k++) {
if (!filter[filterIndices[k]]) continue // no value provided
if (payload[+filterIndices[k]] !== filter[filterIndices[k]]) {
return false
}
}
return true
}
/**
* @param {Object} lGroup - listener group keyed by event ('' in this case)
* @param {*} data - packet to pass to listeners
* @private
*/
static _notifyCatchAllListeners (lGroup, data) {
if (!lGroup['']) return
for (let j = 0; j < lGroup[''].length; j++) {
lGroup[''][j].cb(data)
}
}
/**
* @param {Object} msg
* @private
*/
_handleEventMessage (msg) {
if (msg.event === 'auth') {
return this._handleAuthEvent(msg)
} else if (msg.event === 'subscribed') {
return this._handleSubscribedEvent(msg)
} else if (msg.event === 'unsubscribed') {
return this._handleUnsubscribedEvent(msg)
} else if (msg.event === 'info') {
return this._handleInfoEvent(msg)
} else if (msg.event === 'conf') {
return this._handleConfigEvent(msg)
} else if (msg.event === 'error') {
return this._handleErrorEvent(msg)
}
debug('recv unknown event message: %j', msg)
return null
}
/**
* Emits an error on config failure, otherwise updates the internal flag set
* and triggers any callbacks
*
* @param {Object} msg
* @private
*/
_handleConfigEvent (msg = {}) {
const { status, flags } = msg
const k = this._getConfigEventKey(flags)
if (status !== 'OK') {
const err = new Error(`config failed (${status}) for flags ${flags}`)
debug('config failed: %s', err.message)
this.emit('error', err)
this._eventCallbacks.trigger(k, err)
} else {
debug('flags updated to %d', flags)
this._enabledFlags = flags
this._eventCallbacks.trigger(k, null, msg)
}
}
/**
* @param {Object} msg
* @private
*/
_handleErrorEvent (msg) {
debug('error: %s', JSON.stringify(msg))
this.emit('error', msg)
}
/**
* @param {Object} data
* @private
*/
_handleAuthEvent (data = {}) {
const { chanId, msg = '', status = '' } = data
if (status !== 'OK') {
const err = new Error(`auth failed: ${msg} (${status})`)
debug('auth failed: %s', err.message)
return this.emit('error', err)
}
this._channelMap[chanId] = { channel: 'auth' }
this._isAuthenticated = true
this._wasEverAuthenticated = true
this.emit('auth', data)
debug('authenticated!')
}
/**
* @param {Object} msg
* @private
*/
_handleSubscribedEvent (msg) {
this._channelMap[msg.chanId] = msg
debug('subscribed to %s [%d]', msg.channel, msg.chanId)
this.emit('subscribed', msg)
}
/**
* @param {Object} msg
* @private
*/
_handleUnsubscribedEvent (msg) {
delete this._channelMap[msg.chanId]
debug('unsubscribed from %d', msg.chanId)
this.emit('unsubscribed', msg)
}
/**
* @param {Object} msg
* @private
*/
_handleInfoEvent (msg = {}) {
const { version, code } = msg
if (version) {
if (version !== 2) {
const err = new Error(`server not running API v2: v${version}`)
this.emit('error', err)
this.close()
return
}
const { status } = msg.platform || {}
debug(
'server running API v2 (platform: %s (%d))',
status === 0 ? 'under maintenance' : 'operating normally', status
)
} else if (code) {
if (this._infoListeners[code]) {
this._infoListeners[code].forEach(cb => cb(msg))
}
if (code === INFO_CODES.SERVER_RESTART) {
debug('server restarted, please reconnect')
} else if (code === INFO_CODES.MAINTENANCE_START) {
debug('server maintenance period started!')
} else if (code === INFO_CODES.MAINTENANCE_END) {
debug('server maintenance period ended!')
}
}
this.emit('info', msg)
}
/**
* Subscribes and tracks subscriptions per channel/identifier pair. If
* already subscribed to the specified pair, nothing happens.
*
* @param {string} channel
* @param {string} identifier - for uniquely identifying the ref count
* @param {Object} payload - merged with sub packet
* @return {boolean} subSent
*/
managedSubscribe (channel = '', identifier = '', payload = {}) {
const key = `${channel}:${identifier}`
if (this._subscriptionRefs[key]) {
this._subscriptionRefs[key]++
return false
}
this._subscriptionRefs[key] = 1
this.subscribe(channel, payload)
return true
}
/**
* @param {string} channel
* @param {string} identifier
* @return {boolean} unsubSent
*/
managedUnsubscribe (channel = '', identifier = '') {
const key = `${channel}:${identifier}`
const chanId = this._chanIdByIdentifier(channel, identifier)
if (chanId === null || isNaN(this._subscriptionRefs[key])) return false
this._subscriptionRefs[key]--
if (this._subscriptionRefs[key] > 0) return false
this.unsubscribe(chanId)
delete this._subscriptionRefs[key]
return true
}
/**
* @param {Object} opts
* @param {number} opts.chanId
* @param {string} opts.channel - optional
* @param {string} opts.symbol - optional
* @param {string} opts.key - optional
* @return {Object} chanData - null if not found
*/
getChannelData ({ chanId, channel, symbol, key }) {
const id = chanId || this._chanIdByIdentifier(channel, symbol || key)
return this._channelMap[id] || null
}
/**
* @param {string} channel
* @param {string} identifier
* @private
*/
_chanIdByIdentifier (channel, identifier) {
const channelIds = Object.keys(this._channelMap)
let chan
for (let i = 0; i < channelIds.length; i++) {
chan = this._channelMap[channelIds[i]]
if (chan.channel === channel && (
chan.symbol === identifier ||
chan.key === identifier
)) {
return channelIds[i]
}
}
return null
}
/**
* @param {string} key
* @private
*/
_getEventPromise (key) {
return new Promise((resolve, reject) => {
this._eventCallbacks.push(key, (err, res) => {
if (err) {
return reject(err)
}
resolve(res)
})
})
}
/**
* Send a packet to the WS server
*
* @param {*} msg - packet, gets stringified
*/
send (msg) {
if (!this._ws) {
return this.emit('error', new Error('ws not open'))
}
debug('sending %j', msg)
this._ws.send(JSON.stringify(msg))
}
/**
* Configures the seq flag to enable sequencing (packet number) for this
* connection. When enabled, the seq number will be the last value of
* channel packet arrays.
*
* @param {Object} args
* @param {boolean} args.audit - if true, an error is emitted on invalid seq
* @return {Promise} p
*/
enableSequencing (args = { audit: true }) {
this._seqAudit = args.audit === true
return this.enableFlag(FLAGS.SEQ_ALL)
}
/**
* Enables a configuration flag. See the FLAGS map
*
* @param {number} flag
* @return {Promise} p
*/
enableFlag (flag) {
this.send({
event: 'conf',
flags: this._enabledFlags | flag
})
return this._getEventPromise(this._getConfigEventKey(flag))
}
/**
* Checks local state, relies on successful server config responses
* @see enableFlag
*
* @param {number} flag
* @return {boolean} enabled
*/
isFlagEnabled (flag) {
return (this._enabledFlags & flag) === flag
}
_getConfigEventKey (flag) {
return `conf-res-${flag}`
}
/**
* Register a callback in case of a ws server restart message; Use this to
* call reconnect() if needed. (code 20051)
*
* @param {method} cb
*/
onServerRestart (cb) {
this.onInfoMessage(WSv2.info.SERVER_RESTART, cb)
}
/**
* Register a callback in case of a 'maintenance started' message from the
* server. This is a good time to pause server packets until maintenance ends
*
* @param {method} cb
*/
onMaintenanceStart (cb) {
this.onInfoMessage(WSv2.info.MAINTENANCE_START, cb)
}
/**
* Register a callback to be notified of a maintenance period ending
*
* @param {method} cb
*/
onMaintenanceEnd (cb) {
this.onInfoMessage(WSv2.info.MAINTENANCE_END, cb)
}
/**
* @param {string} channel
* @param {Object} payload - optional extra packet data
*/
subscribe (channel, payload) {
this.send(Object.assign({
event: 'subscribe',
channel
}, payload))
}
/**
* @param {string} symbol
* @return {boolean} subscribed
*/
subscribeTicker (symbol) {
return this.managedSubscribe('ticker', symbol, { symbol })
}
/**
* @param {string} symbol
* @return {boolean} subscribed
*/
subscribeTrades (symbol) {
return this.managedSubscribe('trades', symbol, { symbol })
}
/**
* @param {string} symbol
* @param {string} prec - P0, P1, P2, or P3 (default P0)
* @param {string} len - 25 or 100 (default 25)
* @return {boolean} subscribed
*/
subscribeOrderBook (symbol, prec = 'P0', len = '25') {
return this.managedSubscribe('book', symbol, { symbol, len, prec })
}
/**
* @param {string} key
* @return {boolean} subscribed
*/
subscribeCandles (key) {
return this.managedSubscribe('candles', key, { key })
}
/**
* @param {number} chanId
*/
unsubscribe (chanId) {
this.send({
event: 'unsubscribe',
chanId: +chanId
})
}
/**
* @param {string} symbol
* @return {boolean} unsubscribed
*/
unsubscribeTicker (symbol) {
return this.managedUnsubscribe('ticker', symbol)
}
/**
* @param {string} symbol
* @return {boolean} unsubscribed
*/
unsubscribeTrades (symbol) {
return this.managedUnsubscribe('trades', symbol)
}
/**
* @param {string} symbol
* @param {string} prec - P0, P1, P2, or P3 (default P0)
* @param {string} len - 25 or 100 (default 25)
* @return {boolean} unsubscribed
*/
unsubscribeOrderBook (symbol, prec = 'P0', len = '25') {
return this.managedUnsubscribe('book', symbol)
}
/**
* @param {string} symbol
* @param {string} frame - time frame
* @return {boolean} unsubscribed
*/
unsubscribeCandles (symbol, frame) {
return this.managedUnsubscribe('candles', `trade:${frame}:${symbol}`)
}
/**
* @param {string} cbGID
*/
removeListeners (cbGID) {
delete this._listeners[cbGID]
}
/**
* @param {string[]} prefixes
*/
requestCalc (prefixes) {
this._sendCalc([0, 'calc', null, prefixes.map(p => [p])])
}
/**
* Throttled call to ws.send, max 8 op/s
*
* @param {Array} msg
* @private
*/
_sendCalc (msg) {
debug('req calc: %j', msg)
this._ws.send(JSON.stringify(msg))
}
/**
* Sends a new order to the server and resolves the returned promise once the
* order submit is confirmed. Emits an error if not authenticated. The order
* can be either an array, key/value map, or Order object instance.
*
* @param {Object|Array} order
* @return {Promise} p - resolves on submit notification
*/
submitOrder (order) {
if (!this._isAuthenticated) {
return Promise.reject(new Error('not authenticated'))
}
const packet = Array.isArray(order)
? order
: order instanceof Order
? order.toNewOrderPacket()
: new Order(order).toNewOrderPacket()
this._sendOrderPacket([0, 'on', null, packet])
return this._getEventPromise(`order-new-${packet.cid}`)
}
/**
* Send a changeset to update an order in-place while maintaining position in
* the price queue. The changeset must contain the order ID, and supports a
* 'delta' key to increase/decrease the total amount.
*
* @param {Object} changes - requires at least an 'id'
* @return {Promise} p - resolves on receival of confirmation notification
*/
updateOrder (changes = {}) {
const { id } = changes
if (!this._isAuthenticated) {
return Promise.reject(new Error('not authenticated'))
} else if (!id) {
return Promise.reject(new Error('order ID required for update'))
}
this._sendOrderPacket([0, 'ou', null, changes])
return this._getEventPromise(`order-update-${id}`)
}
/**
* Cancels an order by ID and resolves the returned promise once the cancel is
* confirmed. Emits an error if not authenticated. The ID can be passed as a
* number, or taken from an order array/object.
*
* @param {Object|Array|number} order
* @return {Promise} p
*/
cancelOrder (order) {
if (!this._isAuthenticated) {
return Promise.reject(new Error('not authenticated'))
}
const id = typeof order === 'number'
? order
: Array.isArray(order)
? order[0]
: order.id
debug(`cancelling order ${id}`)
this._sendOrderPacket([0, 'oc', null, { id }])
return this._getEventPromise(`order-cancel-${id}`)
}
/**
* Cancels multiple orders, returns a promise that resolves once all
* operations are confirmed.
*
* @see cancelOrder
*
* @param {Object[]|Array[]|number[]} orders
* @return {Promise} p
*/
cancelOrders (orders) {
if (!this._isAuthenticated) {
return Promise.reject(new Error('not authenticated'))
}
return Promise.all(orders.map((order) => {
return this.cancelOrder(order)
}))
}
/**
* Sends the op payloads to the server as an 'ox_multi' command. A promise is
* returned and resolves immediately if authenticated, as no confirmation is
* available for this message type.
*
* @param {Object[]} opPayloads
* @return {Promise} p - rejects if not authenticated
*/
submitOrderMultiOp (opPayloads) {
if (!this._isAuthenticated) {
return Promise.reject(new Error('not authenticated'))
}
this.send([0, 'ox_multi', null, opPayloads])
return Promise.resolve() // TODO: multi-op tracking
}
/**
* @param {array} packet
* @private
*/
_sendOrderPacket (packet) {
if (this._hasOrderBuff()) {
this._ensureOrderBuffTimeout()
this._orderOpBuffer.push(packet)
} else {
this.send(packet)
}
}
/**
* @return {boolean} buffEnabled
* @private
*/
_hasOrderBuff () {
return this._orderOpBufferDelay > 0
}
/**
* @private
*/
_ensureOrderBuffTimeout () {
if (this._orderOpTimeout !== null) return
this._orderOpTimeout = setTimeout(
this._flushOrderOps.bind(this),
this._orderOpBufferDelay
)
}
/**
* Splits the op buffer into packets of max 15 ops each, and sends them down
* the wire.
*
* @return {Promise} p - resolves after send
* @private
*/
_flushOrderOps () {
this._orderOpTimeout = null
const packets = this._orderOpBuffer.map(p => [p[1], p[3]])
this._orderOpBuffer = []
if (packets.length <= 15) {
return this.submitOrderMultiOp(packets)
}
const promises = []
while (packets.length > 0) {
const opPackets = packets.splice(0, Math.min(packets.length, 15))
promises.push(this.submitOrderMultiOp(opPackets))
}
return Promise.all(promises)
}
/**
* @return {boolean} authenticated
*/
isAuthenticated () {
return this._isAuthenticated
}
/**
* @return {boolean} open
*/
isOpen () {
return this._isOpen
}
/**
* @return {boolean} reconnecting
*/
isReconnecting () {
return this._isReconnecting
}
/**
* Adds a listener to the internal listener set, with an optional grouping
* for batch unsubscribes (GID) & automatic ws packet matching (filterKey)
*
* @param {string} eventName - as received on ws stream
* @param {Object} filter - map of index & value in ws packet
* @param {object} modelClass - model to use for serialization
* @param {string} cbGID - listener group ID for mass removal
* @param {method} cb - listener
* @private
*/
_registerListener (eventName, filter, modelClass, cbGID, cb) {
if (!cbGID) cbGID = null
if (!this._listeners[cbGID]) {
this._listeners[cbGID] = { [eventName]: [] }
}
const listeners = this._listeners[cbGID]
if (!listeners[eventName]) {
listeners[eventName] = []
}
const l = {
cb,
modelClass,
filter
}
listeners[eventName].push(l)
}
/**
* Registers a new callback to be called when a matching info message is
* received.
*
* @param {number} code - from WSv2.info.*
* @param {method} cb
*/
onInfoMessage (code, cb) {
if (!this._infoListeners[code]) {
this._infoListeners[code] = []
}
this._infoListeners[code].push(cb)
}
/**
* @param {Object} opts
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
*/
onMessage ({ cbGID }, cb) {
this._registerListener('', null, null, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.key - candle set key, i.e. trade:30m:tBTCUSD
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-public-candle
*/
onCandle ({ key, cbGID }, cb) {
this._registerListener('candle', { 0: key }, Candle, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.prec
* @param {string} opts.len
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-public-order-books
*/
onOrderBook ({ symbol, prec, len, cbGID }, cb) {
this._registerListener('orderbook', {
0: symbol,
1: prec,
2: len
}, OrderBook, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.prec
* @param {string} opts.len
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-public-order-books
*/
onOrderBookChecksum ({ symbol, prec, len, cbGID }, cb) {
this._registerListener('ob_checksum', {
0: symbol,
1: prec,
2: len
}, null, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.pair
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-public-trades
*/
onTrades ({ pair, cbGID }, cb) {
this._registerListener('trades', { 0: pair }, PublicTrade, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-public-ticker
*/
onTicker ({ symbol = '', cbGID } = {}, cb) {
const m = symbol[0] === 'f' ? FundingTicker : TradingTicker
this._registerListener('ticker', { 0: symbol }, m, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {number} opts.id
* @param {number} opts.cid
* @param {number} opts.gid
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-orders
*/
onOrderSnapshot ({ symbol, id, cid, gid, cbGID }, cb) {
this._registerListener('os', {
0: id,
1: gid,
2: cid,
3: symbol
}, Order, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string?} opts.symbol
* @param {number?} opts.id
* @param {number?} opts.cid
* @param {number?} opts.gid
* @param {string?} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-orders
*/
onOrderNew ({ symbol, id, cid, gid, cbGID }, cb) {
this._registerListener('on', {
0: id,
1: gid,
2: cid,
3: symbol
}, Order, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {number} opts.id
* @param {number} opts.gid
* @param {number} opts.cid
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-orders
*/
onOrderUpdate ({ symbol, id, cid, gid, cbGID }, cb) {
this._registerListener('ou', {
0: id,
1: gid,
2: cid,
3: symbol
}, Order, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {number} opts.id
* @param {number} opts.gid
* @param {number} opts.cid
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-orders
*/
onOrderClose ({ symbol, id, cid, gid, cbGID }, cb) {
this._registerListener('oc', {
0: id,
1: gid,
2: cid,
3: symbol
}, Order, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-position
*/
onPositionSnapshot ({ symbol, cbGID }, cb) {
this._registerListener('ps', { 0: symbol }, Position, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-position
*/
onPositionNew ({ symbol, cbGID }, cb) {
this._registerListener('pn', { 0: symbol }, Position, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-position
*/
onPositionUpdate ({ symbol, cbGID }, cb) {
this._registerListener('pu', { 0: symbol }, Position, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-position
*/
onPositionClose ({ symbol, cbGID }, cb) {
this._registerListener('pc', { 0: symbol }, Position, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.pair
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-trades
*/
onTradeEntry ({ pair, cbGID }, cb) {
this._registerListener('te', { 0: pair }, Trade, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.pair
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-trades
*/
onTradeUpdate ({ pair, cbGID }, cb) {
this._registerListener('tu', { 0: pair }, Trade, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-offers
*/
onFundingOfferSnapshot ({ symbol, cbGID }, cb) {
this._registerListener('fos', { 1: symbol }, FundingOffer, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-offers
*/
onFundingOfferNew ({ symbol, cbGID }, cb) {
this._registerListener('fon', { 1: symbol }, FundingOffer, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-offers
*/
onFundingOfferUpdate ({ symbol, cbGID }, cb) {
this._registerListener('fou', { 1: symbol }, FundingOffer, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-offers
*/
onFundingOfferClose ({ symbol, cbGID }, cb) {
this._registerListener('foc', { 1: symbol }, FundingOffer, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-credits
*/
onFundingCreditSnapshot ({ symbol, cbGID }, cb) {
this._registerListener('fcs', { 1: symbol }, FundingCredit, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-credits
*/
onFundingCreditNew ({ symbol, cbGID }, cb) {
this._registerListener('fcn', { 1: symbol }, FundingCredit, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-credits
*/
onFundingCreditUpdate ({ symbol, cbGID }, cb) {
this._registerListener('fcu', { 1: symbol }, FundingCredit, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-credits
*/
onFundingCreditClose ({ symbol, cbGID }, cb) {
this._registerListener('fcc', { 1: symbol }, FundingCredit, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-loans
*/
onFundingLoanSnapshot ({ symbol, cbGID }, cb) {
this._registerListener('fls', { 1: symbol }, FundingLoan, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-loans
*/
onFundingLoanNew ({ symbol, cbGID }, cb) {
this._registerListener('fln', { 1: symbol }, FundingLoan, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-loans
*/
onFundingLoanUpdate ({ symbol, cbGID }, cb) {
this._registerListener('flu', { 1: symbol }, FundingLoan, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.symbol
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-loans
*/
onFundingLoanClose ({ symbol, cbGID }, cb) {
this._registerListener('flc', { 1: symbol }, FundingLoan, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.cbGID - callback group id
* @param {Method} cb
* @see https://docs.bitfinex.com/v2/reference#ws-auth-wallets
*/
onWalletSnapshot ({ cbGID }, cb) {
this._registerListener('ws', null, Wallet, cbGID, cb)
}
/**
* @param {Object} opts
* @param {string} opts.cbGID - callback group id