UNPKG

bfx-api-node-models

Version:

Object models for usage with the Bitfinex node API

544 lines (461 loc) 14.8 kB
'use strict' const CRC = require('crc-32') const { EventEmitter } = require('events') const _isEmpty = require('lodash/isEmpty') const _isString = require('lodash/isString') const { preparePrice } = require('bfx-api-node-util') /** * High level OB model to automatically integrate WS updates & maintain sort */ class OrderBook extends EventEmitter { /** * Initializes the order book with an existing snapshot (array form) * * @param {Array[]|OrderBook} snap - order book snapshot * @param {boolean} [raw] - true for raw 'R0' order books */ constructor (snap = [], raw = false) { super() this.raw = raw if (snap instanceof OrderBook) { this.bids = snap.bids.slice() this.asks = snap.asks.slice() } else if (snap && Array.isArray(snap)) { this.updateFromSnapshot(snap) } else if (snap && Array.isArray(snap.bids) && Array.isArray(snap.asks)) { this.bids = snap.bids.slice() this.asks = snap.asks.slice() } else { this.bids = [] this.asks = [] } } /** * Returns the total volume at n basis points from the mid price * * @param {number} bps - basis points from mid price * @returns {number} vol - total volume */ volBPSMid (bps) { const priceI = this.raw ? (this.bids[0] || this.asks[0]).length === 4 ? 2 : 1 : 0 const mid = this.midPrice() const askLimit = mid * (1 + (bps / 10000)) const bidLimit = mid * (1 - (bps / 10000)) let askVol = 0 let bidVol = 0 let row for (let i = 0; i < this.bids.length; i += 1) { row = this.bids[i] if (row[priceI] < bidLimit) { break } bidVol += row.length === 4 ? row[3] : row[2] } for (let i = 0; i < this.asks.length; i += 1) { row = this.asks[i] if (row[priceI] > askLimit) { break } askVol += Math.abs(row.length === 4 ? row[3] : row[2]) } return askVol + bidVol } /** * Generates a crc-32 checksum of our current state. The checksum'ed string * itself is a concatenated list of the top 25 bids & asks, alternating. * * @see http://blog.bitfinex.com/api/bitfinex-api-order-books-checksums * * @returns {number} cs */ checksum () { const { raw } = this const data = [] for (let i = 0; i < 25; i += 1) { const bid = this.bids[i] const ask = this.asks[i] if (bid) { let price = bid[0] const amount = bid.length === 4 ? bid[3] : bid[2] if (!raw && !_isString(price)) { price = Number(preparePrice(price)) price = /e/.test(price + '') ? price.toFixed(Math.abs((price + '').split('e')[1]) + 1) // i.e. 1.7e-7 to fixed : price } data.push(price, amount) } if (ask) { let price = ask[0] const amount = ask.length === 4 ? ask[3] : ask[2] if (!raw && !_isString(price)) { price = Number(preparePrice(price)) price = /e/.test(price + '') ? price.toFixed(Math.abs((price + '').split('e')[1]) + 1) // i.e. 1.7e-7 to fixed : price } data.push(price, amount) } } return CRC.str(data.join(':')) } /** * Like checksum(), but for raw array-format order books * * @param {Array[]} arr - assumed sorted, [topBid, bid, ..., topAsk, ask, ...] * @param {boolean} [raw] - true for raw 'R0' order books * @returns {number} cs */ static checksumArr (arr, raw = false) { let topAskI = -1 // find first ask (book is sorted bids first) for (let i = 0; i < arr.length; i += 1) { if ((arr[i].length === 4 ? Number(-arr[i][3]) : Number(arr[i][2])) < 0) { topAskI = i break } } const data = [] let ask let bid // Either bids/asks may be empty, or have differing lengths for (let i = 0; i < 25; i += 1) { bid = topAskI === -1 || i < topAskI // still reading bids ? arr[i] : null // reached asks ask = topAskI === -1 ? null : arr[topAskI + i] if (bid) { let price = bid[0] const amount = bid.length === 4 ? bid[3] : bid[2] if (!raw && !_isString(price)) { price = Number(preparePrice(price)) price = /e/.test(price + '') ? price.toFixed(Math.abs((price + '').split('e')[1]) + 1) // i.e. 1.7e-7 to fixed : price } data.push(price, amount) } if (ask) { let price = ask[0] const amount = ask.length === 4 ? ask[3] : ask[2] if (!raw && !_isString(price)) { price = Number(preparePrice(price)) price = /e/.test(price + '') ? price.toFixed(Math.abs((price + '').split('e')[1]) + 1) // i.e. 1.7e-7 to fixed : price } data.push(price, amount) } } return CRC.str(data.join(':')) } updateFromSnapshot (snapshot) { this.bids = [] this.asks = [] if (_isEmpty(snapshot)) { return } for (let i = 0; i < snapshot.length; i++) { if (snapshot[i].length === 4) { if (Number(snapshot[i][3]) < 0) { this.bids.push(snapshot[i]) } else { this.asks.push(snapshot[i]) } } else { if (Number(snapshot[i][2]) < 0) { this.asks.push(snapshot[i]) } else { this.bids.push(snapshot[i]) } } } } /** * Integrate an update packet (add, update, or remove a price level). Emits an * 'update' event on success * * @param {Array} entry - price level to update with * @returns {boolean} success - false if entry doesn't match OB */ updateWith (entry) { const { raw } = this const priceI = raw ? entry.length === 4 ? 2 : 1 : 0 const numEntry = entry.map((x) => Number(x)) const isValid = numEntry.every(value => Number.isFinite(value)) if (!isValid) { return false } const count = raw ? -1 : numEntry.length === 4 ? numEntry[2] : numEntry[1] const price = numEntry[priceI] const oID = numEntry[0] // only for raw books const amount = numEntry.length === 4 ? numEntry[3] : numEntry[2] const dir = numEntry.length === 4 ? amount < 0 ? 1 : -1 : amount < 0 ? -1 : 1 const side = numEntry.length === 4 ? amount < 0 ? this.bids : this.asks : amount < 0 ? this.asks : this.bids let insertIndex = -1 let pl // apply insert directly if empty if (side.length === 0 && (raw || count > 0)) { side.push(entry) this.emit('update', entry) return true } // Match by price level, or order ID for raw books for (let i = 0; i < side.length; i++) { if ((!raw && Number(side[i][priceI]) === price) || (raw && Number(side[i][0]) === oID)) { if ((!raw && count === 0) || (raw && price === 0)) { side.splice(i, 1) // remove this.emit('update', entry) return true } else if (!raw || (raw && price > 0)) { side.splice(i, 1) // remove, add update as new entry below break } } } // remove unkown, can happen if OB is initialized w/o all price levels if ((raw && price === 0) || (!raw && count === 0)) { return false } for (let i = 0; i < side.length; i++) { pl = side[i].map((x) => Number(x)) if (insertIndex === -1 && ( (dir === -1 && price < pl[priceI]) || // by price (dir === -1 && price === pl[priceI] && (raw && entry[0] < pl[0])) || // by order ID (dir === 1 && price > pl[priceI]) || (dir === 1 && price === pl[priceI] && (raw && entry[0] < pl[0])) )) { insertIndex = i // insert index to maintain sort break } } // add if (insertIndex === -1) { side.push(entry) } else { side.splice(insertIndex, 0, entry) } this.emit('update', entry) return true } /** * @returns {number} topBid - may be null */ topBid () { const priceI = this.raw ? (this.bids[0].length === 4 || this.asks[0].length === 4) ? 2 : 1 : 0 return (this.topBidLevel() || [])[priceI] || null } /** * @returns {number} topBidLevel - may be null */ topBidLevel () { return this.bids[0] || null } /** * @returns {number} topAsk - may be null */ topAsk () { const priceI = this.raw ? (this.bids[0].length === 4 || this.asks[0].length === 4) ? 2 : 1 : 0 return (this.topAskLevel() || [])[priceI] || null } /** * @returns {number} topAskLevel - may be null */ topAskLevel () { return this.asks[0] || null } /** * @returns {number} price */ midPrice () { const priceI = this.raw ? (this.bids[0].length === 4 || this.asks[0].length === 4) ? 2 : 1 : 0 const topAsk = (this.asks[0] || [])[priceI] || 0 const topBid = (this.bids[0] || [])[priceI] || 0 if (topAsk === 0) return topBid if (topBid === 0) return topAsk return (topAsk + topBid) / 2 } /** * @returns {number} spread - top bid/ask difference */ spread () { const priceI = this.raw ? (this.bids[0].length === 4 || this.asks[0].length === 4) ? 2 : 1 : 0 const topAsk = (this.asks[0] || [])[priceI] || 0 const topBid = (this.bids[0] || [])[priceI] || 0 if (topAsk === 0 || topBid === 0) { return 0 } return topAsk - topBid } /** * @returns {number} amount - total buy-side volume */ bidAmount () { let amount = 0 for (let i = 0; i < this.bids.length; i++) { amount += this.bids[i].length === 4 ? this.bids[i][3] : this.bids[i][2] } return Math.abs(amount) } /** * @returns {number} amount - total sell-side volume */ askAmount () { let amount = 0 for (let i = 0; i < this.asks.length; i++) { amount += this.asks[i].length === 4 ? this.asks[i][3] : this.asks[i][2] } return Math.abs(amount) } /** * @param {number} price - price level to fetch * @returns {object} entry - unserialized, null if not found */ getEntry (price) { const priceI = this.raw ? (this.bids[0].length === 4 || this.asks[0].length === 4) ? 2 : 1 : 0 const side = this.asks.length > 0 ? price >= this.asks[0][priceI] ? this.asks : this.bids : price <= this.bids[0][priceI] ? this.bids : this.asks for (let i = 0; i < side.length; i++) { if (price === side[i][priceI]) { return OrderBook.unserialize(side[i]) } } return null } serialize () { return (this.asks || []).concat(this.bids || []) } /** * @returns {object} pojo */ toJS () { const arr = this.serialize() return OrderBook.unserialize(arr, this.raw) } /** * Modifies an array-format OB in place with an update entry. Maintains sort * * @param {number[][]} ob - array-format order book * @param {number[]} entry - price level to update with * @param {boolean} [raw] - true for raw 'R0' order books * @returns {boolean} success - false if entry doesn't match OB */ static updateArrayOBWith (ob, entry, raw = false) { if (entry.length === 0) { return false } const priceI = raw ? entry.length === 4 ? 2 : 1 : 0 const price = Number(entry[priceI]) const amount = entry.length === 4 ? Number(entry[3]) : Number(entry[2]) const dir = entry.length === 4 ? amount < 0 ? 1 : -1 : amount < 0 ? -1 : 1 const count = raw ? -1 : entry.length === 4 ? Number(entry[2]) : Number(entry[1]) let insertIndex = -1 let pl // price level for (let i = 0; i < ob.length; i++) { pl = ob[i].map((x) => Number(x)) if ( (!raw && pl[priceI] === price) || (raw && pl[0] === Number(entry[0])) ) { if ((!raw && count === 0) || (raw && price === 0)) { ob.splice(i, 1) // remove existing return true } else { ob.splice(i, 1) // update; remove & re-insert break } } } // remove unkown, can happen if OB is initialized w/o all price levels if ((!raw && count === 0) || (raw && price === 0)) { return false } for (let i = 0; i < ob.length; i++) { pl = ob[i].map((x) => Number(x)) if (insertIndex === -1) { if ( (dir === -1 && (pl.length === 4 ? -pl[3] : pl[2]) < 0 && price < pl[priceI]) || // by price (dir === -1 && (pl.length === 4 ? -pl[3] : pl[2]) < 0 && price === pl[priceI] && (raw && Number(entry[0]) < pl[0])) || // by order ID (dir === 1 && (pl.length === 4 ? -pl[3] : pl[2]) > 0 && price > pl[priceI]) || (dir === 1 && (pl.length === 4 ? -pl[3] : pl[2]) > 0 && price === pl[priceI] && (raw && Number(entry[0]) < pl[0])) || (dir === 1 && (pl.length === 4 ? -pl[3] : pl[2]) < 0) ) { insertIndex = i break } } } // add if (insertIndex === -1) { ob.push(entry) } else { ob.splice(insertIndex, 0, entry) } return true } static arrayOBMidPrice (ob = [], raw = false) { if (ob.length === 0) return null const priceI = raw ? ob[0].length === 4 ? 2 : 1 : 0 let bestBuy = -Infinity let bestAsk = Infinity let entry for (let i = 0; i < ob.length; i++) { entry = ob[i] if ((entry.length === 4 ? -entry[3] : entry[2]) > 0 && entry[priceI] > bestBuy) bestBuy = entry[priceI] if ((entry.length === 4 ? -entry[3] : entry[2]) < 0 && entry[priceI] < bestAsk) bestAsk = entry[priceI] } if (bestBuy === -Infinity || bestAsk === Infinity) return null return (bestAsk + bestBuy) / 2.0 } /** * Converts an array order book entry or snapshot to an object, with 'price', * 'count', and 'amount' keys on entries * * @param {number[]|number[][]} arr - array format order book * @param {boolean} [raw] - true for raw 'R0' order books * @returns {object} ob - either a map w/ bids & asks, or single entry object */ static unserialize (arr, raw = false) { if (Array.isArray(arr[0])) { const entries = arr.map(e => OrderBook.unserialize(e, raw)) const bids = entries.filter(e => (e.rate ? -e.amount : e.amount) > 0) const asks = entries.filter(e => (e.rate ? -e.amount : e.amount) < 0) return { bids, asks } } return arr.length === 4 ? raw ? { orderID: arr[0], period: arr[1], rate: arr[2], amount: arr[3] } : { rate: arr[0], period: arr[1], count: arr[2], amount: arr[3] } : raw ? { orderID: arr[0], price: arr[1], amount: arr[2] } : { price: arr[0], count: arr[1], amount: arr[2] } } } module.exports = OrderBook