Get started with Trade class

The reason for isolating the trader class is testability. It's hard to
properly test code with as much global state as files in `lib/api` had.

With a `Trader` class we can easily instantiate multiple objects, with
different configs, without the need for seeding the database with test
configs, effectively enabling us to do unit tests as well as integration
tests.

Besides that, this will allow easier reconfiguration, thanks to
`Trader#configure` method.
This commit is contained in:
Maciej Małecki 2014-04-14 13:06:48 +02:00
parent b2d643cf1d
commit d6a0f17e80
2 changed files with 179 additions and 49 deletions

View file

@ -1,49 +0,0 @@
'use strict';
var _transferExchange;
var _tradeExchange;
var _api;
var _config;
var _balance = null;
var _balanceTriggers = [];
var winston = require('winston');
var logger = new (winston.Logger)({transports:[new (winston.transports.Console)()]});
var async = require('async');
exports.init = function(config, api, transferExchange, tradeExchange) {
_api = api;
_config = config;
_transferExchange = transferExchange;
_tradeExchange = tradeExchange;
_balanceTriggers = [function (cb) { _transferExchange.balance(cb); }];
if (tradeExchange)
_balanceTriggers.push(function(cb) { _tradeExchange.balance(cb); });
_pollBalance();
setInterval(_pollBalance, 60 * 1000);
};
exports.balance = function balance() {
return _balance;
};
exports.triggerBalance = _pollBalance;
function _pollBalance() {
logger.info('collecting balance');
async.parallel(_balanceTriggers, function(err, results) {
if (err) return;
_balance = {
transferBalance: results[0],
tradeBalance: results.length === 2 ? results[1] : null,
timestamp: Date.now()
};
logger.info('Balance update:', _balance);
});
}

179
lib/trader.js Normal file
View file

@ -0,0 +1,179 @@
'use strict';
var path = require('path');
var SATOSHI_FACTOR = Math.pow(10, 8);
var Trader = module.exports = function (db) {
if (!db) {
throw new Error('`db` is required');
}
this.db = db;
this.logger = new (winston.Logger)({
transports: [new (winston.transports.Console)()]
});
};
Trader.prototype._findExchange = function (name) {
var exchange;
try {
exchange = require('lamassu-' + name);
} catch (err) {
if (!err.message.match(/Cannot find module/)) throw err;
exchange = require(path.join(path.dirname(__dirname), 'exchanges', name));
}
return exchange;
};
Trader.prototype._findTicker = function (name) {
var exchange = Trader.prototype.findExchange(name);
return exchange.ticker || exchange;
};
Trader.prototype._findTrader = function (name) {
var exchange = Trader.prototype.findExchange(name);
return exchange.trader || exchange;
};
Trader.prototype._findWallet = function (name) {
var exchange = Trader.prototype.findExchange(name);
return exchange.wallet || exchange;
};
Trader.prototype.configure = function (config) {
if (config.settings.lowBalanceMargin < 1) {
throw new Error('`settings.lowBalanceMargin` has to be >= 1');
}
var tickerExchangeCode = config.plugins.current.ticker;
var tickerExchangeConfig = config.plugins.settings[tickerExchangeCode] || {};
tickerExchangeConfig.currency = config.settings.currency;
this.tickerExchange = exports.findTicker(tickerExchangeCode).factory(tickerExchangeConfig);
var tradeExchangeCode = config.plugins.current.trade;
if (tradeExchangeCode) {
var tradeExchangeConfig = config.plugins.settings[tradeExchangeCode];
this.tradeExchange = this._findTrader(tradeExchangeCode).factory(tradeExchangeConfig);
}
var transferExchangeCode = config.plugins.current.transfer;
var transferExchangeConfig = config.plugins.settings[transferExchangeCode];
this.transferExchange = this._findWallet(transferExchangeCode).factory(transferExchangeConfig);
this.config = config;
};
/**
* return fiat balance
*
* in input to this function, balance has the following parameters...
*
* balance.transferBalance - in satoshis
* balance.tradeBalance - in USD
*
* Have added conversion here, but this really needs to be thought through, lamassu-bitstamp should perhaps
* return balance in satoshis
*/
Trader.prototype.fiatBalance = function (transferSatoshis, tradeFiat) {
var rate = this.rate;
var balance = this.balance;
var commission = this.config.exchanges.settings.commission;
if (!rate || !balance) return 0;
// The rate is actually our commission times real rate.
rate = commission * rate;
// `lowBalanceMargin` is our safety net. It's a number > 1, and we divide
// all our balances by it to provide a safety margin.
var lowBalanceMargin = this.config.settings.lowBalanceMargin;
// `balance.transferBalance` is the balance of our transfer account (the one
// we use to send Bitcoins to clients). `transferSatoshis` is the number
// of satoshis we're expected to send for this transaction. By subtracting
// them, we get `adjustedTransferBalance`, amount of satoshis we'll have
// after the transaction.
var adjustedTransferBalance = balance.transferBalance - transferSatoshis;
// Since `adjustedTransferBalance` is in Satoshis, we need to turn it into
// Bitcoins and then fiat to learn how much fiat currency we can exchange.
//
// Unit validity proof: [ $ ] = [ (B * 10^8) / 10^8 * $/B ]
// [ $ ] = [ B * $/B ]
// [ $ ] = [ $ ]
var fiatTransferBalance = ((adjustedTransferBalance / SATOSHI_FACTOR) * rate) / lowBalanceMargin;
// If this server is also configured to trade received fiat for Bitcoins,
// we also need to calculate if we have enough funds on our trade exchange.
if (balance.tradeBalance === null) return fiatTransferBalance;
var tradeBalance = balance.tradeBalance;
// We need to secure `tradeFiat` (amount of fiat in this transaction) and
// enough fiat to cover our trading queue (trades aren't executed immediately).
var adjustedFiat = tradeFiat + exports.trade.queueFiatBalance(rate);
// So we subtract `adjustedFiat` from `tradeBalance` and again, apply
// `lowBalanceMargin`.
var fiatTradeBalance = (tradeBalance - adjustedFiat) / lowBalanceMargin;
// And we return the smallest number.
return Math.min(fiatTransferBalance, fiatTradeBalance);
};
Trader.prototype.sendBitcoins = function (deviceFingerprint, tx, cb) {
var self = this;
self.db.summonTransaction(deviceFingerprint, tx, function (err, isNew, txHash) {
if (err) return cb(err);
if (isNew) {
return self.transferExchange.sendBitcoins(
tx.toAddress,
tx.satoshis,
self.config.settings.transactionFee,
function(err, txHash) {
if (err) {
self.db.reportTransactionError(tx, err);
return cb(err);
}
cb(null, txHash);
self.db.completeTransaction(tx, txHash);
self.triggerBalance();
}
);
}
// transaction exists, but txHash might be null,
// in which case ATM should continue polling
cb(null, txHash);
});
};
Trader.prototype.startPolling = function () {
setInterval(this.pollBalance.bind(this), 60 * 1000);
};
Trader.prototype.pollBalance = function () {
var self = this;
self.logger.info('collecting balance');
async.parallel({
transferBalance: self.transferExchange.balance.bind(self.transferExchange),
tradeBalance: function (next) {
if (!self.tradeExchange) {
return next(null, null);
}
self.tradeExchange.balance(next);
}
}, function (err, balance) {
balance.timestamp = Date.now();
self.logger.info('Balance update:', balance);
self.balance = balance;
});
};