const moment = require('moment'); const _ = require('lodash'); const Errors = require('../exchangeErrors'); const marketData = require('./binance-markets.json'); const exchangeUtils = require('../exchangeUtils'); const retry = exchangeUtils.retry; const scientificToDecimal = exchangeUtils.scientificToDecimal; const Binance = require('binance'); const Trader = function(config) { _.bindAll(this, [ 'roundAmount', 'roundPrice', 'isValidPrice', 'isValidLot' ]); if (_.isObject(config)) { this.key = config.key; this.secret = config.secret; this.currency = config.currency.toUpperCase(); this.asset = config.asset.toUpperCase(); } let recvWindow = 6000; if(config.optimizedConnection) { // there is a bug in binance's API // where some requests randomly take // over a second, this tells binance // to bail out after 500ms. // // As discussed in binance API // telegram. TODO add link. recvWindow = 500; } this.pair = this.asset + this.currency; this.name = 'binance'; this.market = _.find(Trader.getCapabilities().markets, (market) => { return market.pair[0] === this.currency && market.pair[1] === this.asset }); this.binance = new Binance.BinanceRest({ key: this.key, secret: this.secret, timeout: 15000, recvWindow, disableBeautification: false, handleDrift: true, }); if(config.key && config.secret) { // Note non standard func: // // On binance we might pay fees in BNB // if we do we CANNOT calculate feePercent // since we don't track BNB price (when we // are not trading on a BNB market). // // Though we can deduce feePercent based // on user fee tracked through `this.getFee`. // Set default here, overwrite in getFee. this.fee = 0.001; // Set the proper fee asap. this.getFee(_.noop); this.oldOrder = false; } }; const recoverableErrors = [ 'SOCKETTIMEDOUT', 'TIMEDOUT', 'CONNRESET', 'CONNREFUSED', 'NOTFOUND', 'Error -1021', 'Response code 429', 'Response code 5', 'Response code 403', 'ETIMEDOUT', 'EHOSTUNREACH', // getaddrinfo EAI_AGAIN api.binance.com api.binance.com:443 'EAI_AGAIN', 'ENETUNREACH' ]; const includes = (str, list) => { if(!_.isString(str)) return false; return _.some(list, item => str.includes(item)); } Trader.prototype.handleResponse = function(funcName, callback) { return (error, body) => { if (body && body.code) { error = new Error(`Error ${body.code}: ${body.msg}`); } if(error) { if(_.isString(error)) { error = new Error(error); } if(includes(error.message, recoverableErrors)) { error.notFatal = true; } if(funcName === 'cancelOrder' && error.message.includes('UNKNOWN_ORDER')) { console.log(new Date, 'cancelOrder', 'UNKNOWN_ORDER'); // order got filled in full before it could be // cancelled, meaning it was NOT cancelled. return callback(false, {filled: true}); } if(funcName === 'checkOrder' && error.message.includes('Order does not exist.')) { console.log(new Date, 'Binance doesnt know this order, retrying up to 10 times..'); error.retry = 10; } if(funcName === 'addOrder' && error.message.includes('Account has insufficient balance')) { console.log(new Date, 'insufficientFunds'); error.type = 'insufficientFunds'; } return callback(error); } return callback(undefined, body); } }; Trader.prototype.getTrades = function(since, callback, descending) { const processResults = (err, data) => { if (err) return callback(err); var parsedTrades = []; _.each( data, function(trade) { parsedTrades.push({ tid: trade.aggTradeId, date: moment(trade.timestamp).unix(), price: parseFloat(trade.price), amount: parseFloat(trade.quantity), }); }, this ); if (descending) callback(null, parsedTrades.reverse()); else callback(undefined, parsedTrades); }; var reqData = { symbol: this.pair, }; if (since) { var endTs = moment(since) .add(1, 'h') .valueOf(); var nowTs = moment().valueOf(); reqData.startTime = moment(since).valueOf(); reqData.endTime = endTs > nowTs ? nowTs : endTs; } const fetch = cb => this.binance.aggTrades(reqData, this.handleResponse('getTrades', cb)); retry(undefined, fetch, processResults); }; Trader.prototype.getPortfolio = function(callback) { const setBalance = (err, data) => { if (err) return callback(err); const findAsset = item => item.asset === this.asset; const assetAmount = parseFloat(_.find(data.balances, findAsset).free); const findCurrency = item => item.asset === this.currency; const currencyAmount = parseFloat(_.find(data.balances, findCurrency).free); if (!_.isNumber(assetAmount) || _.isNaN(assetAmount)) { assetAmount = 0; } if (!_.isNumber(currencyAmount) || _.isNaN(currencyAmount)) { currencyAmount = 0; } const portfolio = [ { name: this.asset, amount: assetAmount }, { name: this.currency, amount: currencyAmount }, ]; return callback(undefined, portfolio); }; const fetch = cb => this.binance.account({}, this.handleResponse('getPortfolio', cb)); retry(undefined, fetch, setBalance); }; Trader.prototype.getFee = function(callback) { // binance does NOT tell us whether the user is using BNB to pay // for fees, which means a discount (effectively lower fees) const handle = (err, data) => { if(err) { return callback(err); } const basepoints = data.makerCommission; /** Binance raw response { makerCommission: 10, takerCommission: 10, buyerCommission: 0, sellerCommission: 0, canTrade: true, canWithdraw: true, canDeposit: true, So to get decimal representation of fee we actually need to divide by 10000 */ // note non standard func, see constructor this.fee = basepoints / 10000; callback(undefined, this.fee); } const fetch = cb => this.binance.account({}, this.handleResponse('getFee', cb)); retry(undefined, fetch, handle); }; Trader.prototype.getTicker = function(callback) { const setTicker = (err, data) => { if (err) return callback(err); var result = _.find(data, ticker => ticker.symbol === this.pair); if(!result) return callback(new Error(`Market ${this.pair} not found on Binance`)); var ticker = { ask: parseFloat(result.askPrice), bid: parseFloat(result.bidPrice), }; callback(undefined, ticker); }; const handler = cb => this.binance._makeRequest({}, this.handleResponse('getTicker', cb), 'api/v1/ticker/allBookTickers'); retry(undefined, handler, setTicker); }; // Effectively counts the number of decimal places, so 0.001 or 0.234 results in 3 Trader.prototype.getPrecision = function(tickSize) { if (!isFinite(tickSize)) return 0; var e = 1, p = 0; while (Math.round(tickSize * e) / e !== tickSize) { e *= 10; p++; } return p; }; Trader.prototype.round = function(amount, tickSize) { var precision = 100000000; var t = this.getPrecision(tickSize); if(Number.isInteger(t)) precision = Math.pow(10, t); amount *= precision; amount = Math.floor(amount); amount /= precision; // https://gist.github.com/jiggzson/b5f489af9ad931e3d186 amount = scientificToDecimal(amount); return amount; }; Trader.prototype.roundAmount = function(amount) { return this.round(amount, this.market.minimalOrder.amount); } Trader.prototype.roundPrice = function(price) { return this.round(price, this.market.minimalOrder.price); } Trader.prototype.isValidPrice = function(price) { return price >= this.market.minimalOrder.price; } Trader.prototype.isValidLot = function(price, amount) { return amount * price >= this.market.minimalOrder.order; } Trader.prototype.outbidPrice = function(price, isUp) { let newPrice; if(isUp) { newPrice = price + this.market.minimalOrder.price; } else { newPrice = price - this.market.minimalOrder.price; } return this.roundPrice(newPrice); } Trader.prototype.addOrder = function(tradeType, amount, price, callback) { const setOrder = (err, data) => { if (err) return callback(err); const txid = data.orderId; callback(undefined, txid); }; const reqData = { symbol: this.pair, side: tradeType.toUpperCase(), type: 'LIMIT', timeInForce: 'GTC', quantity: amount, price: price, timestamp: new Date().getTime() }; const handler = cb => this.binance.newOrder(reqData, this.handleResponse('addOrder', cb)); retry(undefined, handler, setOrder); }; Trader.prototype.getOpenOrders = function(callback) { const get = (err, data) => { if(err) { return callback(err); } callback(null, data.map(o => o.orderId)); } const reqData = { symbol: this.pair } const handler = cb => this.binance.openOrders(reqData, this.handleResponse('getOpenOrders', cb)); retry(undefined, handler, get); } Trader.prototype.getOrder = function(order, callback) { const get = (err, data) => { if (err) return callback(err); let price = 0; let amount = 0; let date = moment(0); const fees = {}; if(!data.length) { return callback(new Error('Binance did not return any trades')); } const trades = _.filter(data, t => { // note: the API returns a string after creating return t.orderId == order; }); if(!trades.length) { console.log('cannot find trades!', { order, list: data.map(t => t.orderId).reverse() }); const reqData = { symbol: this.pair, orderId: order, }; this.binance.queryOrder(reqData, (err, resp) => { console.log('couldnt find any trade for order, here is order:', {err, resp}); callback(new Error('Trades not found')); }); return; } _.each(trades, trade => { date = moment(trade.time); price = ((price * amount) + (+trade.price * trade.qty)) / (+trade.qty + amount); amount += +trade.qty; if(fees[trade.commissionAsset]) fees[trade.commissionAsset] += (+trade.commission); else fees[trade.commissionAsset] = (+trade.commission); }); let feePercent; if(_.keys(fees).length === 1) { if(fees.BNB && this.asset !== 'BNB' && this.currency !== 'BNB') { // we paid fees in BNB, right now that means the fee is always 75% // of base fee. We cannot calculate since we do not have the BNB rate. feePercent = this.fee * 0.75; } else { if(fees[this.asset]) { feePercent = fees[this.asset] / amount * 100; } else if(fees.currency) { feePercent = fees[this.currency] / price / amount * 100; } else { // use user fee of 10 basepoints feePercent = this.fee; } } } else { // we paid fees in multiple currencies? // assume user fee feePercent = this.fee; } callback(undefined, { price, amount, date, fees, feePercent }); } const reqData = { symbol: this.pair, // if this order was not part of the last 500 trades we won't find it.. limit: 1000, }; const handler = cb => this.binance.myTrades(reqData, this.handleResponse('getOrder', cb)); retry(undefined, handler, get); }; Trader.prototype.buy = function(amount, price, callback) { this.addOrder('buy', amount, price, callback); }; Trader.prototype.sell = function(amount, price, callback) { this.addOrder('sell', amount, price, callback); }; Trader.prototype.checkOrder = function(order, callback) { const check = (err, data) => { if (err) { return callback(err); } if(data.filled === true) { // binance responsed with order not found return callback(undefined, { executed: true, open: false }); } const status = data.status; if( status === 'CANCELED' || status === 'REJECTED' || // for good measure: GB does not // submit orders that can expire yet status === 'EXPIRED' ) { return callback(undefined, { executed: false, open: false }); } else if( status === 'NEW' || status === 'PARTIALLY_FILLED' ) { return callback(undefined, { executed: false, open: true, filledAmount: +data.executedQty }); } else if(status === 'FILLED') { return callback(undefined, { executed: true, open: false }) } console.log('what status?', status); throw status; }; const reqData = { symbol: this.pair, orderId: order, }; const fetcher = cb => this.binance.queryOrder(reqData, this.handleResponse('checkOrder', cb)); retry(undefined, fetcher, check); }; Trader.prototype.cancelOrder = function(order, callback) { const cancel = (err, data) => { this.oldOrder = order; if(err) { return callback(err); } if(data && data.filled) { return callback(undefined, true); } return callback(undefined, false); }; let reqData = { symbol: this.pair, orderId: order, }; const fetcher = cb => this.binance.cancelOrder(reqData, this.handleResponse('cancelOrder', cb)); retry(undefined, fetcher, cancel); }; Trader.getCapabilities = function() { return { name: 'Binance', slug: 'binance', currencies: marketData.currencies, assets: marketData.assets, markets: marketData.markets, requires: ['key', 'secret'], providesHistory: 'date', providesFullHistory: true, tid: 'tid', tradable: true, gekkoBroker: 0.6, limitedCancelConfirmation: true }; }; module.exports = Trader;