diff --git a/index.js b/index.js index 47592dbe..fa696241 100644 --- a/index.js +++ b/index.js @@ -7,5 +7,6 @@ module.exports = { AccumulateDistribute: require('./lib/accumulate_distribute'), PingPong: require('./lib/ping_pong'), MACrossover: require('./lib/ma_crossover'), - NoDataError: require('./lib/errors/no_data') + NoDataError: require('./lib/errors/no_data'), + TriangularArbitrage: require('./lib/triangular_arbitrage') } diff --git a/lib/triangular_arbitrage/events/data_managed_book.js b/lib/triangular_arbitrage/events/data_managed_book.js new file mode 100644 index 00000000..485b410a --- /dev/null +++ b/lib/triangular_arbitrage/events/data_managed_book.js @@ -0,0 +1,30 @@ +'use strict' + +const hasOBTarget = require('../util/has_ob_target') +const trySubmitOrder = require('../util/try_submit_order') + +module.exports = async (instance = {}, book, meta) => { + const { state = {}, h = {} } = instance + const { args = {}, lastBook = {} } = state + const { symbol1, symbol2, symbol3 } = args + const { debug, updateState } = h + const { chanFilter } = meta + const chanSymbol = chanFilter.symbol + + if (!hasOBTarget(args)) { + return + } + + if (![symbol1, symbol2, symbol3].includes(chanSymbol)) { + return + } + + debug('recv updated order book for %s', chanSymbol) + + lastBook[chanSymbol] = book + await updateState(instance, { + lastBook: lastBook + }) + + trySubmitOrder(instance) +} diff --git a/lib/triangular_arbitrage/events/life_start.js b/lib/triangular_arbitrage/events/life_start.js new file mode 100644 index 00000000..905748d1 --- /dev/null +++ b/lib/triangular_arbitrage/events/life_start.js @@ -0,0 +1,13 @@ +'use strict' + +const trySubmitOrder = require('../util/try_submit_order') + +module.exports = async (instance = {}) => { + const { state = {}, h = {} } = instance + const { debug } = h + const { args = {} } = state + const { symbol1, symbol2, symbol3 } = args + + debug(`Triangular ${symbol1}->${symbol2}->${symbol3} arbitrage complete`) + trySubmitOrder(instance) +} diff --git a/lib/triangular_arbitrage/events/life_stop.js b/lib/triangular_arbitrage/events/life_stop.js new file mode 100644 index 00000000..4fe51f0c --- /dev/null +++ b/lib/triangular_arbitrage/events/life_stop.js @@ -0,0 +1,10 @@ +'use strict' + +module.exports = async (instance = {}) => { + const { state = {}, h = {} } = instance + const { args = {}, orders = {}, gid } = state + const { emit } = h + const { cancelDelay } = args + + await emit('exec:order:cancel:all', gid, orders, cancelDelay) +} diff --git a/lib/triangular_arbitrage/events/orders_order_cancel.js b/lib/triangular_arbitrage/events/orders_order_cancel.js new file mode 100644 index 00000000..4b27d986 --- /dev/null +++ b/lib/triangular_arbitrage/events/orders_order_cancel.js @@ -0,0 +1,13 @@ +'use strict' + +module.exports = async (instance = {}, order) => { + const { state = {}, h = {} } = instance + const { args = {}, orders = {}, gid } = state + const { emit, debug } = h + const { cancelDelay } = args + + debug('detected atomic cancelation, stopping...') + + await emit('exec:order:cancel:all', gid, orders, cancelDelay) + await emit('exec:stop') +} diff --git a/lib/triangular_arbitrage/events/orders_order_fill.js b/lib/triangular_arbitrage/events/orders_order_fill.js new file mode 100644 index 00000000..92478cd0 --- /dev/null +++ b/lib/triangular_arbitrage/events/orders_order_fill.js @@ -0,0 +1,7 @@ +'use strict' + +const trySubmitOrder = require('../util/try_submit_order') + +module.exports = async (instance = {}, order) => { + trySubmitOrder(instance) +} diff --git a/lib/triangular_arbitrage/index.js b/lib/triangular_arbitrage/index.js new file mode 100644 index 00000000..e0eaaf8b --- /dev/null +++ b/lib/triangular_arbitrage/index.js @@ -0,0 +1,72 @@ +'use strict' + +// meta +const defineAlgoOrder = require('../define_algo_order') +const validateParams = require('./meta/validate_params') +const genPreview = require('./meta/gen_preview') +const processParams = require('./meta/process_params') +const initState = require('./meta/init_state') +const getUIDef = require('./meta/get_ui_def') +const serialize = require('./meta/serialize') +const unserialize = require('./meta/unserialize') +const genOrderLabel = require('./meta/gen_order_label') + +// events +const onLifeStart = require('./events/life_start') +const onLifeStop = require('./events/life_stop') +const onOrdersOrderFill = require('./events/orders_order_fill') +const onOrdersOrderCancel = require('./events/orders_order_cancel') +const onDataManagedBook = require('./events/data_managed_book') + +/** + * Triangular arbitrage attempts to profit from the small differences in price + * between multiple markets. It submits a series of synchronous orders that + * execute on 3 different markets and end up back to the starting symbol thus + * creating a triangle pattern. For example: + * EOS:BTC (buy) -> EOS:ETH (sell) -> ETH:BTC (sell) + * + * Once the EOS:BTC buy order fills then a new order is executed on EOS:ETH to sell the EOS + * and finally, once that order is filled a sell order is placed on the ETH:BTC market in order + * to complete the full cycle back to BTC. + * + * The user is able to specify whether the orders execute as a taker or a maker by selecting + * the order types 'MARKET', 'BEST ASK' or 'BEST BID' + * + * @name PingPong + * @param {boolean} limit - if enabled all orders will be placed at best bid/ask + * @param {string} symbol1 - starting market + * @param {string} symbol2 - intermediate market + * @param {string} symbol3 - final market + * @param {number} amount - order size + */ +module.exports = defineAlgoOrder({ + id: 'bfx-triangular_arbitrage', + name: 'Triangular Arbitrage', + + meta: { + genOrderLabel, + validateParams, + processParams, + genPreview, + initState, + getUIDef, + serialize, + unserialize + }, + + events: { + life: { + start: onLifeStart, + stop: onLifeStop + }, + + orders: { + order_fill: onOrdersOrderFill, + order_cancel: onOrdersOrderCancel + }, + + data: { + managedBook: onDataManagedBook + } + } +}) diff --git a/lib/triangular_arbitrage/meta/declare_channels.js b/lib/triangular_arbitrage/meta/declare_channels.js new file mode 100644 index 00000000..d30b4aad --- /dev/null +++ b/lib/triangular_arbitrage/meta/declare_channels.js @@ -0,0 +1,34 @@ +'use strict' + +const LIMIT_TYPES = ['BEST_ASK', 'BEST_BID'] + +module.exports = async (instance = {}, host) => { + const { h = {}, state = {} } = instance + const { args = {} } = state + const { symbol1, symbol2, symbol3, orderType1, orderType2, orderType3 } = args + const { declareChannel } = h + const len = 5 + const prec = 'R0' + + if (LIMIT_TYPES.includes(orderType1)) { + await declareChannel(instance, host, 'book', { + symbol: symbol1, + prec, + len + }) + } + if (LIMIT_TYPES.includes(orderType2)) { + await declareChannel(instance, host, 'book', { + symbol: symbol2, + prec, + len + }) + } + if (LIMIT_TYPES.includes(orderType3)) { + await declareChannel(instance, host, 'book', { + symbol: symbol3, + prec, + len + }) + } +} diff --git a/lib/triangular_arbitrage/meta/declare_events.js b/lib/triangular_arbitrage/meta/declare_events.js new file mode 100644 index 00000000..467748f5 --- /dev/null +++ b/lib/triangular_arbitrage/meta/declare_events.js @@ -0,0 +1,8 @@ +'use strict' + +module.exports = (instance = {}, host) => { + const { h = {} } = instance + const { declareEvent } = h + + declareEvent(instance, host, 'self:submit_order', 'submit_order') +} diff --git a/lib/triangular_arbitrage/meta/gen_order_label.js b/lib/triangular_arbitrage/meta/gen_order_label.js new file mode 100644 index 00000000..153f9342 --- /dev/null +++ b/lib/triangular_arbitrage/meta/gen_order_label.js @@ -0,0 +1,12 @@ +'use strict' + +module.exports = (state = {}) => { + const { args = {} } = state + const { orders, limit } = args + // TODO add proper table + return ['Triangular Arbitrage'].concat( + orders.map((o) => { + return ` | ${o.symbol} ${o.amount} @ ${o.price || o.type}` + }) + ).join('') +} diff --git a/lib/triangular_arbitrage/meta/gen_preview.js b/lib/triangular_arbitrage/meta/gen_preview.js new file mode 100644 index 00000000..754902b6 --- /dev/null +++ b/lib/triangular_arbitrage/meta/gen_preview.js @@ -0,0 +1,5 @@ +'use strict' + +module.exports = (args = {}) => { + return [] +} diff --git a/lib/triangular_arbitrage/meta/get_ui_def.js b/lib/triangular_arbitrage/meta/get_ui_def.js new file mode 100644 index 00000000..56473c5c --- /dev/null +++ b/lib/triangular_arbitrage/meta/get_ui_def.js @@ -0,0 +1,126 @@ +'use strict' + +module.exports = () => ({ + id: 'bfx-triangular_arbitrage', + label: 'Triangular Arbitrage', + + uiIcon: 'triangle-arbitrage-active', + customHelp: 'Trangular Arbitrage synchronously exchanges between 3 different markets to create full round trip back to the original starting currency.\n\nOrders will be submitted synchronously until the round trip is complete.', + connectionTimeout: 10000, + actionTimeout: 10000, + + header: { + component: 'ui.checkbox_group', + fields: ['hidden'] + }, + + sections: [{ + title: '', + name: 'general', + rows: [ + ['action', null], + ['amount', 'orderType1'], + ['intermediateCcy', 'orderType2'], + [null, 'orderType3'], + ['submitDelaySec', 'cancelDelaySec'] + ] + }, { + title: '', + name: 'lev', + fullWidth: true, + rows: [ + ['lev'] + ], + + visible: { + _context: { eq: 'f' } + } + }], + + fields: { + hidden: { + component: 'input.checkbox', + label: 'HIDDEN', + default: false, + help: 'trading.hideorder_tooltip' + }, + + submitDelaySec: { + component: 'input.number', + label: 'Submit Delay (sec)', + customHelp: 'Seconds to wait before submitting orders', + default: 1 + }, + + cancelDelaySec: { + component: 'input.number', + label: 'Cancel Delay (sec)', + customHelp: 'Seconds to wait before cancelling orders', + default: 0 + }, + + amount: { + component: 'input.amount', + label: 'Amount $BASE', + customHelp: 'Starting amount' + }, + + intermediateCcy: { + component: 'input.string', + label: 'Intermediate Currency', + customHelp: 'The intermediate market XXX:$BASE', + default: 'ETH' + }, + + lev: { + component: 'input.range', + label: 'Leverage', + min: 1, + max: 100, + default: 10 + }, + + orderType1: { + component: 'input.dropdown', + label: 'Order Type', + default: 'LIMIT', + options: { + MARKET: 'Market', + BEST_BID: 'Best Bid', + BEST_ASK: 'Best Ask' + } + }, + + orderType2: { + component: 'input.dropdown', + label: 'Order Type', + default: 'LIMIT', + options: { + MARKET: 'Market', + BEST_BID: 'Best Bid', + BEST_ASK: 'Best Ask' + } + }, + + orderType3: { + component: 'input.dropdown', + label: 'Order Type', + default: 'LIMIT', + options: { + MARKET: 'Market', + BEST_BID: 'Best Bid', + BEST_ASK: 'Best Ask' + } + } + }, + + action: { + component: 'input.radio', + label: 'Action', + options: ['Buy', 'Sell'], + inline: true, + default: 'Buy' + }, + + actions: ['preview', 'submit'] +}) diff --git a/lib/triangular_arbitrage/meta/init_state.js b/lib/triangular_arbitrage/meta/init_state.js new file mode 100644 index 00000000..a9f13d9d --- /dev/null +++ b/lib/triangular_arbitrage/meta/init_state.js @@ -0,0 +1,5 @@ +'use strict' + +module.exports = (args = {}) => { + return { args } +} diff --git a/lib/triangular_arbitrage/meta/process_params.js b/lib/triangular_arbitrage/meta/process_params.js new file mode 100644 index 00000000..6d40db2e --- /dev/null +++ b/lib/triangular_arbitrage/meta/process_params.js @@ -0,0 +1,52 @@ +'use strict' + +const { getRoundTripSymbols } = require('../util/symbols') +const _isFinite = require('lodash/isFinite') + +module.exports = (data) => { + const params = { ...data } + + if (params._symbol) { + params.symbol1 = params._symbol.replace('t', '') + delete params._symbol + } + + if (params.action) { + if (params.action === 'Sell') { + params.amount = Number(params.amount) * -1 + } else { + params.amount = Number(params.amount) + } + delete params.action + } + + // compile ending symbol using + // starting currency and intermediate market + const rawSymbol = params.symbol1 + const midCcy = params.intermediateCcy + + const isBuy = params.amount > 0 + const { interMarket, finalMarket } = getRoundTripSymbols(rawSymbol, midCcy, isBuy) + params.symbol2 = interMarket + params.symbol3 = finalMarket + + if (params.cancelDelaySec) { + params.cancelDelay = params.cancelDelaySec * 1000 + delete params.cancelDelaySec + } + + if (params.submitDelaySec) { + params.submitDelay = params.submitDelaySec * 1000 + delete params.submitDelaySec + } + + if (!_isFinite(params.cancelDelay)) { + params.cancelDelay = 1000 + } + + if (!_isFinite(params.submitDelay)) { + params.submitDelay = 2000 + } + + return params +} diff --git a/lib/triangular_arbitrage/meta/serialize.js b/lib/triangular_arbitrage/meta/serialize.js new file mode 100644 index 00000000..84d4633b --- /dev/null +++ b/lib/triangular_arbitrage/meta/serialize.js @@ -0,0 +1,11 @@ +'use strict' + +module.exports = (state = {}) => { + const { args = {}, label, name } = state + + return { + label, + name, + args + } +} diff --git a/lib/triangular_arbitrage/meta/unserialize.js b/lib/triangular_arbitrage/meta/unserialize.js new file mode 100644 index 00000000..ab666845 --- /dev/null +++ b/lib/triangular_arbitrage/meta/unserialize.js @@ -0,0 +1,11 @@ +'use strict' + +module.exports = (loadedState = {}) => { + const { args = {}, name, label } = loadedState + + return { + label, + name, + args + } +} diff --git a/lib/triangular_arbitrage/meta/validate_params.js b/lib/triangular_arbitrage/meta/validate_params.js new file mode 100644 index 00000000..d2a41d90 --- /dev/null +++ b/lib/triangular_arbitrage/meta/validate_params.js @@ -0,0 +1,32 @@ +'use strict' + +const _isFinite = require('lodash/isFinite') +const { doesPairExist, doesCcyExist } = require('../util/symbols') +const { ORDER_TYPES } = require('../util/constants') + +module.exports = (args = {}) => { + const { + symbol1, symbol2, symbol3, amount, orderType1, orderType2, orderType3, submitDelay, + intermediateCcy, cancelDelay, lev, _futures + } = args + + if (ORDER_TYPES.indexOf(orderType1) === -1) return `Invalid order type: ${orderType1}` + if (ORDER_TYPES.indexOf(orderType2) === -1) return `Invalid order type: ${orderType2}` + if (ORDER_TYPES.indexOf(orderType3) === -1) return `Invalid order type: ${orderType3}` + if (!_isFinite(amount)) return 'Invalid amount' + if (!_isFinite(submitDelay) || submitDelay < 0) return 'Invalid submit delay' + if (!_isFinite(cancelDelay) || cancelDelay < 0) return 'Invalid cancel delay' + + if (!doesCcyExist(intermediateCcy)) return `Invalid intermediate currency ${intermediateCcy}` + if (!doesPairExist(symbol1)) return `Invalid starting symbol ${symbol1}` + if (!doesPairExist(symbol2)) return `Invalid intermediate symbol ${symbol2}` + if (!doesPairExist(symbol3)) return `Invalid final symbol ${symbol3}` + + if (_futures) { + if (!_isFinite(lev)) return 'Invalid leverage' + if (lev < 1) return 'Leverage less than 1' + if (lev > 100) return 'Leverage greater than 100' + } + + return null +} diff --git a/lib/triangular_arbitrage/util/constants.js b/lib/triangular_arbitrage/util/constants.js new file mode 100644 index 00000000..c09bd9f6 --- /dev/null +++ b/lib/triangular_arbitrage/util/constants.js @@ -0,0 +1,20 @@ +const MARKET = 'MARKET' +const BEST_BID = 'BEST_BID' +const BEST_ASK = 'BEST_ASK' + +module.exports = { + MARKET, + BEST_BID, + BEST_ASK, + ORDER_TYPES: [MARKET, BEST_BID, BEST_ASK], + ORDER_TYPES_TO_BFX: { + [MARKET]: 'EXCHANGE MARKET', + [BEST_BID]: 'EXCHANGE LIMIT', + [BEST_ASK]: 'EXCHANGE LIMIT' + }, + ORDER_TYPES_TO_MARGIN_BFX: { + [MARKET]: 'MARKET', + [BEST_BID]: 'LIMIT', + [BEST_ASK]: 'LIMIT' + } +} diff --git a/lib/triangular_arbitrage/util/generate_order.js b/lib/triangular_arbitrage/util/generate_order.js new file mode 100644 index 00000000..a09281d6 --- /dev/null +++ b/lib/triangular_arbitrage/util/generate_order.js @@ -0,0 +1,119 @@ +'use strict' + +const { Order } = require('bfx-api-node-models') +const genCID = require('../../util/gen_client_id') +const { MARKET, BEST_BID, BEST_ASK, ORDER_TYPES_TO_BFX, ORDER_TYPES_TO_MARGIN_BFX } = require('./constants') +const { getOrderFinalCurrency, getBase, getQuote } = require('./symbols') + +module.exports = (instance = {}, symbol) => { + const { state = {}, h = {} } = instance + const { debug } = h + const { args = {}, lastBook, gid, orders = {} } = state + const { + symbol1, orderType1, symbol2, orderType2, symbol3, orderType3, + amount, _margin, hidden, lev, _futures + } = args + + const sharedOrderParams = { + symbol, + hidden, + gid + } + + if (_futures) { + sharedOrderParams.lev = lev + } + + let orderType + let lastOrder + switch (symbol) { + case symbol1: + orderType = orderType1 + lastOrder = null + if (Object.values(orders).length !== 0) { + debug(`Attempted to submit initial order it already exists`) + return + } + break + case symbol2: + orderType = orderType2 + if (Object.values(orders).length !== 1) { + debug(`Attempted to submit initial order it already exists`) + return + } + lastOrder = Object.values(orders)[0] + break + case symbol3: + orderType = orderType3 + if (Object.values(orders).length !== 2) { + debug(`Attempted to submit final order it already exists`) + return + } + lastOrder = Object.values(orders)[1] + break + default: + // not the correct market + return + } + + let price + if (orderType === MARKET) { + // execute as market order + price = 0 + } else if (orderType === BEST_BID || orderType === BEST_ASK) { + // get price from last book + const book = lastBook[symbol] + if (!book) { + debug(`No orderbook data for market ${symbol} yet`) + return + } + if (orderType === BEST_BID) { + // get best ask price + price = book.topBid() + } else if (orderType === BEST_ASK) { + // get best bid price + price = book.topAsk() + } + } else { + debug(`Unrecognized order type ${orderType}`) + } + + // get the ending currency of the last order + let ccy + let orderAmount = amount + // if no orders have been made then use the starting amount + if (!lastOrder) { + if (amount > 0) { + ccy = getBase(symbol) + } else { + ccy = getQuote(symbol) + } + } else { + ccy = getOrderFinalCurrency(lastOrder) + // calculate base on the last order how much to + // execute in the next order + const base = getBase(symbol) + const quote = getQuote(symbol) + if (base === ccy) { + orderAmount = -lastOrder.amountOrig + } else if (quote === ccy) { + orderAmount = lastOrder.amountOrig / price + } else { + debug(`Path from ${ccy} does not match original args`) + return + } + } + + debug(`resolved order ${symbol} price %f`, price) + debug(`resolved order ${symbol} amount %f`, orderAmount) + + return new Order({ + ...sharedOrderParams, + amount: orderAmount, + symbol, + // get amount from previous order + price: price, + cid: genCID(), + type: _margin || _futures ? ORDER_TYPES_TO_MARGIN_BFX[orderType] : ORDER_TYPES_TO_BFX[orderType] + }) +} diff --git a/lib/triangular_arbitrage/util/symbols.js b/lib/triangular_arbitrage/util/symbols.js new file mode 100644 index 00000000..34cf40f7 --- /dev/null +++ b/lib/triangular_arbitrage/util/symbols.js @@ -0,0 +1,559 @@ +/* eslint-disable */ +const { pair_ccy1 , pair_ccy2, pair_join } = require('@bitfinex/lib-js-util-symbol') +/* eslint-enable */ + +// 20200110122643 +// https://api.bitfinex.com/v1/symbols +// TODO - should dynamically get these +// but we need a better way to provide pre-loaded data + +const symbols = [ + 'btcusd', + 'ltcusd', + 'ltcbtc', + 'ethusd', + 'ethbtc', + 'etcbtc', + 'etcusd', + 'rrtusd', + 'rrtbtc', + 'zecusd', + 'zecbtc', + 'xmrusd', + 'xmrbtc', + 'dshusd', + 'dshbtc', + 'btceur', + 'btcjpy', + 'xrpusd', + 'xrpbtc', + 'iotusd', + 'iotbtc', + 'ioteth', + 'eosusd', + 'eosbtc', + 'eoseth', + 'sanusd', + 'sanbtc', + 'saneth', + 'omgusd', + 'omgbtc', + 'omgeth', + 'neousd', + 'neobtc', + 'neoeth', + 'etpusd', + 'etpbtc', + 'etpeth', + 'qtmusd', + 'qtmbtc', + 'qtmeth', + 'avtusd', + 'avtbtc', + 'avteth', + 'edousd', + 'edobtc', + 'edoeth', + 'btgusd', + 'btgbtc', + 'datusd', + 'datbtc', + 'dateth', + 'qshusd', + 'qshbtc', + 'qsheth', + 'yywusd', + 'yywbtc', + 'yyweth', + 'gntusd', + 'gntbtc', + 'gnteth', + 'sntusd', + 'sntbtc', + 'snteth', + 'ioteur', + 'batusd', + 'batbtc', + 'bateth', + 'mnausd', + 'mnabtc', + 'mnaeth', + 'funusd', + 'funbtc', + 'funeth', + 'zrxusd', + 'zrxbtc', + 'zrxeth', + 'tnbusd', + 'tnbbtc', + 'tnbeth', + 'spkusd', + 'spkbtc', + 'spketh', + 'trxusd', + 'trxbtc', + 'trxeth', + 'rcnusd', + 'rcnbtc', + 'rcneth', + 'rlcusd', + 'rlcbtc', + 'rlceth', + 'aidusd', + 'aidbtc', + 'aideth', + 'sngusd', + 'sngbtc', + 'sngeth', + 'repusd', + 'repbtc', + 'repeth', + 'elfusd', + 'elfbtc', + 'elfeth', + 'necusd', + 'necbtc', + 'neceth', + 'btcgbp', + 'etheur', + 'ethjpy', + 'ethgbp', + 'neoeur', + 'neojpy', + 'neogbp', + 'eoseur', + 'eosjpy', + 'eosgbp', + 'iotjpy', + 'iotgbp', + 'iosusd', + 'iosbtc', + 'ioseth', + 'aiousd', + 'aiobtc', + 'aioeth', + 'requsd', + 'reqbtc', + 'reqeth', + 'rdnusd', + 'rdnbtc', + 'rdneth', + 'lrcusd', + 'lrcbtc', + 'lrceth', + 'waxusd', + 'waxbtc', + 'waxeth', + 'daiusd', + 'daibtc', + 'daieth', + 'agiusd', + 'agibtc', + 'agieth', + 'bftusd', + 'bftbtc', + 'bfteth', + 'mtnusd', + 'mtnbtc', + 'mtneth', + 'odeusd', + 'odebtc', + 'odeeth', + 'antusd', + 'antbtc', + 'anteth', + 'dthusd', + 'dthbtc', + 'dtheth', + 'mitusd', + 'mitbtc', + 'miteth', + 'stjusd', + 'stjbtc', + 'stjeth', + 'xlmusd', + 'xlmeur', + 'xlmjpy', + 'xlmgbp', + 'xlmbtc', + 'xlmeth', + 'xvgusd', + 'xvgeur', + 'xvgjpy', + 'xvggbp', + 'xvgbtc', + 'xvgeth', + 'bciusd', + 'bcibtc', + 'mkrusd', + 'mkrbtc', + 'mkreth', + 'kncusd', + 'kncbtc', + 'knceth', + 'poausd', + 'poabtc', + 'poaeth', + 'evtusd', + 'lymusd', + 'lymbtc', + 'lymeth', + 'utkusd', + 'utkbtc', + 'utketh', + 'veeusd', + 'veebtc', + 'veeeth', + 'dadusd', + 'dadbtc', + 'dadeth', + 'orsusd', + 'orsbtc', + 'orseth', + 'aucusd', + 'aucbtc', + 'auceth', + 'poyusd', + 'poybtc', + 'poyeth', + 'fsnusd', + 'fsnbtc', + 'fsneth', + 'cbtusd', + 'cbtbtc', + 'cbteth', + 'zcnusd', + 'zcnbtc', + 'zcneth', + 'senusd', + 'senbtc', + 'seneth', + 'ncausd', + 'ncabtc', + 'ncaeth', + 'cndusd', + 'cndbtc', + 'cndeth', + 'ctxusd', + 'ctxbtc', + 'ctxeth', + 'paiusd', + 'paibtc', + 'seeusd', + 'seebtc', + 'seeeth', + 'essusd', + 'essbtc', + 'esseth', + 'atmusd', + 'atmbtc', + 'atmeth', + 'hotusd', + 'hotbtc', + 'hoteth', + 'dtausd', + 'dtabtc', + 'dtaeth', + 'iqxusd', + 'iqxbtc', + 'iqxeos', + 'wprusd', + 'wprbtc', + 'wpreth', + 'zilusd', + 'zilbtc', + 'zileth', + 'bntusd', + 'bntbtc', + 'bnteth', + 'absusd', + 'abseth', + 'xrausd', + 'xraeth', + 'manusd', + 'maneth', + 'bbnusd', + 'bbneth', + 'niousd', + 'nioeth', + 'dgxusd', + 'dgxeth', + 'vetusd', + 'vetbtc', + 'veteth', + 'utnusd', + 'utneth', + 'tknusd', + 'tkneth', + 'gotusd', + 'goteur', + 'goteth', + 'xtzusd', + 'xtzbtc', + 'cnnusd', + 'cnneth', + 'boxusd', + 'boxeth', + 'trxeur', + 'trxgbp', + 'trxjpy', + 'mgousd', + 'mgoeth', + 'rteusd', + 'rteeth', + 'yggusd', + 'yggeth', + 'mlnusd', + 'mlneth', + 'wtcusd', + 'wtceth', + 'csxusd', + 'csxeth', + 'omnusd', + 'omnbtc', + 'intusd', + 'inteth', + 'drnusd', + 'drneth', + 'pnkusd', + 'pnketh', + 'dgbusd', + 'dgbbtc', + 'bsvusd', + 'bsvbtc', + 'babusd', + 'babbtc', + 'wlousd', + 'wloxlm', + 'vldusd', + 'vldeth', + 'enjusd', + 'enjeth', + 'onlusd', + 'onleth', + 'rbtusd', + 'rbtbtc', + 'ustusd', + 'euteur', + 'eutusd', + 'gsdusd', + 'udcusd', + 'tsdusd', + 'paxusd', + 'rifusd', + 'rifbtc', + 'pasusd', + 'paseth', + 'vsyusd', + 'vsybtc', + 'zrxdai', + 'mkrdai', + 'omgdai', + 'bttusd', + 'bttbtc', + 'btcust', + 'ethust', + 'clousd', + 'clobtc', + 'impusd', + 'impeth', + 'ltcust', + 'eosust', + 'babust', + 'scrusd', + 'screth', + 'gnousd', + 'gnoeth', + 'genusd', + 'geneth', + 'atousd', + 'atobtc', + 'atoeth', + 'wbtusd', + 'xchusd', + 'eususd', + 'wbteth', + 'xcheth', + 'euseth', + 'leousd', + 'leobtc', + 'leoust', + 'leoeos', + 'leoeth', + 'astusd', + 'asteth', + 'foausd', + 'foaeth', + 'ufrusd', + 'ufreth', + 'zbtusd', + 'zbtust', + 'okbusd', + 'uskusd', + 'gtxusd', + 'kanusd', + 'okbust', + 'okbeth', + 'okbbtc', + 'uskust', + 'usketh', + 'uskbtc', + 'uskeos', + 'gtxust', + 'kanust', + 'ampusd', + 'algusd', + 'algbtc', + 'algust', + 'btcxch', + 'swmusd', + 'swmeth', + 'triusd', + 'trieth', + 'loousd', + 'looeth', + 'ampust', + 'dusk:usd', + 'dusk:btc', + 'uosusd', + 'uosbtc', + 'rrbusd', + 'rrbust', + 'dtxusd', + 'dtxust', + 'ampbtc', + 'fttusd', + 'fttust', + 'paxust', + 'udcust', + 'tsdust', + 'btc:cnht', + 'ust:cnht', + 'cnh:cnht', + 'chzusd', + 'chzust', + 'btcf0:ustf0', + 'ethf0:ustf0' +] + +let symbolsMap = {} +symbols.forEach((symb) => { + symbolsMap[symb] = symb +}) + +let ccyMap = {} +symbols.forEach((sym) => { + let cc1 = pair_ccy1(sym) + let cc2 = pair_ccy2(sym) + ccyMap[cc1] = cc1 + ccyMap[cc2] = cc2 +}) + +/** + * Returns true if the currency exists in the list of supported + * symbols on bitfinex + * @param {string} ccy + */ +function doesCcyExist (ccy) { + return !!ccyMap[ccy.toLowerCase()] +} + +function doesPairExist (pair) { + return !!symbolsMap[pair.toLowerCase()] +} + +/** + * Returns the ending currency after the order has + * been executed + * @param {Order} order + */ +function getOrderFinalCurrency (order) { + if (order.amountOrigin < 0) { + // sell + return getQuote(order.symbol) + } else { + // buy + return getBase(order.symbol) + } +} + +/** + * Gets all of the available intermediate pairs for + * triangular arbitrage. + * @param {string} pair + * @param {boolean} isBuy (true = buy, false = sell) + */ +function getIntermediatePairsForPair (pair, isBuy) { + /* This function has to be quite complicated since it needs + to calculate all round trip pairs. Ect: + + This works: + BTCUSD + ETHBTC + ETHUSD + + But this doesnt: + XRPUSD + ETHXRP <--- market does not exist + */ + return Object.keys(ccyMap).map((ccy) => { + let { interMarket, finalMarket } = getRoundTripSymbols(pair, ccy, isBuy) + if (doesPairExist(interMarket) && doesPairExist(finalMarket)) { + return interMarket.toUpperCase() + } + }) + // filter out null markets + .filter((x) => x) +} + +/** + * Gets all of the available intermediate currencies for + * triangular arbitrage. + * @param {string} pair + * @param {boolean} isBuy (true = buy, false = sell) + */ +function getIntermediateCurrenciesForPair (pair, isBuy) { + let ccys = {} + getIntermediatePairsForPair(pair, isBuy).forEach((pair) => { + ccys[pair_ccy1(pair)] = null + }) + return Object.keys(ccys) +} + +function getRoundTripSymbols (pair, midCcy, isBuy) { + const baseCcy = pair_ccy1(pair) + const quoteCcy = pair_ccy2(pair) + if (isBuy) { + return { + startMarket: pair, + intermediateMarket: pair_join(midCcy, baseCcy), + finalMarket: pair_join(midCcy, quoteCcy) + } + } else { + return { + startMarket: pair, + intermediateMarket: pair_join(midCcy, quoteCcy), + finalMarket: pair_join(midCcy, baseCcy) + } + } +} + +function getBase (symbol) { + return pair_ccy1(symbol.replace('t', '')) +} + +function getQuote (symbol) { + return pair_ccy2(symbol.replace('t', '')) +} + +module.exports = { + Symbols: symbols, + doesCcyExist, + doesPairExist, + getIntermediatePairsForPair, + getIntermediateCurrenciesForPair, + getRoundTripSymbols, + getOrderFinalCurrency, + getBase, + getQuote +} diff --git a/lib/triangular_arbitrage/util/try_submit_order.js b/lib/triangular_arbitrage/util/try_submit_order.js new file mode 100644 index 00000000..424d83e4 --- /dev/null +++ b/lib/triangular_arbitrage/util/try_submit_order.js @@ -0,0 +1,48 @@ +'use strict' + +const generateOrder = require('./generate_order') +const { Config } = require('bfx-api-node-core') +const { DUST } = Config + +module.exports = async (instance = {}) => { + const { state = {}, h = {} } = instance + const { args = {}, gid, orders = {} } = state + const { emit, debug } = h + const { + symbol1, symbol2, symbol3, submitDelay, + order1, order2, order3 + } = args + const ordersArray = Object.values(orders) + + if (order1.amount > DUST) { + // order1 not fully filled + return + } else { + // execute order on symbol 2 + if (ordersArray.length === 1) { + const order = generateOrder(instance, symbol2) + if (order) { + await emit('exec:order:submit:all', gid, [order], submitDelay) + } + } + } + + if (order2.amount > DUST) { + // order2 not fully filled + return + } else { + // execute order on symbol 3 + if (ordersArray.length === 2) { + const order = generateOrder(instance, symbol3) + if (order) { + await emit('exec:order:submit:all', gid, [order], submitDelay) + } + } + } + + if (order3.amount > DUST) { + // order3 not fully filled + } else { + debug(`Triangular ${symbol1}->${symbol2}->${symbol3} arbitrage complete`) + } +} diff --git a/package.json b/package.json index 912ac697..52d51d66 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "BTC" ], "dependencies": { + "@bitfinex/lib-js-util-symbol": "git+https://github.com/bitfinexcom/lib-js-util-symbol.git", "bfx-api-mock-srv": "^1.0.0", "bfx-api-node-core": "^1.1.0", "bfx-api-node-models": "^1.0.12", @@ -69,6 +70,7 @@ "bfx-hf-ext-plugin-bitfinex": "^1.0.0", "bfx-hf-models-adapter-lowdb": "^1.0.0", "chai": "^4.2.0", + "chai-as-promised": "^7.1.1", "dotenv": "^6.0.0", "eslint": "^4.19.1", "eslint-config-standard": "^7.0.0", diff --git a/test/ping_pong/events/life_start.js b/test/ping_pong/events/life_start.js new file mode 100644 index 00000000..9dc2acd3 --- /dev/null +++ b/test/ping_pong/events/life_start.js @@ -0,0 +1,48 @@ +/* eslint-env mocha */ +'use strict' + +const assert = require('assert') +const Promise = require('bluebird') +const onLifeStart = require('ping_pong/events/life_start') + +describe('ping_pong:events:life_start', () => { + it('submits ping orders on startup', (done) => { + onLifeStart({ + h: { + debug: () => {}, + emit: (eName) => { + return new Promise((resolve) => { + assert.strictEqual(eName, 'exec:order:submit:all') + resolve() + }).then(done).catch(done) + } + }, + state: { + pingPongTable: [], + activePongs: [] + } + }) + }) + it('submits pong orders on startup', (done) => { + onLifeStart({ + h: { + debug: () => {}, + emit: (eName, gid, orders) => { + return new Promise((resolve) => { + assert.strictEqual(eName, 'exec:order:submit:all') + if (orders[0] && orders[0].price === '928') { + done() + } + resolve() + }).catch(done) + } + }, + state: { + pingPongTable: [], + activePongs: { + '928': {} + } + } + }) + }) +}) diff --git a/test/ping_pong/events/life_stop.js b/test/ping_pong/events/life_stop.js new file mode 100644 index 00000000..df221a2d --- /dev/null +++ b/test/ping_pong/events/life_stop.js @@ -0,0 +1,26 @@ +/* eslint-env mocha */ +'use strict' + +const assert = require('assert') +const Promise = require('bluebird') +const onLifeStop = require('ping_pong/events/life_stop') + +describe('ping_pong:events:life_stop', () => { + it('submits ping orders on startup', (done) => { + onLifeStop({ + h: { + debug: () => {}, + emit: (eName) => { + return new Promise((resolve) => { + assert.strictEqual(eName, 'exec:order:cancel:all') + resolve() + }).then(done).catch(done) + } + }, + state: { + pingPongTable: [], + activePongs: [] + } + }) + }) +}) diff --git a/test/ping_pong/events/orders_order_cancel.js b/test/ping_pong/events/orders_order_cancel.js new file mode 100644 index 00000000..a126500c --- /dev/null +++ b/test/ping_pong/events/orders_order_cancel.js @@ -0,0 +1,31 @@ +/* eslint-env mocha */ +'use strict' + +const Promise = require('bluebird') +const onOrdersCancel = require('ping_pong/events/orders_order_cancel') + +describe('ping_pong:events:orders_order_cancel', () => { + it('detects atomic cancelations and stops', (done) => { + let cancelAllCalled = false + onOrdersCancel({ + h: { + debug: () => {}, + emit: (eName) => { + return new Promise((resolve) => { + if (eName === 'exec:order:cancel:all') { + cancelAllCalled = true + } + if (eName === 'exec:stop' && cancelAllCalled) { + done() + } + resolve() + }).catch(done) + } + }, + state: { + pingPongTable: [], + activePongs: [] + } + }) + }) +}) diff --git a/test/ping_pong/events/orders_order_fill.js b/test/ping_pong/events/orders_order_fill.js new file mode 100644 index 00000000..c7496248 --- /dev/null +++ b/test/ping_pong/events/orders_order_fill.js @@ -0,0 +1,88 @@ +/* eslint-env mocha */ +'use strict' + +const assert = require('assert') +const { Order } = require('bfx-api-node-models') +const Promise = require('bluebird') +const onOrdersFill = require('ping_pong/events/orders_order_fill') + +describe('ping_pong:events:orders_order_cancel', () => { + it('re-submits ping order when pong fills - if endless=true', (done) => { + let orderUpdate = new Order({ amount: 0, price: 100 }) + let instance = { + h: { + debug: () => {}, + emit: (eName, _, pingOrders) => { + assert.strictEqual(eName, 'exec:order:submit:all') + assert.strictEqual(pingOrders.length, 1) + assert.strictEqual(pingOrders[0].price, 90) + return new Promise((resolve) => { + resolve() + }).then(done).catch(done) + }, + updateState: () => {} + }, + state: { + args: { + endless: true + }, + pingPongTable: { + }, + activePongs: { + 100: 90 + } + } + } + onOrdersFill(instance, orderUpdate) + }) + it('does not re-submit ping order when pong fills - if endless=false', (done) => { + let orderUpdate = new Order({ amount: 0, price: 100 }) + let instance = { + h: { + debug: () => {}, + emit: (eName, _, pingOrders) => { + assert.strictEqual(eName, 'exec:stop') + return new Promise((resolve) => { + resolve() + }).then(done).catch(done) + }, + updateState: () => {} + }, + state: { + args: { + endless: false + }, + pingPongTable: { + }, + activePongs: { + 100: 90 + } + } + } + onOrdersFill(instance, orderUpdate) + }) + it('submit pong order when ping fills', (done) => { + let orderUpdate = new Order({ amount: 0, price: 100 }) + let instance = { + h: { + debug: console.log, + emit: (eName, _, pingOrders) => { + assert.strictEqual(eName, 'exec:order:submit:all') + assert.strictEqual(pingOrders.length, 1) + assert.strictEqual(pingOrders[0].price, 120) + return new Promise((resolve) => { + resolve() + }).then(done).catch(done) + }, + updateState: () => {} + }, + state: { + pingPongTable: { + 100: 120 + }, + activePongs: [] + } + } + onOrdersFill(instance, orderUpdate) + }) +}) diff --git a/test/triangular_arbitrage/meta/declare_channels.js b/test/triangular_arbitrage/meta/declare_channels.js new file mode 100644 index 00000000..385c0d0d --- /dev/null +++ b/test/triangular_arbitrage/meta/declare_channels.js @@ -0,0 +1,62 @@ +/* eslint-env mocha */ +'use strict' + +const chaiAsPromised = require('chai-as-promised') +const chai = require('chai') +const assert = require('assert') +const Promise = require('bluebird') +const onDeclareChannels = require('triangular_arbitrage/meta/declare_channels') + +chai.use(chaiAsPromised) +const expect = chai.expect; + +describe('triangular_arbitrage:meta:onDeclareChannels', () => { + it('declares orderbook channels if limit enabled', (done) => { + const symbol1 = 'tBTCUSD' + const symbol2 = 'tETHBTC' + const symbol3 = 'tETHUSD' + let counter = 0 + onDeclareChannels({ + h: { + declareChannel: (instance, host, channel, data) => { + return new Promise((resolve) => { + let sym = data.symbol + assert.strictEqual(channel, 'book') + assert.strictEqual(sym === symbol1 || sym === symbol2 || sym === symbol3, true) + counter += 1 + if (counter >= 3) { + done() + } + console.log(channel, data) + resolve() + }).catch(done) + } + }, + state: { + args: { + symbol1, + symbol2, + symbol3, + limit: true + } + } + }) + }) + it('does not declare orderbook channels if limit not specified', async () => { + await expect(onDeclareChannels({ + h: { + declareChannel: (instance, host, channel, data) => { + throw Error('Not supposed to be called') + } + }, + state: { + args: { + symbol1: 'tBTCUSD', + symbol2: 'tETHBTC', + symbol3: 'tETHUSD', + limit: true + } + } + })).to.eventually.be.rejectedWith(Error) + }) +}) diff --git a/test/triangular_arbitrage/meta/gen_order_label.js b/test/triangular_arbitrage/meta/gen_order_label.js new file mode 100644 index 00000000..8f46dcf4 --- /dev/null +++ b/test/triangular_arbitrage/meta/gen_order_label.js @@ -0,0 +1,26 @@ +/* eslint-env mocha */ +'use strict' + +const { Order } = require('bfx-api-node-models') +const assert = require('assert') +const genOrderLabel = require('triangular_arbitrage/meta/gen_order_label') + +describe('triangular_arbitrage:meta:genOrderLabel', () => { + it('declares orderbook channels if limit enabled', () => { + const label = genOrderLabel({ + args: { + symbol1: 'tBTCUSD', + symbol2: 'tETHBTC', + symbol3: 'tETHUSD', + orders: [ + new Order({ symbol: 'tBTCUSD', amount: 100, price: 100 }), + new Order({ symbol: 'tETHBTC', amount: 120, price: 90 }), + new Order({ symbol: 'tETHUSD', amount: -90, price: 120 }) + ], + limit: true + } + }) + assert.strictEqual(typeof label, 'string') + assert.strictEqual(label.length > 0, true) + }) +}) diff --git a/test/triangular_arbitrage/meta/process_params.js b/test/triangular_arbitrage/meta/process_params.js new file mode 100644 index 00000000..ce5cfac7 --- /dev/null +++ b/test/triangular_arbitrage/meta/process_params.js @@ -0,0 +1,31 @@ +/* eslint-env mocha */ +'use strict' + +const assert = require('assert') +const processParams = require('triangular_arbitrage/meta/process_params') + +describe('triangular_arbitrage:meta:processParams', () => { + it('symbol1 + symbol2 + symbol3 are processed correctly during buy', () => { + const params = processParams({ + _symbol: 'tBTCUSD', + action: 'Buy', + amount: '10', + intermediateCcy: 'ETH' + }) + assert.strictEqual(params.symbol1, 'BTCUSD') + assert.strictEqual(params.symbol2, 'ETHBTC') + assert.strictEqual(params.symbol3, 'ETHUSD') + }) + + it('symbol1 + symbol2 + symbol3 are processed correctly during sell', () => { + const params = processParams({ + _symbol: 'tBTCUSD', + action: 'Sell', + amount: '10', + intermediateCcy: 'ETH' + }) + assert.strictEqual(params.symbol1, 'BTCUSD') + assert.strictEqual(params.symbol2, 'ETHUSD') + assert.strictEqual(params.symbol3, 'ETHBTC') + }) +}) diff --git a/test/triangular_arbitrage/util/generate_order.js b/test/triangular_arbitrage/util/generate_order.js new file mode 100644 index 00000000..64bfd383 --- /dev/null +++ b/test/triangular_arbitrage/util/generate_order.js @@ -0,0 +1,116 @@ +/* eslint-env mocha */ +'use strict' + +const { Order, OrderBook } = require('bfx-api-node-models') +const assert = require('assert') +const generateOrder = require('triangular_arbitrage/util/generate_order') +const { MARKET, BEST_BID, BEST_ASK } = require('triangular_arbitrage/util/constants') + +const stateTemplate = { + h: { + debug: console.log + }, + state: { + lastBook: { + }, + args: { + symbol1: 'tBTCUSD', + symbol2: 'tETHBTC', + symbol3: 'tETHUSD', + orderType1: BEST_BID, + orderType2: BEST_BID, + orderType3: BEST_ASK, + amount: 2.3, + orders: [ + ] + } + } +} + +describe('triangular_arbitrage:meta:generate_order', () => { + it('generates the correct starting order - BEST_BID', () => { + let instance = Object.assign({}, stateTemplate) + instance.state.lastBook['tBTCUSD'] = new OrderBook({ + bids: [ + // PRICE + // COUNT + // AMOUNT + [100, 1, 0.8] + ], + asks: [ + [110, 3, 8.3] + ] + }) + instance.state.args.orderType1 = BEST_BID + let order = generateOrder(instance, 'tBTCUSD') + assert(order.symbol, 'tBTCUSD') + assert(order.price, 100) + assert(order.amount, 2.3) + assert(order.type, 'EXCHANGE LIMIT') + }) + + it('generates the correct intermediate order - BEST_BID', () => { + let instance = Object.assign({}, stateTemplate) + instance.state.lastBook['tETHBTC'] = new OrderBook({ + bids: [ + // PRICE + // COUNT + // AMOUNT + [0.019, 1, 0.8] + ], + asks: [ + [0.0191, 3, 8.3] + ] + }) + instance.orders = [ + new Order({ symbol: 'tBTCUSD', amountOrig: 2.3, priceAvg: 100 }) + ] + instance.state.args.orderType2 = BEST_BID + let order = generateOrder(instance, 'tETHBTC') + assert(order.symbol, 'tETHBTC') + assert(order.price, 0.001) + assert(order.amount, 2.3 / 0.019) + assert(order.type, 'EXCHANGE LIMIT') + }) + + it('generates the correct final order - BEST_ASK', () => { + let instance = Object.assign({}, stateTemplate) + instance.state.lastBook['tETHUSD'] = new OrderBook({ + bids: [ + // PRICE + // COUNT + // AMOUNT + [168.64, 1, 0.8] + ], + asks: [ + [172.01, 3, 8.3] + ] + }) + instance.orders = [ + new Order({ symbol: 'tBTCUSD', amountOrig: 2.3, priceAvg: 100 }), + new Order({ symbol: 'tETHBTC', amountOrig: 121.05, priceAvg: 0.019 }) + // new Order({ symbol: 'tETHUSD', amount: -90, price: 120 }) + ] + instance.state.args.orderType3 = BEST_ASK + let order = generateOrder(instance, 'tETHUSD') + assert(order.symbol, 'tETHUSD') + assert(order.amount, -(2.3 / 0.019)) + assert(order.price, 172.01) + assert(order.type, 'EXCHANGE LIMIT') + }) + + it('generates the correct final order - MARKET', () => { + let instance = Object.assign({}, stateTemplate) + instance.orders = [ + new Order({ symbol: 'tBTCUSD', amountOrig: 2.3, priceAvg: 100 }), + new Order({ symbol: 'tETHBTC', amountOrig: 121.05, priceAvg: 0.019 }) + // new Order({ symbol: 'tETHUSD', amount: -90, price: 120 }) + ] + instance.state.args.orderType3 = MARKET + let order = generateOrder(instance, 'tETHUSD') + console.log(order) + assert(order.symbol, 'tETHUSD') + assert(order.amount, -(2.3 / 0.019)) + assert(order.type, 'EXCHANGE MARKET') + }) +})