bfx-api-node-models
Version:
Object models for usage with the Bitfinex node API
776 lines (683 loc) • 20.8 kB
JavaScript
'use strict'
const Promise = require('bluebird')
const _keys = require('lodash/keys')
const _isFinite = require('lodash/isFinite')
const _isString = require('lodash/isString')
const _isEmpty = require('lodash/isEmpty')
const _compact = require('lodash/compact')
const _flatten = require('lodash/flatten')
const _includes = require('lodash/includes')
const _isBoolean = require('lodash/isBoolean')
const _isUndefined = require('lodash/isUndefined')
const { prepareAmount, preparePrice } = require('bfx-api-node-util')
const numberValidator = require('./validators/number')
const dateValidator = require('./validators/date')
const amountValidator = require('./validators/amount')
const stringValidator = require('./validators/string')
const boolValidator = require('./validators/bool')
const priceValidator = require('./validators/price')
const symbolValidator = require('./validators/symbol')
const Model = require('./model')
const statuses = ['ACTIVE', 'EXECUTED', 'PARTIALLY FILLED', 'CANCELED']
const types = [
'MARKET', 'EXCHANGE MARKET', 'LIMIT', 'EXCHANGE LIMIT', 'STOP',
'EXCHANGE STOP', 'TRAILING STOP', 'EXCHANGE TRAILING STOP', 'FOK',
'EXCHANGE FOK', 'STOP LIMIT', 'EXCHANGE STOP LIMIT', 'IOC', 'EXCHANGE IOC'
]
const boolFields = ['notify']
const fields = {
id: 0,
gid: 1,
cid: 2,
symbol: 3,
mtsCreate: 4,
mtsUpdate: 5,
amount: 6,
amountOrig: 7,
type: 8,
typePrev: 9,
mtsTIF: 10,
// placeholder
// placeholder
flags: 12,
status: 13,
// placeholder
// placeholder
price: 16,
priceAvg: 17,
priceTrailing: 18,
priceAuxLimit: 19,
// placeholder
// placeholder
// placeholder
notify: 23,
hidden: 24,
placedId: 25,
// placeholder
// placeholder
routing: 28,
// placeholder
// placeholder
meta: 31
}
let lastCID = Date.now()
/**
* High level order model; provides methods for execution & can stay updated via
* a WSv2 connection or used to execute as a rest payload
*/
class Order extends Model {
/**
* @param {object|Array} data - order data
* @param {number} data.id - ID
* @param {number} data.gid - group ID
* @param {number} data.cid - client ID
* @param {string} data.symbol - symbol
* @param {number} data.mtsCreate - creation timestamp
* @param {number} data.mtsUpdate - last update timestamp
* @param {string} data.amount - remaining order amount
* @param {string} data.amountOrig - original/initial order amount
* @param {string} data.type - order type (i.e. 'EXCHANGE LIMIT')
* @param {string} data.typePrev - previous order type, if any
* @param {number} [data.mtsTIF] - TIF timestamp, if set
* @param {number} data.flags - order flags
* @param {string} data.status - current order status
* @param {string} data.price - order price
* @param {string} data.priceAvg - average execution price
* @param {string} [data.priceTrailing] - trailing distance for TRAILING STOP orders
* @param {string} [data.priceAuxLimit] - stop price for STOP LIMIT and OCO orders
* @param {number|boolean} [data.notify] - notify flag
* @param {number} [data.placedId] - placed ID
* @param {string} [data.affiliateCode] - affiliate code
* @param {number} [data.lev] - leverage
* @param {object} [apiInterface] - saved for a later call to registerListeners()
*/
constructor (data = {}, apiInterface) {
super({ data, fields, boolFields })
if (!this.flags) this.flags = 0
if (!_isUndefined(data.hidden)) this.setHidden(data.hidden)
if (!_isUndefined(data.visibleOnHit)) this.setVisibleOnHit(data.visibleOnHit)
if (!_isUndefined(data.postonly)) this.setPostOnly(data.postonly)
if (!_isUndefined(data.reduceonly)) this.setReduceOnly(data.reduceonly)
if (!_isUndefined(data.oco)) {
this.setOCO(data.oco, data.priceAuxLimit, data.cidOCO)
}
this.lev = data.lev
this.affiliateCode = data.affiliateCode
this._apiInterface = apiInterface
this._onWSOrderNew = this._onWSOrderNew.bind(this)
this._onWSOrderUpdate = this._onWSOrderUpdate.bind(this)
this._onWSOrderClose = this._onWSOrderClose.bind(this)
if (isNaN(this.amountOrig) && !isNaN(this.amount)) {
this.amountOrig = this.amount
}
this._lastAmount = this.amount
}
/**
* @param {object[]|object|Array[]|Array} data - data to convert to POJO
* @returns {object} pojo
*/
static unserialize (data) {
return super.unserialize({ data, fields, boolFields })
}
/**
* Returns a string representation of the order
*
* TODO: add verbose option to log all order information (TIF, etc)
*
* @returns {string} str
*/
toString () {
const {
type = '', symbol = '', amount, price, affiliateCode, priceAuxLimit,
amountOrig, status, id, cid
} = this
const market = `${symbol.substring(1, 4)}/${symbol.substring(4)}`
return _compact(_flatten([
id && `(id: ${id})`,
cid && `(cid: ${cid})`,
type.toUpperCase(),
'order on',
market,
!_isEmpty(status) && `(${status})`,
'for',
prepareAmount(amount),
(amountOrig !== amount) && `(${prepareAmount(amountOrig)})`,
'@',
/market/.test(type.toLowerCase()) ? 'MARKET' : preparePrice(price),
this.isHidden() && 'hidden',
this.isVisibleOnHit() && 'visible-on-hit',
this.isPostOnly() && 'post-only',
this.isReduceOnly() && 'reduce-only',
this.isPositionClose() && 'pos-close',
this.isOCO() && ['OCO', `(stop: ${priceAuxLimit})`],
!this.includesVariableRates() && 'No VRR',
affiliateCode && `[aff-code: ${affiliateCode}]`
])).join(' ')
}
/**
* @returns {boolean} oco
*/
isOCO () {
return !!(this.flags & Order.flags.OCO)
}
/**
* @returns {boolean} hidden
*/
isHidden () {
return !!(this.flags & Order.flags.HIDDEN)
}
/**
* @returns {boolean} visibleOnHit
*/
isVisibleOnHit () {
return !!(this.isHidden() && this.meta && this.meta.make_visible)
}
/**
* @returns {boolean} postonly
*/
isPostOnly () {
return !!(this.flags & Order.flags.POSTONLY)
}
/**
* @returns {boolean} includesVR
*/
includesVariableRates () {
return !(this.flags & Order.flags.NO_VR)
}
/**
* @returns {boolean} posclose
*/
isPositionClose () {
return !!(this.flags & Order.flags.POS_CLOSE)
}
/**
* @returns {boolean} reduceonly
*/
isReduceOnly () {
return !!(this.flags & Order.flags.REDUCE_ONLY)
}
/**
* Set the OCO flag and optionally update the stop price and OCO client ID.
*
* @param {boolean} v - flag value
* @param {number} [stopPrice] - optional, defaults to current value
* @param {number} [cidOCO] - optional, defaults to current value
* @returns {number} finalFlags
*/
setOCO (v, stopPrice = this.priceAuxLimit, cidOCO = this.cidOCO) {
if (v) {
this.priceAuxLimit = stopPrice
this.cidOCO = cidOCO
}
return this.modifyFlag(Order.flags.OCO, v)
}
/**
* Update the hidden flag value. If hidden and the order is inserted into
* the order book, it will not be shown to other users or available via the
* API.
*
* @param {boolean} v - flag value
* @returns {number} finalFlags
*/
setHidden (v) {
if (!v && this.meta) {
delete this.meta.make_visible
}
return this.modifyFlag(Order.flags.HIDDEN, v)
}
/**
* Update the meta object. The rest part of the hidden order will be visible
* after first hit(partial execution).
*
* @param {boolean} v - visibleOnHit value
* @returns {number} meta
*/
setVisibleOnHit (v) {
if (!this.isHidden() || !_isBoolean(v)) {
return
}
this.meta = {
...(this.meta || {}),
make_visible: +v
}
return this.meta
}
/**
* Update the post-only flag value. If post-only and the order would
* immediately fill, the order is automatically cancelled.
*
* @param {boolean} v - flag value
* @returns {number} finalFlags
*/
setPostOnly (v) {
return this.modifyFlag(Order.flags.POSTONLY, v)
}
/**
* Update the no-variable-rates flag value. Limits orders on margin from
* taking funding with variable rates.
*
* @param {boolean} v - flag value
* @returns {number} finalFlags
*/
setNoVariableRates (v) {
return this.modifyFlag(Order.flags.NO_VR, v)
}
/**
* Update the position-close flag value. If set, the order is cancelled if it
* would not close an open position.
*
* @param {boolean} v - flag value
* @returns {number} finalFlags
*/
setPositionClose (v) {
return this.modifyFlag(Order.flags.POS_CLOSE, v)
}
/**
* Update the reduce-only flag. If set and the order would open a new
* position, or increase the size of an existing position, it is
* automatically cancelled.
*
* @param {boolean} v - flag value
* @returns {number} finalFlags
*/
setReduceOnly (v) {
return this.modifyFlag(Order.flags.REDUCE_ONLY, v)
}
/**
* Updates a specific flag
*
* @param {number} flag - flag value
* @param {boolean} active - active status
* @returns {number} finalFlags
*/
modifyFlag (flag, active) {
if (!!(this.flags & flag) === active) {
return
}
this.flags += active ? flag : -flag
return this.flags
}
/**
* Send an order update packet to the WS server, and update local state. This
* updates the order atomically without changing its position in the queue for
* its price level.
*
* Rejects with an error if an attempt is made to apply a delta to a missing
* amount.
*
* @param {object} changes - changeset to apply to this order
* @param {WSv2|RESTv2} [apiInterface] - optional ws or rest, defaults to internal instance
* @returns {Promise} p - resolves on ws2 confirmation or rest response
* @example
* const ws = new WSv2({ ... })
*
* await ws.open()
* await ws.auth()
*
* const o = new Order({ ... }, ws)
*
* await o.submit()
* await o.update({ price: '2.0' }) // update price
* await o.update({ delta: '18.0' }) // update amount with delta
*
* console.log(o.toString())) // inspect order
*/
async update (changes = {}, apiInterface = this._apiInterface) {
if (!apiInterface) {
throw new Error('no ws client available')
}
const keys = Object.keys(changes)
// Apply change locally
keys.forEach(k => {
if (k === 'id') return
if (_includes(_keys(this._fields), k)) {
this[k] = changes[k]
} else if (k === 'price_trailing') {
this.priceTrailing = Number(changes[k])
} else if (k === 'price_oco_stop' || k === 'price_aux_limit') {
this.priceAuxLimit = Number(changes[k])
} else if (k === 'delta' && !Number.isNaN(+changes[k])) {
if (!Number.isNaN(+this.amount)) {
this.amount += Number(changes[k])
this._lastAmount = this.amount // update last amount for fill calcs
} else {
return Promise.reject(new Error('can\'t apply delta to missing amount'))
}
}
})
changes.id = this.id // tag with ID
if (changes.price) changes.price = preparePrice(changes.price)
if (changes.amount) changes.amount = prepareAmount(changes.amount)
if (changes.delta) changes.delta = prepareAmount(changes.delta)
if (changes.price_aux_limit) {
changes.price_aux_limit = preparePrice(changes.price_aux_limit)
}
if (changes.price_trailing) {
changes.price_trailing = preparePrice(changes.price_trailing)
}
return apiInterface.updateOrder(changes)
}
/**
* Returns a POJO that can be used as a preview order in Honey Framework
* algorithmic orders.
*
* @returns {object} preview
*/
toPreview () {
const prev = {
gid: this.gid,
cid: this.cid,
symbol: this.symbol,
amount: this.amount,
type: this.type,
price: this.price,
notify: this.notify,
flags: this.flags
}
if (!Number.isNaN(+this.lev)) {
prev.lev = +this.lev
}
return prev
}
/**
* Registers for updates/persistence on the specified ws2 instance.
*
* @param {object} apiInterface - optional, defaults to internal ws
*/
registerListeners (apiInterface = this._apiInterface) {
if (!apiInterface || apiInterface.constructor.name !== 'WSv2') return
const chanData = {
symbol: this.symbol,
cbGID: this.cbGID()
}
if (_isFinite(+this.id)) chanData.id = +this.id
if (_isFinite(+this.gid)) chanData.gid = +this.gid
if (_isFinite(+this.cid)) chanData.cid = +this.cid
apiInterface.onOrderNew(chanData, this._onWSOrderNew)
apiInterface.onOrderUpdate(chanData, this._onWSOrderUpdate)
apiInterface.onOrderClose(chanData, this._onWSOrderClose)
this._apiInterface = apiInterface
}
/**
* Removes update listeners from the specified ws2 instance.
* Will fail if rest interface is provided.
*
* @param {WSv2|RESTv2} apiInterface - optional ws defaults to internal ws
*/
removeListeners (apiInterface = this._apiInterface) {
if (apiInterface) {
apiInterface.removeListeners(this.cbGID())
}
}
/**
* Return the callback group ID for the order, used to bind listeners on
* an API interface.
*
* @returns {string} cbGID
*/
cbGID () {
return `${this.gid}.${this.cid}`
}
/**
* Submit the order
*
* @param {WSv2|Rest2} apiInterface - optional ws or rest, defaults to internal ws
* @returns {Promise} p
*/
async submit (apiInterface = this._apiInterface) {
if (!apiInterface) {
throw new Error('no API interface provided')
}
const orderArr = await apiInterface.submitOrder({ order: this })
Object.assign(this, Order.unserialize(orderArr))
return this
}
/**
* Cancel the order if open
*
* @param {WSv2|Rest2} apiInterface - optional ws or rest, defaults to internal ws
* @returns {Promise} p
*/
async cancel (apiInterface = this._apiInterface) {
if (!apiInterface) throw new Error('no API interface provided')
if (!this.id) throw new Error('order has no ID')
return apiInterface.cancelOrder({ id: this.id })
}
/**
* Equivalent to calling cancel() followed by submit()
*
* @param {WSv2|RESTv2} apiInterface - optional ws or rest, defaults to internal ws
* @returns {Promise} p
*/
async recreate (apiInterface = this._apiInterface) {
if (!apiInterface) throw new Error('no API interface provided')
if (!this.id) throw new Error('order has no ID')
await this.cancel(apiInterface)
this.id = null
return this.submit(apiInterface)
}
/**
* Updates order information from the provided order.
*
* @param {Order} order - order to update from
* @throws Error if the order ID/CID/GID do not match
*/
updateFrom (order = {}) {
const { id, gid, cid } = order
if (
(
(this.id && (+id !== +this.id)) && (this.cid && (+cid !== +this.cid))
) ||
(this.gid && (+gid !== +this.gid))
) {
throw new Error('order IDs do not match, cannot update from order')
}
this.id = order.id
this.amount = order.amount
this.status = order.status
this.mtsUpdate = order.mtsUpdate
this.priceAvg = order.priceAvg
}
/**
* Query the amount that was filled on the last order update
*
* @returns {number} amount
*/
getLastFillAmount () {
return this._lastAmount - this.amount
}
/**
* Resets the last amount, so getLastFillAmount() returns 0
*
* @see Order~getLastFillAmount
* @see Order~isPartiallyFilled
*/
resetFilledAmount () {
this._lastAmount = this.amount
}
/**
* Returns the base currency of the order.
*
* @returns {string} currency
*/
getBaseCurrency () {
return this.symbol.substring(1, 4)
}
/**
* Returns the quote currency of the order.
*
* @returns {string} currency
*/
getQuoteCurrency () {
return this.symbol.substring(4)
}
/**
* Returns the notional value of the order
*
* @returns {number} value
*/
getNotionalValue () {
return Math.abs(this.amount * this.price)
}
/**
* Indicates if the order is partially filled, based on the original and
* current amounts.
*
* @see Order~resetFilledAmount
*
* @returns {boolean} isPartiallyFilled
*/
isPartiallyFilled () {
const a = Math.abs(this.amount)
return a > 0 && a < Math.abs(this.amountOrig)
}
/**
* @param {Array} order - incoming order
* @private
*/
_onWSOrderUpdate (order) {
Object.assign(this, Order.unserialize(order))
this.emit('update', order, this)
}
/**
* @param {Array} order - incoming order
* @private
*/
_onWSOrderClose (order) {
Object.assign(this, Order.unserialize(order))
this.emit('update', order, this)
this.emit('close', order, this)
}
/**
* @param {Array} order - incoming order
* @private
*/
_onWSOrderNew (order) {
Object.assign(this, Order.unserialize(order))
this.emit('update', order, this)
this.emit('new', order, this)
}
/**
* Creates an order map that can be passed to the `on` command.
*
* @returns {object} o
* @example
* const ws = new WSv2({ apiKey: '...', apiSecret: '...' })
* await ws.open()
* await ws.auth()
*
* const o = new Order({
* type: 'MARKET'
* symbol: 'tLEOUSD',
* amount: -6,
* })
*
* ws.send([0, 'on', null, o.toNewOrderPacket()])
*/
toNewOrderPacket () {
const meta = { ...(this.meta || {}) }
if (_isString(this.affiliateCode) && !_isEmpty(this.affiliateCode)) {
meta.aff_code = this.affiliateCode // eslint-disable-line
}
const data = {
gid: this.gid,
cid: _isFinite(+this.cid) ? +this.cid : lastCID++,
symbol: this.symbol,
type: this.type,
amount: prepareAmount(+this.amount),
flags: this.flags || 0,
meta,
// optional, populated only for new orders; it is mtsTIF for existing orders
tif: this.tif
}
if (!Number.isNaN(+this.price)) {
data.price = preparePrice(+this.price)
}
if (!Number.isNaN(+this.lev)) {
data.lev = +this.lev
}
if (this.priceTrailing !== null && !Number.isNaN(+this.priceTrailing)) {
data.price_trailing = preparePrice(+this.priceTrailing)
}
if (this.priceAuxLimit !== null && !Number.isNaN(+this.priceAuxLimit)) {
if (this.flags & Order.flags.OCO) {
data.price_oco_stop = preparePrice(+this.priceAuxLimit)
data.cid_oco = this.cidOCO
} else {
data.price_aux_limit = preparePrice(+this.priceAuxLimit)
}
}
return data
}
/**
* Get the base currency for an order in WSv2 array format.
*
* @param {Array} arr - order in ws2 array format
* @returns {string} currency - base currency from symbol
*/
static getBaseCurrency (arr = []) {
return (arr[3] || '').substring(1, 4).toUpperCase()
}
/**
* Get the quote currency for an order in WSv2 array format.
*
* @param {Array} arr - order in ws2 array format
* @returns {string} currency - quote currency from symbol
*/
static getQuoteCurrency (arr = []) {
return (arr[3] || '').substring(4).toUpperCase()
}
/**
* Validates a given order instance
*
* @param {object[]|object|Order[]|Order|Array} data - instance to validate
* @returns {string} error - null if instance is valid
*/
static validate (data) {
return super.validate({
data,
fields,
validators: {
symbol: symbolValidator,
id: numberValidator,
gid: numberValidator,
cid: dateValidator,
mtsCreate: dateValidator,
mtsUpdate: dateValidator,
amount: amountValidator,
amountOrig: amountValidator,
type: v => stringValidator(v, Object.values(Order.type)),
typePrev: v => stringValidator(v, Object.values(Order.type)),
mtsTIF: dateValidator,
flags: numberValidator,
status: stringValidator,
price: priceValidator,
priceAvg: priceValidator,
priceTrailing: priceValidator,
priceAuxLimit: priceValidator,
notify: boolValidator,
hidden: boolValidator,
placedId: numberValidator
}
})
}
}
Order.type = {}
Order.status = {}
statuses.forEach((s) => {
Order.status[s] = s
Order.status[s.split(' ').join('_')] = s
})
types.forEach((t) => {
Order.type[t] = t
Order.type[t.split(' ').join('_')] = t
})
Order.flags = {
OCO: 2 ** 14, // 16384
POSTONLY: 2 ** 12, // 4096
HIDDEN: 2 ** 6, // 64
NO_VR: 2 ** 19, // 524288
POS_CLOSE: 2 ** 9, // 512
REDUCE_ONLY: 2 ** 10 // 1024
}
module.exports = Order