diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index dc85bfedb..8d36c51e3 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -11,6 +11,7 @@ from cachetools import cached, TTLCache from freqtrade import OperationalException from freqtrade.exchange.bittrex import Bittrex +from freqtrade.exchange.binance import Binance from freqtrade.exchange.interface import Exchange logger = logging.getLogger(__name__) @@ -28,6 +29,7 @@ class Exchanges(enum.Enum): Maps supported exchange names to correspondent classes. """ BITTREX = Bittrex + BINANCE = Binance def init(config: dict) -> None: @@ -143,14 +145,14 @@ def get_ticker_history(pair: str, tick_interval) -> List[Dict]: return _API.get_ticker_history(pair, tick_interval) -def cancel_order(order_id: str) -> None: +def cancel_order(order_id: str, pair: str) -> None: if _CONF['dry_run']: return - return _API.cancel_order(order_id) + return _API.cancel_order(order_id, pair) -def get_order(order_id: str) -> Dict: +def get_order(order_id: str, pair: str) -> Dict: if _CONF['dry_run']: order = _DRY_RUN_OPEN_ORDERS[order_id] order.update({ @@ -158,7 +160,7 @@ def get_order(order_id: str) -> Dict: }) return order - return _API.get_order(order_id) + return _API.get_order(order_id, pair) def get_pair_detail_url(pair: str) -> str: @@ -183,3 +185,7 @@ def get_fee() -> float: def get_wallet_health() -> List[Dict]: return _API.get_wallet_health() + + +def get_trade_qty(pair: str) -> tuple: + return _API.get_trade_qty(pair) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py new file mode 100644 index 000000000..c1dad53cd --- /dev/null +++ b/freqtrade/exchange/binance.py @@ -0,0 +1,374 @@ +import logging +import datetime +import json +import http +from typing import List, Dict, Optional + +from binance.client import Client as _Binance +from binance.exceptions import BinanceAPIException +from binance.enums import * +from decimal import Decimal + +from freqtrade import OperationalException +from freqtrade.exchange.interface import Exchange + +logger = logging.getLogger(__name__) + +_API: _Binance = None +_EXCHANGE_CONF: dict = {} +_CONF: dict = {} + + +class Binance(Exchange): + """ + Binance API wrapper. + """ + # Base URL and API endpoints + BASE_URL: str = 'https://www.binance.com' + + def __init__(self, config: dict) -> None: + global _API, _EXCHANGE_CONF, _CONF + + _EXCHANGE_CONF.update(config) + + _API = _Binance(_EXCHANGE_CONF['key'], _EXCHANGE_CONF['secret']) + + def _pair_to_symbol(self, pair, seperator='') -> str: + """ + Turns freqtrade pair into Binance symbol + - Freqtrade pair = _ + i.e.: BTC_XALT + - Binance symbol = + i.e.: XALTBTC + """ + + pair_currencies = pair.split('_') + + return '{0}{1}{2}'.format(pair_currencies[1], seperator, pair_currencies[0]) + + def _symbol_to_pair(self, symbol) -> str: + """ + Turns Binance symbol into freqtrade pair + - Freqtrade pair = _ + i.e.: BTC_XALT + - Binance symbol = + i.e.: XALTBTC + """ + stake = _EXCHANGE_CONF['stake_currency'] + + symbol_stake_currency = symbol[-len(stake):] + symbol_currency = symbol[:-len(stake)] + + return '{0}_{1}'.format(symbol_stake_currency, symbol_currency) + + @staticmethod + def _handle_exception(excepter) -> None: + """ + Validates the given Binance response/exception + and raises a ContentDecodingError if a non-fatal issue happened. + """ + # Could to alternate exception handling for specific exceptions/errors + # See: http://python-binance.readthedocs.io/en/latest/binance.html#module-binance.exceptions + if type(excepter) == http.client.RemoteDisconnected: + logger.info( + 'Got HTTP error from Binance: %s' % excepter + ) + return True + + if type(excepter) == json.decoder.JSONDecodeError: + logger.info( + 'Got JSON error from Binance: %s' % excepter + ) + return True + + if type(excepter) == BinanceAPIException: + + logger.info( + 'Got API error from Binance: %s' % excepter + ) + + return True + + raise type(excepter)(excepter.args) + + @property + def fee(self) -> float: + # 0.1 %: See https://support.binance.com/hc/en-us + # /articles/115000429332-Fee-Structure-on-Binance + return 0.001 + + def buy(self, pair: str, rate: float, amount: float) -> str: + + symbol = self._pair_to_symbol(pair) + + try: + data = _API.order_limit_buy( + symbol=symbol, + quantity="{0:.8f}".format(amount), + price="{0:.8f}".format(rate)) + except Exception as e: + Binance._handle_exception(e) + raise OperationalException('{message} params=({pair}, {rate}, {amount})'.format( + message=str(e), + pair=pair, + rate=Decimal(rate), + amount=Decimal(amount))) + + return data['orderId'] + + def sell(self, pair: str, rate: float, amount: float) -> str: + + symbol = self._pair_to_symbol(pair) + + try: + data = _API.order_limit_sell( + symbol=symbol, + quantity="{0:.8f}".format(amount), + price="{0:.8f}".format(rate)) + except Exception as e: + Binance._handle_exception(e) + raise OperationalException( + '{message} params=({pair}, {rate}, {amount})'.format( + message=str(e), + pair=pair, + rate=rate, + amount=amount)) + + return data['orderId'] + + def get_balance(self, currency: str) -> float: + + try: + data = _API.get_asset_balance(asset=currency) + except Exception as e: + Binance._handle_exception(e) + raise OperationalException('{message} params=({currency})'.format( + message=str(e), + currency=currency)) + + return float(data['free'] or 0.0) + + def get_balances(self) -> List[Dict]: + + try: + data = _API.get_account() + except Exception as e: + Binance._handle_exception(e) + raise OperationalException('{message}'.format(message=str(e))) + + balances = data['balances'] + + currency_balances = [] + for currency in balances: + balance = {} + + if float(currency['free']) == 0 and float(currency['locked']) == 0: + continue + balance['Currency'] = currency.pop('asset') + balance['Available'] = currency.pop('free') + balance['Pending'] = currency.pop('locked') + balance['Balance'] = float(balance['Available']) + float(balance['Pending']) + + currency_balances.append(balance) + + return currency_balances + + def get_ticker(self, pair: str, refresh: Optional[bool] = True) -> dict: + + symbol = self._pair_to_symbol(pair) + + try: + data = _API.get_ticker(symbol=symbol) + except Exception as e: + Binance._handle_exception(e) + raise OperationalException('{message} params=({pair})'.format( + message=str(e), + pair=pair)) + + return { + 'bid': float(data['bidPrice']), + 'ask': float(data['askPrice']), + 'last': float(data['lastPrice']), + } + + def get_ticker_history(self, pair: str, tick_interval: int) -> List[Dict]: + + INTERVAL_ENUM = eval('KLINE_INTERVAL_' + str(tick_interval) + 'MINUTE') + + if INTERVAL_ENUM in ['', None]: + raise ValueError('Cannot parse tick_interval: {}'.format(tick_interval)) + + symbol = self._pair_to_symbol(pair) + + try: + data = _API.get_klines(symbol=symbol, interval=INTERVAL_ENUM) + except Exception as e: + Binance._handle_exception(e) + raise OperationalException('{message} params=({pair})'.format( + message=str(e), + pair=pair)) + + tick_data = [] + + for tick in data: + t = {} + t['O'] = float(tick[1]) + t['H'] = float(tick[2]) + t['L'] = float(tick[3]) + t['C'] = float(tick[4]) + t['V'] = float(tick[5]) + t['T'] = datetime.datetime.fromtimestamp(int(tick[6])/1000).isoformat() + t['BV'] = float(tick[7]) + + tick_data.append(t) + + return tick_data + + def get_order(self, order_id: str, pair: str) -> Dict: + + symbol = self._pair_to_symbol(pair) + + try: + data = _API.get_all_orders(symbol=symbol, orderId=order_id) + except Exception as e: + Binance._handle_exception(e) + raise OperationalException( + '{message} params=({symbol},{order_id})'.format( + message=str(e), + symbol=symbol, + order_id=order_id)) + + order = {} + + for o in data: + + if o['orderId'] == int(order_id): + + order['id'] = o['orderId'] + order['type'] = "{}_{}".format(o['type'], o['side']) + order['pair'] = self._symbol_to_pair(o['symbol']) + order['opened'] = datetime.datetime.fromtimestamp( + int(o['time'])/1000).isoformat() + order['closed'] = datetime.datetime.fromtimestamp( + int(o['time'])/1000).isoformat()\ + if o['status'] == 'FILLED' else None + order['rate'] = float(o['price']) + order['amount'] = float(o['origQty']) + order['remaining'] = int(float(o['origQty']) - float(o['executedQty'])) + + return order + + def cancel_order(self, order_id: str, pair: str) -> None: + + symbol = self._pair_to_symbol(pair) + + try: + data = _API.cancel_order(symbol=symbol, orderId=order_id) + except Exception as e: + Binance._handle_exception(e) + raise OperationalException('{message} params=({order_id})'.format( + message=str(e), + order_id=order_id)) + + return data + + def get_pair_detail_url(self, pair: str) -> str: + symbol = self._pair_to_symbol(pair, '_') + return 'https://www.binance.com/indexSpa.html#/trade/index?symbol={}'.format(symbol) + + def get_markets(self) -> List[str]: + try: + data = _API.get_all_tickers() + except Exception as e: + Binance._handle_exception(e) + raise OperationalException('{message}'.format(message=str(e))) + + markets = [] + + stake = _EXCHANGE_CONF['stake_currency'] + + for t in data: + symbol = t['symbol'] + symbol_stake_currency = symbol[-len(stake):] + + if symbol_stake_currency == stake: + pair = self._symbol_to_pair(symbol) + markets.append(pair) + + return markets + + def get_market_summaries(self) -> List[Dict]: + + try: + data = _API.get_ticker() + except Exception as e: + Binance._handle_exception(e) + raise OperationalException('{message}'.format(message=str(e))) + + market_summaries = [] + + for t in data: + market = {} + + # Looks like this one is only one actually used + market['MarketName'] = self._symbol_to_pair(t['symbol']) + + market['High'] = t['highPrice'] + market['Low'] = t['lowPrice'] + market['Volume'] = t['volume'] + market['Last'] = t['lastPrice'] + market['TimeStamp'] = t['closeTime'] + market['BaseVolume'] = t['volume'] + market['Bid'] = t['bidPrice'] + market['Ask'] = t['askPrice'] + market['OpenBuyOrders'] = None # TODO: Implement me (or dont care) + market['OpenSellOrders'] = None # TODO: Implement me (or dont care) + market['PrevDay'] = t['prevClosePrice'] + market['Created'] = None # TODO: Implement me (or don't care) + + market_summaries.append(market) + + return market_summaries + + def get_trade_qty(self, pair: str) -> tuple: + + try: + data = _API.get_exchange_info() + except Exception as e: + Binance._handle_exception(e) + raise OperationalException('{message}'.format(message=str(e))) + + symbol = self._pair_to_symbol(pair) + + for s in data['symbols']: + + if symbol == s['symbol']: + + for f in s['filters']: + + if f['filterType'] == 'LOT_SIZE': + + return (float(f['minQty']), float(f['maxQty']), float(f['stepSize'])) + + return (None, None, None) + + def get_wallet_health(self) -> List[Dict]: + + try: + data = _API.get_exchange_info() + except Exception as e: + Binance._handle_exception(e) + raise OperationalException('{message}'.format(message=str(e))) + + wallet_health = [] + + for s in data['symbols']: + wallet = {} + wallet['Currency'] = s['baseAsset'] + wallet['IsActive'] = True if s['status'] == 'TRADING' else False + wallet['LastChecked'] = None # TODO + wallet['Notice'] = s['status'] if s['status'] != 'TRADING' else '' + + wallet_health.append(wallet) + + return wallet_health diff --git a/freqtrade/exchange/bittrex.py b/freqtrade/exchange/bittrex.py index 2be81be2d..63ad61d5e 100644 --- a/freqtrade/exchange/bittrex.py +++ b/freqtrade/exchange/bittrex.py @@ -157,7 +157,8 @@ class Bittrex(Exchange): return data['result'] - def get_order(self, order_id: str) -> Dict: + def get_order(self, order_id: str, pair: str) -> Dict: + data = _API.get_order(order_id) if not data['success']: Bittrex._validate_response(data) @@ -176,7 +177,8 @@ class Bittrex(Exchange): 'closed': data['Closed'], } - def cancel_order(self, order_id: str) -> None: + def cancel_order(self, order_id: str, pair: str) -> None: + data = _API.cancel(order_id) if not data['success']: Bittrex._validate_response(data) @@ -212,3 +214,6 @@ class Bittrex(Exchange): 'LastChecked': entry['Health']['LastChecked'], 'Notice': entry['Currency'].get('Notice'), } for entry in data['result']] + + def get_trade_qty(self, pair: str) -> tuple: + return (None, None, None) diff --git a/freqtrade/exchange/interface.py b/freqtrade/exchange/interface.py index 6121a98b3..cb1b01c5a 100644 --- a/freqtrade/exchange/interface.py +++ b/freqtrade/exchange/interface.py @@ -94,7 +94,7 @@ class Exchange(ABC): ] """ - def get_order(self, order_id: str) -> Dict: + def get_order(self, order_id: str, pair: str) -> Dict: """ Get order details for the given order_id. :param order_id: ID as str @@ -111,7 +111,7 @@ class Exchange(ABC): """ @abstractmethod - def cancel_order(self, order_id: str) -> None: + def cancel_order(self, order_id: str, pair: str) -> None: """ Cancels order for given order_id. :param order_id: ID as str @@ -170,3 +170,11 @@ class Exchange(ABC): }, ... """ + + @abstractmethod + def get_trade_qty(self, pair: str) -> tuple: + """ + Returns a tuple of trade quantity limits + :return: tuple, format: ( min_qty: str, max_qty: str, step_qty: str ) + ... + """ diff --git a/freqtrade/main.py b/freqtrade/main.py index 163597eee..3b365f549 100755 --- a/freqtrade/main.py +++ b/freqtrade/main.py @@ -84,7 +84,7 @@ def process_maybe_execute_sell(trade: Trade, interval: int) -> bool: if trade.open_order_id: # Update trade with order values logger.info('Got open order for %s', trade) - trade.update(exchange.get_order(trade.open_order_id)) + trade.update(exchange.get_order(trade.open_order_id, trade.pair)) if trade.is_open and trade.open_order_id is None: # Check if we can sell our current pair @@ -151,7 +151,7 @@ def handle_timedout_limit_buy(trade: Trade, order: Dict) -> bool: """Buy timeout - cancel order :return: True if order was fully cancelled """ - exchange.cancel_order(trade.open_order_id) + exchange.cancel_order(trade.open_order_id, trade.pair) if order['remaining'] == order['amount']: # if trade is not partially completed, just delete the trade Trade.session.delete(trade) @@ -182,7 +182,7 @@ def handle_timedout_limit_sell(trade: Trade, order: Dict) -> bool: """ if order['remaining'] == order['amount']: # if trade is not partially completed, just cancel the trade - exchange.cancel_order(trade.open_order_id) + exchange.cancel_order(trade.open_order_id, trade.pair) trade.close_rate = None trade.close_profit = None trade.close_date = None @@ -207,7 +207,7 @@ def check_handle_timedout(timeoutvalue: int) -> None: for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all(): try: - order = exchange.get_order(trade.open_order_id) + order = exchange.get_order(trade.open_order_id, trade.pair) except requests.exceptions.RequestException: logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc()) continue @@ -400,15 +400,42 @@ def create_trade(stake_amount: float, interval: int) -> bool: else: return False - # Calculate amount + min_qty = None + max_qty = None + step_qty = None + + (min_qty, max_qty, step_qty) = exchange.get_trade_qty(pair) + + # Calculate bid price buy_limit = get_target_bid(exchange.get_ticker(pair)) + + # Calculate base amount amount = stake_amount / buy_limit - order_id = exchange.buy(pair, buy_limit, amount) + # if amount above max qty: just buy max qty + if max_qty: + if amount > max_qty: + amount = max_qty + + if min_qty: + if amount < min_qty: + raise DependencyException( + 'stake amount is too low (min_qty={})'.format(min_qty) + ) + + # make trade exact amount of step qty + if step_qty: + real_amount = (amount // step_qty) * step_qty + else: + real_amount = amount + + order_id = exchange.buy(pair, buy_limit, real_amount) + + real_stake_amount = buy_limit * real_amount fiat_converter = CryptoToFiatConverter() stake_amount_fiat = fiat_converter.convert_amount( - stake_amount, + real_stake_amount, _CONF['stake_currency'], _CONF['fiat_display_currency'] ) @@ -418,7 +445,7 @@ def create_trade(stake_amount: float, interval: int) -> bool: exchange.get_name().upper(), pair.replace('_', '/'), exchange.get_pair_detail_url(pair), - buy_limit, stake_amount, _CONF['stake_currency'], + buy_limit, real_stake_amount, _CONF['stake_currency'], stake_amount_fiat, _CONF['fiat_display_currency'] )) # Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index d6d016aba..542ae19f6 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -6,12 +6,12 @@ from typing import Dict, Tuple import arrow from pandas import DataFrame, Series from tabulate import tabulate +from freqtrade import OperationalException import freqtrade.misc as misc import freqtrade.optimize as optimize from freqtrade import exchange from freqtrade.analyze import populate_buy_trend, populate_sell_trend -from freqtrade.exchange import Bittrex from freqtrade.main import should_sell from freqtrade.persistence import Trade from freqtrade.strategy.strategy import Strategy @@ -116,7 +116,19 @@ def backtest(args) -> DataFrame: records = [] trades = [] trade_count_lock: dict = {} - exchange._API = Bittrex({'key': '', 'secret': ''}) + + # Monkey patch config + from freqtrade import main + exchange_config = main._CONF['exchange'] + + exchange_name = exchange_config['name'] + try: + exchange_class = exchange.Exchanges[exchange_name.upper()].value + except KeyError: + raise OperationalException('Exchange {} is not supported'.format( + exchange_name)) + + exchange._API = exchange_class({'key': '', 'secret': ''}) for pair, pair_data in processed.items(): pair_data['buy'], pair_data['sell'] = 0, 0 ticker = populate_sell_trend(populate_buy_trend(pair_data)) @@ -167,8 +179,6 @@ def start(args): format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', ) - exchange._API = Bittrex({'key': '', 'secret': ''}) - logger.info('Using config: %s ...', args.config) config = misc.load_config(args.config) @@ -184,6 +194,15 @@ def start(args): logger.info('Using ticker_interval: %d ...', strategy.ticker_interval) + exchange_name = config['exchange']['name'] + try: + exchange_class = exchange.Exchanges[exchange_name.upper()].value + except KeyError: + raise OperationalException('Exchange {} is not supported'.format( + exchange_name)) + + exchange._API = exchange_class({'key': '', 'secret': ''}) + data = {} pairs = config['exchange']['pair_whitelist'] if args.live: diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index ea170baa1..0cbff321d 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -396,6 +396,43 @@ def _version(bot: Bot, update: Update) -> None: send_msg('*Version:* `{}`'.format(__version__), bot=bot) +<<<<<<< HEAD +======= +def shorten_date(_date): + """ + Trim the date so it fits on small screens + """ + new_date = re.sub('seconds?', 'sec', _date) + new_date = re.sub('minutes?', 'min', new_date) + new_date = re.sub('hours?', 'h', new_date) + new_date = re.sub('days?', 'd', new_date) + new_date = re.sub('^an?', '1', new_date) + return new_date + + +def _exec_forcesell(trade: Trade) -> None: + # Check if there is there is an open order + if trade.open_order_id: + order = exchange.get_order(trade.open_order_id, trade.pair) + + # Cancel open LIMIT_BUY orders and close trade + if order and not order['closed'] and order['type'] == 'LIMIT_BUY': + exchange.cancel_order(trade.open_order_id, trade.pair) + trade.close(order.get('rate') or trade.open_rate) + # TODO: sell amount which has been bought already + return + + # Ignore trades with an attached LIMIT_SELL order + if order and not order['closed'] and order['type'] == 'LIMIT_SELL': + return + + # Get current rate and execute sell + current_rate = exchange.get_ticker(trade.pair, False)['bid'] + from freqtrade.main import execute_sell + execute_sell(trade, current_rate) + + +>>>>>>> Add Binance exchange support def send_msg(msg: str, bot: Bot = None, parse_mode: ParseMode = ParseMode.MARKDOWN) -> None: """ Send given markdown message diff --git a/freqtrade/tests/exchange/test_exchange.py b/freqtrade/tests/exchange/test_exchange.py index 526ff54ab..605e7d9ab 100644 --- a/freqtrade/tests/exchange/test_exchange.py +++ b/freqtrade/tests/exchange/test_exchange.py @@ -227,7 +227,7 @@ def test_cancel_order_dry_run(default_conf, mocker): default_conf['dry_run'] = True mocker.patch.dict('freqtrade.exchange._CONF', default_conf) - assert cancel_order(order_id='123') is None + assert cancel_order(order_id='123',pair='ABC_XYZ') is None # Ensure that if not dry_run, we should call API @@ -237,7 +237,7 @@ def test_cancel_order(default_conf, mocker): api_mock = MagicMock() api_mock.cancel_order = MagicMock(return_value=123) mocker.patch('freqtrade.exchange._API', api_mock) - assert cancel_order(order_id='_') == 123 + assert cancel_order(order_id='_',pair='ABC_XYZ') == 123 def test_get_order(default_conf, mocker): @@ -245,16 +245,18 @@ def test_get_order(default_conf, mocker): mocker.patch.dict('freqtrade.exchange._CONF', default_conf) order = MagicMock() order.myid = 123 + order.pair = 'ABC_XYZ' exchange._DRY_RUN_OPEN_ORDERS['X'] = order - print(exchange.get_order('X')) - assert exchange.get_order('X').myid == 123 + print(exchange.get_order('X','ABC_XYZ')) + assert exchange.get_order('X','ABC_XYZ').myid == 123 + assert exchange.get_order('X','ABC_XYZ').pair == 'ABC_XYZ' default_conf['dry_run'] = False mocker.patch.dict('freqtrade.exchange._CONF', default_conf) api_mock = MagicMock() api_mock.get_order = MagicMock(return_value=456) mocker.patch('freqtrade.exchange._API', api_mock) - assert exchange.get_order('X') == 456 + assert exchange.get_order('X', 'ABC_XYZ') == 456 def test_get_name(default_conf, mocker): diff --git a/freqtrade/tests/exchange/test_exchange_binance.py b/freqtrade/tests/exchange/test_exchange_binance.py new file mode 100644 index 000000000..3c8ea909d --- /dev/null +++ b/freqtrade/tests/exchange/test_exchange_binance.py @@ -0,0 +1,349 @@ +# pragma pylint: disable=missing-docstring, C0103, protected-access, unused-argument + +from unittest.mock import MagicMock +import pytest +from freqtrade.exchange.binance import Binance +import freqtrade.exchange.binance as bin + + +# Eat this flake8 +# +------------------+ +# | binance.Binance | +# +------------------+ +# | +# (mock Fake_binance) +# | +# +-----------------------------+ +# | freqtrade.exchange.Binance | +# +-----------------------------+ +# Call into Binance will flow up to the +# external package binance.Binance. +# By inserting a mock, we redirect those +# calls. +# The faked binance API is called just 'fb' +# The freqtrade.exchange.Binance is a +# wrapper, and is called 'wb' + + +def _stub_config(): + return {'key': '', + 'secret': ''} + + +class FakeBinance(): + def __init__(self, success=True): + self.success = True # Believe in yourself + self.result = None + self.get_ticker_call_count = 0 + # This is really ugly, doing side-effect during instance creation + # But we're allowed to in testing-code + bin._API = MagicMock() + bin._API.order_limit_buy = self.fake_order_limit_buy + bin._API.order_limit_sell = self.fake_order_limit_sell + bin._API.get_asset_balance = self.fake_get_asset_balance + bin._API.get_account = self.fake_get_account + bin._API.get_ticker = self.fake_get_ticker + bin._API.get_klines = self.fake_get_klines + bin._API.get_all_orders = self.fake_get_all_orders + bin._API.cancel_order = self.fake_cancel_order + bin._API.get_all_tickers = self.fake_get_all_tickers + bin._API.get_exchange_info = self.fake_get_exchange_info + bin._EXCHANGE_CONF = {'stake_currency': 'BTC'} + + def fake_order_limit_buy(self, symbol, quantity, price): + return {"symbol": "BTCETH", + "orderId": 42, + "clientOrderId": "6gCrw2kRUAF9CvJDGP16IP", + "transactTime": 1507725176595, + "price": "0.00000000", + "origQty": "10.00000000", + "executedQty": "10.00000000", + "status": "FILLED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "BUY"} + + def fake_order_limit_sell(self, symbol, quantity, price): + return {"symbol": "BTCETH", + "orderId": 42, + "clientOrderId": "6gCrw2kRUAF9CvJDGP16IP", + "transactTime": 1507725176595, + "price": "0.00000000", + "origQty": "10.00000000", + "executedQty": "10.00000000", + "status": "FILLED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "SELL"} + + def fake_get_asset_balance(self, asset): + return { + "asset": "BTC", + "free": "4723846.89208129", + "locked": "0.00000000" + } + + def fake_get_account(self): + return { + "makerCommission": 15, + "takerCommission": 15, + "buyerCommission": 0, + "sellerCommission": 0, + "canTrade": True, + "canWithdraw": True, + "canDeposit": True, + "balances": [ + { + "asset": "BTC", + "free": "4723846.89208129", + "locked": "0.00000000" + }, + { + "asset": "LTC", + "free": "4763368.68006011", + "locked": "0.00000000" + } + ] + } + + def fake_get_ticker(self, symbol=None): + self.get_ticker_call_count += 1 + t = {"symbol": "ETHBTC", + "priceChange": "-94.99999800", + "priceChangePercent": "-95.960", + "weightedAvgPrice": "0.29628482", + "prevClosePrice": "0.10002000", + "lastPrice": "4.00000200", + "bidPrice": "4.00000000", + "askPrice": "4.00000200", + "openPrice": "99.00000000", + "highPrice": "100.00000000", + "lowPrice": "0.10000000", + "volume": "8913.30000000", + "openTime": 1499783499040, + "closeTime": 1499869899040, + "fristId": 28385, + "lastId": 28460, + "count": 76} + return t if symbol else [t] + + def fake_get_klines(self, symbol, interval): + return [[0, + "0", + "0", + "0", + "0", + "0", + 0, + "0", + 0, + "0", + "0", + "0"]] + + def fake_get_all_orders(self, symbol, orderId): + return [{"symbol": "LTCBTC", + "orderId": 42, + "clientOrderId": "myOrder1", + "price": "0.1", + "origQty": "1.0", + "executedQty": "0.0", + "status": "NEW", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "BUY", + "stopPrice": "0.0", + "icebergQty": "0.0", + "status_code": "200", + "time": 1499827319559}] + + def fake_cancel_order(self, symbol, orderId): + return {"symbol": "LTCBTC", + "origClientOrderId": "myOrder1", + "orderId": 42, + "clientOrderId": "cancelMyOrder1"} + + def fake_get_all_tickers(self): + return [{"symbol": "LTCBTC", + "price": "4.00000200"}, + {"symbol": "ETHBTC", + "price": "0.07946600"}] + + def fake_get_exchange_info(self): + return { + "timezone": "UTC", + "serverTime": 1508631584636, + "rateLimits": [ + { + "rateLimitType": "REQUESTS", + "interval": "MINUTE", + "limit": 1200 + }, + { + "rateLimitType": "ORDERS", + "interval": "SECOND", + "limit": 10 + }, + { + "rateLimitType": "ORDERS", + "interval": "DAY", + "limit": 100000 + } + ], + "exchangeFilters": [], + "symbols": [ + { + "symbol": "ETHBTC", + "status": "TRADING", + "baseAsset": "ETH", + "baseAssetPrecision": 8, + "quoteAsset": "BTC", + "quotePrecision": 8, + "orderTypes": ["LIMIT", "MARKET"], + "icebergAllowed": False, + "filters": [ + { + "filterType": "PRICE_FILTER", + "minPrice": "0.00000100", + "maxPrice": "100000.00000000", + "tickSize": "0.00000100" + }, { + "filterType": "LOT_SIZE", + "minQty": "0.00100000", + "maxQty": "100000.00000000", + "stepSize": "0.00100000" + }, { + "filterType": "MIN_NOTIONAL", + "minNotional": "0.00100000" + } + ] + } + ] + } + + +# The freqtrade.exchange.binance is called wrap_binance +# to not confuse naming with binance.binance +def make_wrap_binance(): + conf = _stub_config() + wb = bin.Binance(conf) + return wb + + +def test_exchange_binance_class(): + conf = _stub_config() + b = Binance(conf) + assert isinstance(b, Binance) + slots = dir(b) + for name in ['fee', 'buy', 'sell', 'get_balance', 'get_balances', + 'get_ticker', 'get_ticker_history', 'get_order', + 'cancel_order', 'get_pair_detail_url', 'get_markets', + 'get_market_summaries', 'get_wallet_health']: + assert name in slots + # FIX: ensure that the slot is also a method in the class + # getattr(b, name) => bound method Binance.buy + # type(getattr(b, name)) => class 'method' + + +def test_exchange_binance_fee(): + fee = Binance.fee.__get__(Binance) + assert fee >= 0 and fee < 0.1 # Fee is 0-10 % + + +def test_exchange_binance_buy_good(): + wb = make_wrap_binance() + fb = FakeBinance() + uuid = wb.buy('BTC_ETH', 1, 1) + assert uuid == fb.fake_order_limit_buy(1, 2, 3)['orderId'] + + with pytest.raises(IndexError, match=r'.*'): + wb.buy('BAD', 1, 1) + + +def test_exchange_binance_sell_good(): + wb = make_wrap_binance() + fb = FakeBinance() + uuid = wb.sell('BTC_ETH', 1, 1) + assert uuid == fb.fake_order_limit_sell(1, 2, 3)['orderId'] + + with pytest.raises(IndexError, match=r'.*'): + uuid = wb.sell('BAD', 1, 1) + + +def test_exchange_binance_get_balance(): + wb = make_wrap_binance() + fb = FakeBinance() + bal = wb.get_balance('BTC') + assert str(bal) == fb.fake_get_asset_balance(1)['free'] + + +def test_exchange_binance_get_balances(): + wb = make_wrap_binance() + fb = FakeBinance() + bals = wb.get_balances() + assert len(bals) <= len(fb.fake_get_account()['balances']) + + +def test_exchange_binance_get_ticker(): + wb = make_wrap_binance() + FakeBinance() + + # Poll ticker, which updates the cache + tick = wb.get_ticker('BTC_ETH') + for x in ['bid', 'ask', 'last']: + assert x in tick + + +def test_exchange_binance_get_ticker_history_intervals(): + wb = make_wrap_binance() + FakeBinance() + for tick_interval in [1, 5]: + assert ([{'O': 0.0, 'H': 0.0, + 'L': 0.0, 'C': 0.0, + 'V': 0.0, 'T': '1970-01-01T01:00:00', 'BV': 0.0}] == + wb.get_ticker_history('BTC_ETH', tick_interval)) + + +def test_exchange_binance_get_ticker_history(): + wb = make_wrap_binance() + FakeBinance() + assert wb.get_ticker_history('BTC_ETH', 5) + + +def test_exchange_binance_get_order(): + wb = make_wrap_binance() + FakeBinance() + order = wb.get_order('42', 'BTC_LTC') + assert order['id'] == 42 + + +def test_exchange_binance_cancel_order(): + wb = make_wrap_binance() + FakeBinance() + assert wb.cancel_order('42', 'BTC_LTC')['orderId'] == 42 + + +def test_exchange_get_pair_detail_url(): + wb = make_wrap_binance() + FakeBinance() + assert wb.get_pair_detail_url('BTC_ETH') + + +def test_exchange_get_markets(): + wb = make_wrap_binance() + FakeBinance() + x = wb.get_markets() + assert len(x) > 0 + + +def test_exchange_get_market_summaries(): + wb = make_wrap_binance() + FakeBinance() + assert wb.get_market_summaries() + + +def test_exchange_get_wallet_health(): + wb = make_wrap_binance() + FakeBinance() + x = wb.get_wallet_health() + assert x[0]['Currency'] == 'ETH' diff --git a/freqtrade/tests/exchange/test_exchange_bittrex.py b/freqtrade/tests/exchange/test_exchange_bittrex.py index 99a964815..7f5a53cfd 100644 --- a/freqtrade/tests/exchange/test_exchange_bittrex.py +++ b/freqtrade/tests/exchange/test_exchange_bittrex.py @@ -264,27 +264,27 @@ def test_exchange_bittrex_get_ticker_history(): def test_exchange_bittrex_get_order(): wb = make_wrap_bittrex() fb = FakeBittrex() - order = wb.get_order('someUUID') + order = wb.get_order('someUUID','somePAIR') assert order['id'] == 'ABC123' fb.success = False with pytest.raises(btx.OperationalException, match=r'lost'): - wb.get_order('someUUID') + wb.get_order('someUUID','somePAIR') def test_exchange_bittrex_cancel_order(): wb = make_wrap_bittrex() fb = FakeBittrex() - wb.cancel_order('someUUID') + wb.cancel_order('someUUID','somePAIR') with pytest.raises(btx.OperationalException, match=r'no such order'): fb.success = False - wb.cancel_order('someUUID') + wb.cancel_order('someUUID','somePAIR') # Note: this can be a bug in exchange.bittrex._validate_response with pytest.raises(KeyError): fb.result = {'success': False} # message is missing! - wb.cancel_order('someUUID') + wb.cancel_order('someUUID','somePAIR') with pytest.raises(btx.OperationalException, match=r'foo'): fb.result = {'success': False, 'message': 'foo'} - wb.cancel_order('someUUID') + wb.cancel_order('someUUID','somePAIR') def test_exchange_get_pair_detail_url(): diff --git a/requirements.txt b/requirements.txt index b65dcfc2e..ae4f8b91b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ python-bittrex==0.3.0 +python-binance==0.6.1 SQLAlchemy==1.2.2 python-telegram-bot==9.0.0 arrow==0.12.1