const _ = require('lodash'); const util = require('../../core/util.js'); const config = util.getConfig(); const dirs = util.dirs(); const moment = require('moment'); const log = require(dirs.core + 'log'); const Broker = require(dirs.broker + '/gekkoBroker'); require(dirs.gekko + '/exchange/dependencyCheck'); const Trader = function(next) { _.bindAll(this); this.brokerConfig = { ...config.trader, ...config.watch, private: true } this.propogatedTrades = 0; this.propogatedTriggers = 0; try { this.broker = new Broker(this.brokerConfig); } catch(e) { util.die(e.message); } if(!this.broker.capabilities.gekkoBroker) { util.die('This exchange is not yet supported'); } this.sync(() => { log.info('\t', 'Portfolio:'); log.info('\t\t', this.portfolio.currency, this.brokerConfig.currency); log.info('\t\t', this.portfolio.asset, this.brokerConfig.asset); log.info('\t', 'Balance:'); log.info('\t\t', this.balance, this.brokerConfig.currency); log.info('\t', 'Exposed:'); log.info('\t\t', this.exposed ? 'yes' : 'no', `(${(this.exposure * 100).toFixed(2)}%)` ); next(); }); this.cancellingOrder = false; this.sendInitialPortfolio = false; setInterval(this.sync, 1000 * 60 * 10); } // teach our trader events util.makeEventEmitter(Trader); Trader.prototype.sync = function(next) { log.debug('syncing private data'); this.broker.syncPrivateData(() => { if(!this.price) { this.price = this.broker.ticker.bid; } const oldPortfolio = this.portfolio; this.setPortfolio(); this.setBalance(); if(this.sendInitialPortfolio && !_.isEqual(oldPortfolio, this.portfolio)) { this.relayPortfolioChange(); } // balance is relayed every minute // no need to do it here. if(next) { next(); } }); } Trader.prototype.relayPortfolioChange = function() { this.deferredEmit('portfolioChange', { asset: this.portfolio.asset, currency: this.portfolio.currency }); } Trader.prototype.relayPortfolioValueChange = function() { this.deferredEmit('portfolioValueChange', { balance: this.balance }); } Trader.prototype.setPortfolio = function() { this.portfolio = { currency: _.find( this.broker.portfolio.balances, b => b.name === this.brokerConfig.currency ).amount, asset: _.find( this.broker.portfolio.balances, b => b.name === this.brokerConfig.asset ).amount } } Trader.prototype.setBalance = function() { this.balance = this.portfolio.currency + this.portfolio.asset * this.price; this.exposure = (this.portfolio.asset * this.price) / this.balance; // if more than 10% of balance is in asset we are exposed this.exposed = this.exposure > 0.1; } Trader.prototype.processCandle = function(candle, done) { this.price = candle.close; const previousBalance = this.balance; this.setPortfolio(); this.setBalance(); if(!this.sendInitialPortfolio) { this.sendInitialPortfolio = true; this.deferredEmit('portfolioChange', { asset: this.portfolio.asset, currency: this.portfolio.currency }); } if(this.balance !== previousBalance) { // this can happen because: // A) the price moved and we have > 0 asset // B) portfolio got changed this.relayPortfolioValueChange(); } done(); } Trader.prototype.processAdvice = function(advice) { let direction; if(advice.recommendation === 'long') { direction = 'buy'; } else if(advice.recommendation === 'short') { direction = 'sell'; } else { log.error('ignoring advice in unknown direction'); return; } const id = 'trade-' + (++this.propogatedTrades); if(this.order) { if(this.order.side === direction) { return log.info('ignoring advice: already in the process to', direction); } if(this.cancellingOrder) { return log.info('ignoring advice: already cancelling previous', this.order.side, 'order'); } log.info('Received advice to', direction, 'however Gekko is already in the process to', this.order.side); log.info('Canceling', this.order.side, 'order first'); return this.cancelOrder(id, advice, () => this.processAdvice(advice)); } let amount; if(direction === 'buy') { if(this.exposed) { log.info('NOT buying, already exposed'); return this.deferredEmit('tradeAborted', { id, adviceId: advice.id, action: direction, portfolio: this.portfolio, balance: this.balance, reason: "Portfolio already in position." }); } amount = this.portfolio.currency / this.price * 0.95; log.info( 'Trader', 'Received advice to go long.', 'Buying ', this.brokerConfig.asset ); } else if(direction === 'sell') { if(!this.exposed) { log.info('NOT selling, already no exposure'); return this.deferredEmit('tradeAborted', { id, adviceId: advice.id, action: direction, portfolio: this.portfolio, balance: this.balance, reason: "Portfolio already in position." }); } // clean up potential old stop trigger if(this.activeStopTrigger) { this.deferredEmit('triggerAborted', { id: this.activeStopTrigger.id, date: advice.date }); this.activeStopTrigger.instance.cancel(); delete this.activeStopTrigger; } amount = this.portfolio.asset; log.info( 'Trader', 'Received advice to go short.', 'Selling ', this.brokerConfig.asset ); } this.createOrder(direction, amount, advice, id); } Trader.prototype.createOrder = function(side, amount, advice, id) { const type = 'sticky'; // NOTE: this is the best check we can do at this point // with the best price we have. The order won't be actually // created with this.price, but it should be close enough to // catch non standard errors (lot size, price filter) on // exchanges that have them. const check = this.broker.isValidOrder(amount, this.price); if(!check.valid) { log.warn('NOT creating order! Reason:', check.reason); return this.deferredEmit('tradeAborted', { id, adviceId: advice.id, action: side, portfolio: this.portfolio, balance: this.balance, reason: check.reason }); } log.debug('Creating order to', side, amount, this.brokerConfig.asset); this.deferredEmit('tradeInitiated', { id, adviceId: advice.id, action: side, portfolio: this.portfolio, balance: this.balance }); this.order = this.broker.createOrder(type, side, amount); this.order.on('fill', f => log.info('[ORDER] partial', side, 'fill, total filled:', f)); this.order.on('statusChange', s => log.debug('[ORDER] statusChange:', s)); this.order.on('error', e => { log.error('[ORDER] Gekko received error from GB:', e.message); log.debug(e); this.order = null; this.cancellingOrder = false; this.deferredEmit('tradeErrored', { id, adviceId: advice.id, date: moment(), reason: e.message }); }); this.order.on('completed', () => { this.order.createSummary((err, summary) => { if(!err && !summary) { err = new Error('GB returned an empty summary.') } if(err) { log.error('Error while creating summary:', err); return this.deferredEmit('tradeErrored', { id, adviceId: advice.id, date: moment(), reason: err.message }); } log.info('[ORDER] summary:', summary); this.order = null; this.sync(() => { let cost; if(_.isNumber(summary.feePercent)) { cost = summary.feePercent / 100 * summary.amount * summary.price; } let effectivePrice; if(_.isNumber(summary.feePercent)) { if(side === 'buy') { effectivePrice = summary.price * (1 + summary.feePercent / 100); } else { effectivePrice = summary.price * (1 - summary.feePercent / 100); } } else { log.warn('WARNING: exchange did not provide fee information, assuming no fees..'); effectivePrice = summary.price; } this.deferredEmit('tradeCompleted', { id, adviceId: advice.id, action: summary.side, cost, amount: summary.amount, price: summary.price, portfolio: this.portfolio, balance: this.balance, date: summary.date, feePercent: summary.feePercent, effectivePrice }); if( side === 'buy' && advice.trigger && advice.trigger.type === 'trailingStop' ) { const trigger = advice.trigger; const triggerId = 'trigger-' + (++this.propogatedTriggers); this.deferredEmit('triggerCreated', { id: triggerId, at: advice.date, type: 'trailingStop', properties: { trail: trigger.trailValue, initialPrice: summary.price, } }); log.info(`Creating trailingStop trigger "${triggerId}"! Properties:`); log.info(`\tInitial price: ${summary.price}`); log.info(`\tTrail of: ${trigger.trailValue}`); this.activeStopTrigger = { id: triggerId, adviceId: advice.id, instance: this.broker.createTrigger({ type: 'trailingStop', onTrigger: this.onStopTrigger, props: { trail: trigger.trailValue, initialPrice: summary.price, } }) } } }); }) }); } Trader.prototype.onStopTrigger = function(price) { log.info(`TrailingStop trigger "${this.activeStopTrigger.id}" fired! Observed price was ${price}`); this.deferredEmit('triggerFired', { id: this.activeStopTrigger.id, date: moment() }); const adviceMock = { recommendation: 'short', id: this.activeStopTrigger.adviceId } delete this.activeStopTrigger; this.processAdvice(adviceMock); } Trader.prototype.cancelOrder = function(id, advice, next) { if(!this.order) { return next(); } this.cancellingOrder = true; this.order.removeAllListeners(); this.order.cancel(); this.order.once('completed', () => { this.order = null; this.cancellingOrder = false; this.deferredEmit('tradeCancelled', { id, adviceId: advice.id, date: moment() }); this.sync(next); }); } module.exports = Trader;