ao_host.js

'use strict'

const PI = require('p-iteration')
const _isFunction = require('lodash/isFunction')
const _isEmpty = require('lodash/isEmpty')
const _includes = require('lodash/includes')
const _get = require('lodash/get')
const debug = require('debug')('bfx:hf:algo:ao-host')

const WsAdapter = require('./ws_adapter')
const AsyncEventEmitter = require('./async_event_emitter')
const onMinimumSizeError = require('./host/events/minimum_size_error')
const onInsufficientBalanceError = require('./host/events/insufficient_balance')
const onSubmitAllOrders = require('./host/events/submit_all_orders')
const onCancelAllOrders = require('./host/events/cancel_all_orders')
const onUpdateState = require('./host/events/update_state')
const onAssignChannel = require('./host/events/assign_channel')
const onNotify = require('./host/events/notify')
const onStop = require('./host/events/stop')
const withAOUpdate = require('./host/with_ao_update')
const bindWS2Bus = require('./host/ws2/bind_bus')
const initAO = require('./host/init_ao')
const genHelpers = require('./host/gen_helpers')

/**
 * @typedef {object} EventMetaInformation
 * @property {object} chanFilter - source channel filter
 * @property {string} chanFilter.symbol - source channel symbol
 */

/**
 * @typedef {object} AOUIDefinition
 * @property {string} label - name of the order to be shown to the user
 * @property {string} id - internal algorithmic order ID
 * @property {string} [uiIcon] - CSS classname of the icon to show
 * @property {string} [customHelp] - documentation
 * @property {number} connectionTimeout - how long to wait before considering
 *   the HF disconnected
 * @property {number} actionTimeout - how long to wait for action confirmatio
 *   before considering the HF disconnected
 * @property {object} [header] - rendered at the top of the form
 * @property {string} [header.component] - component to use for the header
 * @property {string[]} [header.fields] - array of field names to render in
 *   header
 * @property {object[]} sections - the layout definition itself
 * @property {string} sections[].title - rendered above the section
 * @property {string} sections[].name - unique internal ID for the section
 * @property {string[][]} sections[].rows - array of rows of field IDs to
 *   render in the section, two per row.
 * @property {object} fields - field definitions, key'd by ID
 * @property {string[]} actions - array of action names, maximum 2
 */

/**
 * The AOHost class provides a wrapper around the algo order system, and
 * manages lifetime events/order execution. Internally it hosts a Manager
 * instance from bfx-api-node-core for communication with the Bitfinex API, and
 * listens for websocket stream events in order to update order state/trigger
 * algo order events.
 *
 * Execution is handled by an event system, with events being triggered by
 * Bitfinex API websocket stream payloads, and the algo orders themselves.
 *
 * To start/stop algo orders, `gid = startAO(id, args)` and `stopAO(gid)`
 * methods are provided, with the generated group ID (`gid`) being the same as
 * that used for all atomic orders created by the individual algo orders.
 */
class AOHost extends AsyncEventEmitter {
  /**
   * @param {object} [args] - arguments
   * @param {object} [args.db] - optional
   * @param {string} [args.wsURL] - wss://api.bitfinex.com/ws/2
   * @param {string} [args.restURL] - https://api.bitfinex.com
   * @param {object} [args.agent] - optional proxy agent
   * @param {object[]} [args.aos] - algo orders to manage
   * @param {number} [args.dms] - dead man switch, active 4 (default)
   */
  constructor (args = {}) {
    super()

    const { aos, wsSettings } = args

    this.aos = aos
    this.adapter = new WsAdapter(wsSettings)

    this.instances = {}

    this.onAOStart = this.onAOStart.bind(this)
    this.onAOStop = this.onAOStop.bind(this)
    this.onAOPersist = this.onAOPersist.bind(this)
    this.loadAO = this.loadAO.bind(this)
    this.triggerAOEvent = this.triggerAOEvent.bind(this)
    this.triggerGlobalEvent = this.triggerGlobalEvent.bind(this)
    this.triggerOrderEvent = this.triggerOrderEvent.bind(this)

    this.adapter.on('meta:error', this.onMetaError.bind(this))
    this.adapter.on('data:ticker', this.onDataTicker.bind(this))
    this.adapter.on('data:trades', this.onDataTrades.bind(this))
    this.adapter.on('data:candles', this.onDataCandles.bind(this))
    this.adapter.on('data:book', this.onDataBook.bind(this))
    this.adapter.on('data:managed:book', this.onDataManagedBook.bind(this))
    this.adapter.on('data:managed:candles', this.onDataManagedCandles.bind(this))
    this.adapter.on('data:notification', this.onDataNotification.bind(this))
    this.adapter.on('meta:reload', this.onMetaReload.bind(this))
    this.adapter.on('meta:connection:update', this.onMetaConnectionUpdate.bind(this))

    this.on('ao:start', this.onAOStart)
    this.on('ao:stop', this.onAOStop)
    this.on('ao:persist', this.onAOPersist)

    bindWS2Bus(this)

    this.adapter.once('order:snapshot', (snapshot) => {
      this.orderSnapshot = snapshot
      this.emit('ready')
    })
  }

  /**
   * Get configured exchange adapter
   *
   * @returns {object} adapter
   */
  getAdapter () {
    return this.adapter
  }

  /**
   * Disconnect & reconnect the exchange adapter
   */
  reconnect () {
    this.adapter.reconnect()
  }

  /**
   * Close the exchange adapter connection.
   *
   * @returns {Promise} p - resolves on connection close
   */
  close () {
    return this.adapter.disconnect()
  }

  /**
   * @param {Error} error - error from incoming event
   * @private
   */
  onMetaError (error) {
    this.emit('error', error)
  }

  /**
   * Update internal connection when the adpater applies an update
   *
   * @param {number} i - connection ID
   * @param {object} c - new connection object
   * @private
   */
  onMetaConnectionUpdate (i, c) {
    Object.values(this.instances).forEach((instance = {}) => {
      const { connection } = instance.state
      const { id } = connection

      if (id === i) {
        connection.c = c
      }
    })
  }

  onMetaReload () {
    Object.values(this.instances).forEach((instance = {}) => {
      const { state = {} } = instance
      state.ev.removeAllListeners()
    })
    this.instances = {}
    this.emit('meta:reload')
  }

  /**
   * Opens a new socket connection on the internal adapter
   */
  connect () {
    this.adapter.connect()
  }

  /**
   * Fetch configured algorithmic orders
   *
   * @returns {Array} aos
   */
  getAOs () {
    return Object.values(this.aos)
  }

  /**
   * Returns the algo order definition identified by the provided ID
   *
   * @param {string} id - i.e. bfx-iceberg
   * @returns {object} aoDef
   */
  getAO (id) {
    return Object.values(this.aos).find(ao => ao.id === id)
  }

  /**
   * Returns the active AO instance state identified by the provided GID
   *
   * @param {string} gid - algo order group ID
   * @returns {object} state - algo order state
   */
  getAOInstance (gid) {
    return this.instances[gid]
  }

  /**
   * Returns an array of all running algo order instances
   *
   * @returns {object[]} aoInstances
   */
  getAOInstances () {
    return Object.values(this.instances)
  }

  /**
   * Propagates notifications
   *
   * @param {object} notification - model
   * @param {object} meta - routing information
   * @private
   */
  onDataNotification (notification, meta = {}) {
    /**
     * Triggered when a notification is received.
     *
     * @event AOHost~dataNotification
     * @param {Array[]} notification - incoming notification data
     * @param {EventMetaInformation} meta - source channel information
     */
    this.triggerGlobalEvent('data', 'notification', notification, meta)
    this.emit('notification', notification)
  }

  /**
   * Propagates tickers
   *
   * @param {object} ticker - model
   * @param {object} meta - routing information
   * @private
   */
  onDataTicker (ticker, meta = {}) {
    /**
     * Triggered when a ticker is received.
     *
     * @event AOHost~dataTicker
     * @param {Array[]} ticker - incoming ticker data
     * @param {EventMetaInformation} meta - source channel information
     */
    this.triggerGlobalEvent('data', 'ticker', ticker, meta)
  }

  /**
   * Propagates trades
   *
   * @param {object[]} trades - models
   * @param {object} meta - routing information
   * @private
   */
  onDataTrades (trades, meta = {}) {
    /**
     * Triggered when a trade snapshot or single trade is received
     *
     * @event AOHost~dataTrades
     * @param {Array[]} update - incoming snapshot or single trade
     * @param {EventMetaInformation} meta - source channel information
     */
    this.triggerGlobalEvent('data', 'trades', trades, meta)
  }

  /**
   * Propagates candles
   *
   * @param {object[]} candles - models
   * @param {object} meta - routing information
   * @private
   */
  onDataCandles (candles, meta = {}) {
    /**
     * Triggered when a candle snapshot or individual candle is received.
     *
     * @event AOHost~dataCandles
     * @param {Array[]} update - incoming snapshot or single candle
     * @param {EventMetaInformation} meta - source channel information
     */
    this.triggerGlobalEvent('data', 'candles', candles, meta)
  }

  /**
   * Propagates order books
   *
   * @param {object} update - partial or full order book snapshot
   * @param {object} meta - routing information
   * @private
   */
  onDataBook (update, meta = {}) {
    /**
     * Triggered when an order book update is received.
     *
     * @event AOHost~dataBook
     * @param {Array[]} update - incoming snapshot or price level
     * @param {EventMetaInformation} meta - source channel information
     */
    this.triggerGlobalEvent('data', 'book', update, meta)
  }

  /**
   * Propagates full managed order books
   *
   * @param {object} book - full managed order book
   * @param {object} meta - routing information
   * @private
   * @fires AOHost~dataManagedBook
   */
  onDataManagedBook (book, meta = {}) {
    /**
     * Triggered when an order book update is received, and an internally
     * managed order book instance is updated. The entire order book is passed
     * to the event listeners.
     *
     * @event AOHost~dataManagedBook
     * @param {object} book - full order boook
     * @param {EventMetaInformation} meta - source channel information
     */
    this.triggerGlobalEvent('data', 'managedBook', book, meta)
  }

  /**
   * Propagates full candle sets
   *
   * @param {object[]} candles - full candle set
   * @param {object} meta - routing information
   * @private
   */
  onDataManagedCandles (candles, meta = {}) {
    /**
     * Triggered when a candle update is received, and an internally managed
     * candle dataset is updated. The entire dataset is passed to the event
     * listeners.
     *
     * @event AOHost~dataManagedCandles
     * @param {object[]} candles - full dataset
     * @param {EventMetaInformation} meta - source channel information
     */
    this.triggerGlobalEvent('data', 'managedCandles', candles, meta)
  }

  /**
   * Loads and starts a single algo order, with the provided serialized state
   *
   * @param {string} id - algo order definition ID
   * @param {string} gid - algo order instance group ID
   * @param {object} loadedState - algo order instance state
   * @returns {string} gid
   * @private
   */
  async loadAO (id, gid, loadedState = {}) {
    const ao = this.getAO(id)

    if (!ao) {
      throw new Error(`unknown algo order: ${id}`)
    }

    const { meta = {} } = ao
    const { unserialize } = meta

    const state = _isFunction(unserialize)
      ? unserialize(loadedState)
      : { ...loadedState }

    state.id = id
    state.gid = gid
    state.channels = []
    state.orders = {}
    state.cancelledOrders = {}
    state.allOrders = {}
    state.ev = new AsyncEventEmitter()

    const h = genHelpers(state, this.adapter)

    /**
     * @typedef {object} AOInstance
     * @property {object} state - instance state used during execution
     * @property {number} state.id - ID of the instance
     * @property {number} state.gid - ID of the order group, attached to all
     *   orders
     * @property {Array} state.channels - subscribed channels and their filters
     * @property {object} state.orders - map of open orders key'd by client ID
     * @property {object} state.cancelledOrders - map of cancelled orders key'd
     *   by client ID
     * @property {object} state.allOrders - map of all orders ever created by
     *   the instance key'd by client ID
     * @property {AsyncEventEmitter} state.ev - internal event emitter
     * @property {module:Helpers} h - helpers bound to the instance
     */
    const inst = { state, h }

    await this.bootstrapAO(ao, inst)
    return this.emit('ao:loaded', gid)
  }

  /**
   * Creates and starts a new algo order instance, based on the AO def
   * identified by the supplied ID
   *
   * @param {string} id - algo order definition ID, i.e. bfx-iceberg
   * @param {object} args - algo order arguments/parameters
   * @param {Function} [gidCB] - callback to acquire GID prior to ao:start
   * @returns {string} gid - instance GID
   */
  async startAO (id, args = {}, gidCB) {
    const ao = this.getAO(id)

    if (!ao) {
      throw new Error(`unknown algo order: ${id}`)
    }

    const inst = initAO(this.adapter, ao, args)

    return this.bootstrapAO(ao, inst, gidCB)
  }

  /**
   * Prepares the provided algo order instance for execution, saves it
   * internally for execution tracking, and starts it. Hooks up event listeners
   * and executes `declareEvents` and `declareChannels` on the instance. Emits
   * the 'ao:start' event.
   *
   * @param {object} ao - base algo order definition
   * @param {object} instance - new algo order to be started
   * @param {Function} [gidCB] - callback to acquire GID prior to ao:start
   * @returns {string} gid - new instance GID
   * @private
   */
  async bootstrapAO (ao, instance = {}, gidCB) {
    const { state } = instance
    const { gid } = state

    state.connection = this.adapter.getConnection()

    this.instances[gid] = instance

    state.ev.on('channel:assign', onAssignChannel.bind(null, this))
    state.ev.on('state:update', onUpdateState.bind(null, this))
    state.ev.on('notify', onNotify.bind(null, this))
    state.ev.on('error:minimum_size', onMinimumSizeError.bind(null, this))
    state.ev.on('error:insufficient_balance', onInsufficientBalanceError.bind(null, this))
    state.ev.on('exec:order:submit:all', onSubmitAllOrders.bind(null, this))
    state.ev.on('exec:order:cancel:all', onCancelAllOrders.bind(null, this))
    state.ev.on('exec:stop', onStop.bind(null, this, gid))

    const { declareEvents, declareChannels } = ao.meta || {}

    if (_isFunction(declareEvents)) {
      await declareEvents(this.instances[gid], this)
    }

    if (_isFunction(declareChannels)) {
      await declareChannels(this.instances[gid], this)
    }

    // Cancel existing orders
    for (let i = 0; i < this.orderSnapshot.length; i += 1) {
      if (this.orderSnapshot[i].gid === +gid) {
        await this.adapter.cancelOrderWithDelay(
          state.connection, 0, this.orderSnapshot[i]
        )
      }
    }

    if (_isFunction(gidCB)) {
      await gidCB(gid)
    }

    await this.emit('ao:start', this.instances[gid])

    return gid
  }

  /**
   * Stops an algo order instance by GID
   *
   * @param {string} gid - algo order instance GID
   */
  async stopAO (gid) {
    const instance = this.instances[gid]

    if (!instance) {
      throw new Error(`unknown AO: ${gid}`)
    }

    await this.emit('ao:stop', instance)
  }

  /**
   * Triggers a 'self' event on an algo order instance with the provided
   * arguments
   *
   * @param {object} instance - algo order instance to operate on
   * @param {string} eventName - name of event to trigger
   * @param {...any} args - event arguments
   * @returns {Promise} p - resolves when all handlers complete
   * @private
   */
  onAOSelfEvent (instance, eventName, ...args) {
    return this.triggerAOEvent(instance, 'self', eventName, ...args)
  }

  /**
   * @returns {boolean} aosRunning - true if any algo order is currently running
   */
  aosRunning () {
    return Object.values(this.instances).find((instance) => {
      const { state } = instance
      const { active } = state
      return active
    })
  }

  /**
   * Handles init for an algo order instance; sets the 'active' flag, subscribes
   * to required channels, and triggers the life.start event.
   *
   * @param {object} instance - algo order instance that has started
   * @private
   */
  async onAOStart (instance = {}) {
    const { channels = [], gid, connection } = instance.state

    await withAOUpdate(this, gid, (instance = {}) => {
      const { state = {} } = instance

      return {
        ...state,
        active: true
      }
    })

    if (!_isEmpty(channels)) {
      channels.forEach(ch => {
        debug('subscribing to channel %j [AO gid %d]', ch, gid)

        this.adapter.subscribe(connection, ch.channel, ch.filter)
      })
    }

    /**
     * Triggered when an algorithmic order begins execution.
     *
     * @event AOHost~lifeStart
     */
    await this.triggerAOEvent(instance, 'life', 'start')
    await this.emit('ao:persist', gid)
  }

  /**
   * Handles algo order teardown; disables the 'active' state flag, unsubscribes
   * from channels, emits the life.stop event, and saves the AO instance.
   *
   * @param {object} instance - algo order instance to operate on
   * @private
   */
  async onAOStop (instance = {}) {
    const { h } = instance
    const { channels = [], gid, connection } = instance.state

    h.clearAllTimeouts()

    await withAOUpdate(this, gid, (instance = {}) => {
      const { state = {} } = instance

      return {
        ...state,
        active: false
      }
    })

    if (!_isEmpty(channels)) {
      channels.forEach(ch => {
        debug('unsubscribing from channel %s [AO gid %d]', ch.channel, gid)
        this.adapter.unsubscribe(connection, ch.channel, ch.filter)
      })
    }

    /**
     * Triggered when an algorithmic order ends execution.
     *
     * @event AOHost~lifeStop
     */
    await this.triggerAOEvent(instance, 'life', 'stop')
    await this.emit('ao:persist', gid)
  }

  /**
   * Serializes & saves an algo order instance state to the DB
   *
   * @param {string} gid - GID of algo order instance to persist
   * @private
   */
  async onAOPersist (gid) {
    const instance = this.instances[gid]

    if (!instance) {
      return
    }

    const { state = {} } = instance
    const { id } = state
    const ao = this.getAO(id)

    if (!ao) {
      throw new Error(`can\t persist unknown ao: ${id}`)
    }

    const { meta = {} } = ao
    const { serialize } = meta

    if (!serialize) {
      debug('can\t save AO %s [%s] due to missing serialize method', gid, id)
      return
    }

    await this.emit('ao:persist:db:update', {
      gid,
      algoID: id,
      state: JSON.stringify(serialize(state)),
      active: state.active
    })

    debug('saved AO %s', gid)
  }

  /**
   * Passes event to the AO instances that know the order
   *
   * @param {string} section - name of section to trigger event on
   * @param {string} eventName - name of event to trigger
   * @param {object} order - order instance to pass to event handler
   * @private
   */
  async triggerOrderEvent (section, eventName, order) {
    if (!this.adapter.orderEventsValid()) {
      return
    }

    const instances = Object.values(this.instances)

    await PI.forEach(instances, async (instance) => {
      const { state = {} } = instance
      const { orders = {}, allOrders = {}, cancelledOrders = {}, id, gid } = state
      const cids = Object.keys(allOrders)
      const cancelledCIds = Object.keys(cancelledOrders)
      const ocid = order.cid + ''

      // Note that we avoid triggering order_cancel for orders cancelled by us.
      // order_cancel is meant to trigger after a user UI interaction
      if (
        _includes(cids, ocid) && // tracked (known) order
        (
          (eventName !== 'order_cancel' && eventName !== 'order_error') ||
          !_includes(cancelledCIds, ocid) // or not canceled by us
        )
      ) {
        debug(
          'triggering order event %s:%s for AO %s [gid %s, o cid %s, %f @ %f %s]',
          section, eventName, id, gid, order.cid, order.amountOrig, order.price,
          order.status
        )

        if (orders[ocid]) {
          orders[ocid].updateFrom(order)
        }

        if (allOrders[ocid]) {
          allOrders[ocid].updateFrom(order)
        }

        if (cancelledOrders[ocid]) {
          cancelledOrders[ocid].updateFrom(order)
        }

        await this.triggerAOEvent(
          instance,
          section,
          eventName,
          allOrders[ocid]
        )
      }
    })
  }

  /**
   * Triggers an event with the supplied arguments on all active algo order
   * instances.
   *
   * @param {string} section - name of section to trigger event on
   * @param {string} eventName - name of event to trigger
   * @param  {...any} args - event arguments
   * @returns {Promise} p - resolves when all handlers complete
   * @private
   */
  async triggerGlobalEvent (section, eventName, ...args) {
    const instances = Object.values(this.instances)

    return PI.forEach(instances, async (instance) => (
      this.triggerAOEvent(instance, section, eventName, ...args)
    ))
  }

  /**
   * Triggers an event on an algo order instance
   *
   * @param {object} instance - algo order instance to operate on
   * @param {string} section - name of section to trigger event on
   * @param {string} eventName - name of event to trigger
   * @param  {...any} args - event arguments
   * @returns {Promise} p - resolves when all handlers complete
   * @private
   */
  async triggerAOEvent (instance, section, eventName, ...args) {
    const { state } = instance
    const { id, gid, ev } = state
    const ao = this.getAO(id)
    const sectionHandlers = (ao.events || {})[section]
    const handler = _get((sectionHandlers || {}), eventName)

    if (!_isFunction(handler)) {
      if (section === 'self') {
        debug('error: unknown handler %s:%s', section, eventName)
      }

      return
    }

    debug(
      'triggering %s:%s for AO %s [gid %s]',
      section, eventName, id, gid
    )

    await handler(instance, ...args)

    return ev.emit(`internal:${section}:${eventName}`, ...args)
  }
}

/**
 * How long orders are allowed to settle for before teardown in ms.
 *
 * @type {number}
 * @default 10000
 */
AOHost.TEARDOWN_GRACE_PERIOD_MS = 1 * 1000

module.exports = AOHost