430 lines
12 KiB
JavaScript
430 lines
12 KiB
JavaScript
const Gdax = require('gdax');
|
|
const _ = require('lodash');
|
|
const moment = require('moment');
|
|
|
|
const errors = require('../exchangeErrors');
|
|
const retry = require('../exchangeUtils').retry;
|
|
|
|
const BATCH_SIZE = 100;
|
|
const QUERY_DELAY = 350;
|
|
|
|
const marketData = require('./coinbase-markets.json');
|
|
|
|
const Trader = function(config) {
|
|
this.post_only = true;
|
|
this.use_sandbox = false;
|
|
this.name = 'GDAX';
|
|
this.scanback = false;
|
|
this.scanbackTid = 0;
|
|
this.scanbackResults = [];
|
|
this.asset = config.asset;
|
|
this.currency = config.currency;
|
|
|
|
this.api_url = 'https://api.pro.coinbase.com';
|
|
this.api_sandbox_url = 'https://api-public.sandbox.pro.coinbase.com';
|
|
|
|
if (_.isObject(config)) {
|
|
this.key = config.key;
|
|
this.secret = config.secret;
|
|
this.passphrase = config.passphrase;
|
|
|
|
this.pair = [config.asset, config.currency].join('-').toUpperCase();
|
|
this.post_only =
|
|
typeof config.post_only !== 'undefined' ? config.post_only : true;
|
|
|
|
if (config.sandbox) {
|
|
this.use_sandbox = config.sandbox;
|
|
}
|
|
|
|
}
|
|
|
|
this.gdax_public = new Gdax.PublicClient(
|
|
this.use_sandbox ? this.api_sandbox_url : undefined
|
|
);
|
|
this.gdax = new Gdax.AuthenticatedClient(
|
|
this.key,
|
|
this.secret,
|
|
this.passphrase,
|
|
this.use_sandbox ? this.api_sandbox_url : undefined
|
|
);
|
|
};
|
|
|
|
const recoverableErrors = [
|
|
'SOCKETTIMEDOUT',
|
|
'TIMEDOUT',
|
|
'CONNRESET',
|
|
'CONNREFUSED',
|
|
'NOTFOUND',
|
|
'Rate limit exceeded',
|
|
'Response code 5',
|
|
'GDAX is currently under maintenance.',
|
|
'HTTP 408 Error',
|
|
'HTTP 504 Error',
|
|
'HTTP 503 Error',
|
|
'socket hang up',
|
|
'EHOSTUNREACH',
|
|
'EAI_AGAIN',
|
|
'ENETUNREACH'
|
|
];
|
|
|
|
const includes = (str, list) => {
|
|
if(!_.isString(str))
|
|
return false;
|
|
|
|
return _.some(list, item => str.includes(item));
|
|
}
|
|
|
|
Trader.prototype.processResponse = function(method, next) {
|
|
return (error, response, body) => {
|
|
if(!error && body && !_.isEmpty(body.message)) {
|
|
error = new Error(body.message);
|
|
}
|
|
|
|
if(
|
|
response &&
|
|
response.statusCode < 200 &&
|
|
response.statusCode >= 300
|
|
) {
|
|
error = new Error(`Response code ${response.statusCode}`);
|
|
}
|
|
|
|
if(error) {
|
|
if(includes(error.message, recoverableErrors)) {
|
|
error.notFatal = true;
|
|
error.backoffDelay = 1000;
|
|
}
|
|
|
|
if(
|
|
['buy', 'sell'].includes(method) &&
|
|
error.message.includes('Insufficient funds')
|
|
) {
|
|
error.retry = 10;
|
|
}
|
|
|
|
return next(error);
|
|
}
|
|
|
|
return next(undefined, body);
|
|
}
|
|
}
|
|
|
|
Trader.prototype.getPortfolio = function(callback) {
|
|
const result = (err, data) => {
|
|
if (err) return callback(err);
|
|
|
|
var portfolio = data.map(function(account) {
|
|
return {
|
|
name: account.currency.toUpperCase(),
|
|
amount: parseFloat(account.available),
|
|
};
|
|
});
|
|
callback(undefined, portfolio);
|
|
};
|
|
|
|
const fetch = cb =>
|
|
this.gdax.getAccounts(this.processResponse('getPortfolio', cb));
|
|
retry(null, fetch, result);
|
|
};
|
|
|
|
Trader.prototype.getTicker = function(callback) {
|
|
const result = (err, data) => {
|
|
if (err) return callback(err);
|
|
callback(undefined, { bid: +data.bid, ask: +data.ask });
|
|
};
|
|
|
|
const fetch = cb =>
|
|
this.gdax_public.getProductTicker(this.pair, this.processResponse('getTicker', cb));
|
|
retry(null, fetch, result);
|
|
};
|
|
|
|
Trader.prototype.getFee = function(callback) {
|
|
//https://www.gdax.com/fees
|
|
// const fee = this.asset == 'BTC' ? 0.0025 : 0.003;
|
|
const fee = 0;
|
|
|
|
//There is no maker fee, not sure if we need taker fee here
|
|
//If post only is enabled, gdax only does maker trades which are free
|
|
callback(undefined, this.post_only ? 0 : fee);
|
|
};
|
|
|
|
Trader.prototype.roundPrice = function(price) {
|
|
return this.getMaxDecimalsNumber(price, this.currency == 'BTC' ? 5 : 2);
|
|
}
|
|
|
|
Trader.prototype.roundAmount = function(amount) {
|
|
return this.getMaxDecimalsNumber(amount);
|
|
}
|
|
|
|
Trader.prototype.buy = function(amount, price, callback) {
|
|
const buyParams = {
|
|
price: this.getMaxDecimalsNumber(price, this.currency == 'BTC' ? 5 : 2),
|
|
size: this.getMaxDecimalsNumber(amount),
|
|
product_id: this.pair,
|
|
post_only: this.post_only,
|
|
};
|
|
|
|
const result = (err, data) => {
|
|
if (err) {
|
|
console.log({buyParams}, err.message);
|
|
return callback(err);
|
|
}
|
|
callback(undefined, data.id);
|
|
};
|
|
|
|
const fetch = cb =>
|
|
this.gdax.buy(buyParams, this.processResponse('buy', cb));
|
|
retry(null, fetch, result);
|
|
};
|
|
|
|
Trader.prototype.sell = function(amount, price, callback) {
|
|
const sellParams = {
|
|
price: this.getMaxDecimalsNumber(price, this.currency == 'BTC' ? 5 : 2),
|
|
size: this.getMaxDecimalsNumber(amount),
|
|
product_id: this.pair,
|
|
post_only: this.post_only,
|
|
};
|
|
|
|
const result = (err, data) => {
|
|
if (err) {
|
|
console.log({sellParams}, err.message);
|
|
return callback(err);
|
|
}
|
|
|
|
if(data.message && data.message.includes('Insufficient funds')) {
|
|
err = new Error(data.message);
|
|
err.retryOnce = true;
|
|
return callback(err);
|
|
}
|
|
|
|
callback(undefined, data.id);
|
|
};
|
|
|
|
const fetch = cb =>
|
|
this.gdax.sell(sellParams, this.processResponse('sell', cb));
|
|
retry(null, fetch, result);
|
|
};
|
|
|
|
Trader.prototype.checkOrder = function(order, callback) {
|
|
const result = (err, data) => {
|
|
if (err) return callback(err);
|
|
|
|
// @link:
|
|
// https://stackoverflow.com/questions/48132078/available-gdax-order-statuses-and-meanings
|
|
var status = data.status;
|
|
if(status == 'pending') {
|
|
// technically not open yet, but will be soon
|
|
return callback(undefined, { executed: false, open: true, filledAmount: 0 });
|
|
} if (status === 'done' || status === 'settled') {
|
|
return callback(undefined, { executed: true, open: false });
|
|
} else if (status === 'rejected') {
|
|
return callback(undefined, { executed: false, open: false });
|
|
} else if(status === 'open' || status === 'active') {
|
|
return callback(undefined, { executed: false, open: true, filledAmount: parseFloat(data.filled_size) });
|
|
}
|
|
|
|
callback(new Error('Unknown status ' + status));
|
|
};
|
|
|
|
const fetch = cb =>
|
|
this.gdax.getOrder(order, this.processResponse('checkOrder', cb));
|
|
retry(null, fetch, result);
|
|
};
|
|
|
|
Trader.prototype.getOrder = function(order, callback) {
|
|
const result = (err, data) => {
|
|
if (err) return callback(err);
|
|
|
|
const price = parseFloat(data.price);
|
|
const amount = parseFloat(data.filled_size);
|
|
const date = moment(data.done_at);
|
|
const fees = {
|
|
// you always pay fee in the base currency on gdax
|
|
[this.currency]: +data.fill_fees
|
|
}
|
|
const feePercent = +data.fill_fees / price / amount * 100;
|
|
|
|
callback(undefined, { price, amount, date, fees, feePercent });
|
|
};
|
|
|
|
const fetch = cb =>
|
|
this.gdax.getOrder(order, this.processResponse('getOrder', cb));
|
|
retry(null, fetch, result);
|
|
};
|
|
|
|
Trader.prototype.cancelOrder = function(order, callback) {
|
|
// callback for cancelOrder should be true if the order was already filled, otherwise false
|
|
const result = (err, data) => {
|
|
if(err) {
|
|
return callback(null, true); // need to catch the specific error but usually an error on cancel means it was filled
|
|
}
|
|
|
|
return callback(null, false);
|
|
};
|
|
|
|
const fetch = cb =>
|
|
this.gdax.cancelOrder(order, this.processResponse('cancelOrder', cb));
|
|
retry(null, fetch, result);
|
|
};
|
|
|
|
Trader.prototype.getTrades = function(since, callback, descending) {
|
|
var lastScan = 0;
|
|
|
|
const handle = function(err, data) {
|
|
if (err) return callback(err);
|
|
|
|
var result = _.map(data, function(trade) {
|
|
return {
|
|
tid: trade.trade_id,
|
|
amount: parseFloat(trade.size),
|
|
date: moment.utc(trade.time).format('X'),
|
|
price: parseFloat(trade.price),
|
|
};
|
|
});
|
|
|
|
if (this.scanback) {
|
|
var last = _.last(data);
|
|
var first = _.first(data);
|
|
|
|
// Try to find trade id matching the since date
|
|
if (!this.scanbackTid) {
|
|
// either scan for new ones or we found it.
|
|
if (moment.utc(last.time) < moment.utc(since)) {
|
|
this.scanbackTid = last.trade_id;
|
|
} else {
|
|
console.log('Scanning backwards...' + last.time);
|
|
setTimeout(() => {
|
|
let handler = cb =>
|
|
this.gdax_public.getProductTrades(
|
|
this.pair,
|
|
{
|
|
after: last.trade_id - BATCH_SIZE * lastScan,
|
|
limit: BATCH_SIZE,
|
|
},
|
|
this.processResponse('getTrades', cb)
|
|
);
|
|
retry(
|
|
retryForever,
|
|
_.bind(handler, this),
|
|
_.bind(process, this)
|
|
);
|
|
}, QUERY_DELAY);
|
|
lastScan++;
|
|
if (lastScan > 100) {
|
|
lastScan = 10;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.scanbackTid) {
|
|
// if scanbackTid is set we need to move forward again
|
|
console.log(
|
|
'Backwards: ' +
|
|
last.time +
|
|
' (' +
|
|
last.trade_id +
|
|
') to ' +
|
|
first.time +
|
|
' (' +
|
|
first.trade_id +
|
|
')'
|
|
);
|
|
|
|
this.scanbackResults = this.scanbackResults.concat(result.reverse());
|
|
|
|
if (this.scanbackTid != first.trade_id) {
|
|
this.scanbackTid = first.trade_id;
|
|
setTimeout(() => {
|
|
let handler = cb =>
|
|
this.gdax_public.getProductTrades(
|
|
this.pair,
|
|
{ after: this.scanbackTid + BATCH_SIZE + 1, limit: BATCH_SIZE },
|
|
this.processResponse('getTrades', cb)
|
|
);
|
|
retry(
|
|
retryForever,
|
|
_.bind(handler, this),
|
|
_.bind(process, this)
|
|
);
|
|
}, QUERY_DELAY);
|
|
} else {
|
|
this.scanback = false;
|
|
this.scanbackTid = 0;
|
|
|
|
console.log('Scan finished: data found:' + this.scanbackResults.length);
|
|
callback(null, this.scanbackResults);
|
|
|
|
this.scanbackResults = [];
|
|
}
|
|
}
|
|
} else {
|
|
callback(null, result.reverse());
|
|
}
|
|
};
|
|
|
|
if (since || this.scanback) {
|
|
this.scanback = true;
|
|
if (this.scanbackTid) {
|
|
let handler = cb =>
|
|
this.gdax_public.getProductTrades(
|
|
this.pair,
|
|
{ after: this.scanbackTid + BATCH_SIZE + 1, limit: BATCH_SIZE },
|
|
this.processResponse('getTrades', cb)
|
|
);
|
|
retry(
|
|
retryForever,
|
|
_.bind(handler, this),
|
|
_.bind(process, this)
|
|
);
|
|
} else if (since) {
|
|
console.log('Scanning back in the history needed...', since);
|
|
}
|
|
}
|
|
|
|
const fetch = cb =>
|
|
this.gdax_public.getProductTrades(
|
|
this.pair,
|
|
{ limit: BATCH_SIZE },
|
|
this.processResponse('getTrades', cb)
|
|
);
|
|
retry(null, fetch, handle);
|
|
};
|
|
|
|
Trader.prototype.getMaxDecimalsNumber = function(number, decimalLimit = 8) {
|
|
var decimalNumber = parseFloat(number);
|
|
|
|
// The ^-?\d*\. strips off any sign, integer portion, and decimal point
|
|
// leaving only the decimal fraction.
|
|
// The 0+$ strips off any trailing zeroes.
|
|
var decimalCount = (+decimalNumber).toString().replace(/^-?\d*\.?|0+$/g, '')
|
|
.length;
|
|
|
|
var decimalMultiplier = 1;
|
|
for (i = 0; i < decimalLimit; i++) {
|
|
decimalMultiplier *= 10;
|
|
}
|
|
|
|
return decimalCount <= decimalLimit
|
|
? decimalNumber.toString()
|
|
: (
|
|
Math.floor(decimalNumber * decimalMultiplier) / decimalMultiplier
|
|
).toFixed(decimalLimit);
|
|
};
|
|
|
|
Trader.getCapabilities = function() {
|
|
return {
|
|
name: 'GDAX',
|
|
slug: 'gdax',
|
|
currencies: marketData.currencies,
|
|
assets: marketData.assets,
|
|
markets: marketData.markets,
|
|
requires: ['key', 'secret', 'passphrase'],
|
|
providesHistory: 'date',
|
|
providesFullHistory: true,
|
|
tid: 'tid',
|
|
tradable: true,
|
|
forceReorderDelay: false,
|
|
gekkoBroker: 0.6
|
|
};
|
|
};
|
|
|
|
module.exports = Trader; |