From 55a69e4a4599f681cd8296c13021f95c917ce8b3 Mon Sep 17 00:00:00 2001 From: gcarq Date: Mon, 20 Nov 2017 22:15:19 +0100 Subject: [PATCH] use normal program flow to handle interrupts --- freqtrade/__init__.py | 14 ++++++- freqtrade/exchange/__init__.py | 10 +++-- freqtrade/exchange/bittrex.py | 23 ++++++----- freqtrade/main.py | 69 +++++++++++++++++--------------- freqtrade/misc.py | 4 -- freqtrade/tests/test_exchange.py | 5 ++- freqtrade/tests/test_main.py | 13 +++--- 7 files changed, 78 insertions(+), 60 deletions(-) diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index 8ece07269..a190ca117 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,4 +1,16 @@ """ FreqTrade bot """ __version__ = '0.14.3' -from . import main + +class DependencyException(BaseException): + """ + Indicates that a assumed dependency is not met. + This could happen when there is currently not enough money on the account. + """ + + +class OperationalException(BaseException): + """ + Requires manual intervention. + This happens when an exchange returns an unexpected error during runtime. + """ diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index 98ade43a0..a1f039820 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -11,6 +11,7 @@ from cachetools import cached, TTLCache from freqtrade.exchange.bittrex import Bittrex from freqtrade.exchange.interface import Exchange +from freqtrade import OperationalException logger = logging.getLogger(__name__) @@ -51,7 +52,7 @@ def init(config: dict) -> None: try: exchange_class = Exchanges[name.upper()].value except KeyError: - raise RuntimeError('Exchange {} is not supported'.format(name)) + raise OperationalException('Exchange {} is not supported'.format(name)) _API = exchange_class(exchange_config) @@ -62,7 +63,7 @@ def init(config: dict) -> None: def validate_pairs(pairs: List[str]) -> None: """ Checks if all given pairs are tradable on the current exchange. - Raises RuntimeError if one pair is not available. + Raises OperationalException if one pair is not available. :param pairs: list of pairs :return: None """ @@ -75,11 +76,12 @@ def validate_pairs(pairs: List[str]) -> None: stake_cur = _CONF['stake_currency'] for pair in pairs: if not pair.startswith(stake_cur): - raise RuntimeError( + raise OperationalException( 'Pair {} not compatible with stake_currency: {}'.format(pair, stake_cur) ) if pair not in markets: - raise RuntimeError('Pair {} is not available at {}'.format(pair, _API.name.lower())) + raise OperationalException( + 'Pair {} is not available at {}'.format(pair, _API.name.lower())) def buy(pair: str, rate: float, amount: float) -> str: diff --git a/freqtrade/exchange/bittrex.py b/freqtrade/exchange/bittrex.py index 37793530f..32e29184b 100644 --- a/freqtrade/exchange/bittrex.py +++ b/freqtrade/exchange/bittrex.py @@ -5,6 +5,7 @@ from bittrex.bittrex import Bittrex as _Bittrex, API_V2_0, API_V1_1 from requests.exceptions import ContentDecodingError from freqtrade.exchange.interface import Exchange +from freqtrade import OperationalException logger = logging.getLogger(__name__) @@ -46,7 +47,7 @@ class Bittrex(Exchange): def buy(self, pair: str, rate: float, amount: float) -> str: data = _API.buy_limit(pair.replace('_', '-'), amount, rate) if not data['success']: - raise RuntimeError('{message} params=({pair}, {rate}, {amount})'.format( + raise OperationalException('{message} params=({pair}, {rate}, {amount})'.format( message=data['message'], pair=pair, rate=rate, @@ -56,7 +57,7 @@ class Bittrex(Exchange): def sell(self, pair: str, rate: float, amount: float) -> str: data = _API.sell_limit(pair.replace('_', '-'), amount, rate) if not data['success']: - raise RuntimeError('{message} params=({pair}, {rate}, {amount})'.format( + raise OperationalException('{message} params=({pair}, {rate}, {amount})'.format( message=data['message'], pair=pair, rate=rate, @@ -66,7 +67,7 @@ class Bittrex(Exchange): def get_balance(self, currency: str) -> float: data = _API.get_balance(currency) if not data['success']: - raise RuntimeError('{message} params=({currency})'.format( + raise OperationalException('{message} params=({currency})'.format( message=data['message'], currency=currency)) return float(data['result']['Balance'] or 0.0) @@ -74,13 +75,13 @@ class Bittrex(Exchange): def get_balances(self): data = _API.get_balances() if not data['success']: - raise RuntimeError('{message}'.format(message=data['message'])) + raise OperationalException('{message}'.format(message=data['message'])) return data['result'] def get_ticker(self, pair: str) -> dict: data = _API.get_ticker(pair.replace('_', '-')) if not data['success']: - raise RuntimeError('{message} params=({pair})'.format( + raise OperationalException('{message} params=({pair})'.format( message=data['message'], pair=pair)) @@ -121,7 +122,7 @@ class Bittrex(Exchange): pair=pair)) if not data['success']: - raise RuntimeError('{message} params=({pair})'.format( + raise OperationalException('{message} params=({pair})'.format( message=data['message'], pair=pair)) @@ -130,7 +131,7 @@ class Bittrex(Exchange): def get_order(self, order_id: str) -> Dict: data = _API.get_order(order_id) if not data['success']: - raise RuntimeError('{message} params=({order_id})'.format( + raise OperationalException('{message} params=({order_id})'.format( message=data['message'], order_id=order_id)) data = data['result'] @@ -148,7 +149,7 @@ class Bittrex(Exchange): def cancel_order(self, order_id: str) -> None: data = _API.cancel(order_id) if not data['success']: - raise RuntimeError('{message} params=({order_id})'.format( + raise OperationalException('{message} params=({order_id})'.format( message=data['message'], order_id=order_id)) @@ -158,19 +159,19 @@ class Bittrex(Exchange): def get_markets(self) -> List[str]: data = _API.get_markets() if not data['success']: - raise RuntimeError('{message}'.format(message=data['message'])) + raise OperationalException('{message}'.format(message=data['message'])) return [m['MarketName'].replace('-', '_') for m in data['result']] def get_market_summaries(self) -> List[Dict]: data = _API.get_market_summaries() if not data['success']: - raise RuntimeError('{message}'.format(message=data['message'])) + raise OperationalException('{message}'.format(message=data['message'])) return data['result'] def get_wallet_health(self) -> List[Dict]: data = _API_V2.get_wallet_health() if not data['success']: - raise RuntimeError('{message}'.format(message=data['message'])) + raise OperationalException('{message}'.format(message=data['message'])) return [{ 'Currency': entry['Health']['Currency'], 'IsActive': entry['Health']['IsActive'], diff --git a/freqtrade/main.py b/freqtrade/main.py index dc66540cb..1c2137569 100755 --- a/freqtrade/main.py +++ b/freqtrade/main.py @@ -6,16 +6,16 @@ import sys import time import traceback from datetime import datetime -from signal import signal, SIGINT, SIGABRT, SIGTERM from typing import Dict, Optional, List import requests from cachetools import cached, TTLCache -from freqtrade import __version__, exchange, persistence, rpc +from freqtrade import __version__, exchange, persistence, rpc, DependencyException, \ + OperationalException from freqtrade.analyze import get_signal, SignalType from freqtrade.misc import State, get_state, update_state, parse_args, throttle, \ - load_config, FreqtradeException + load_config from freqtrade.persistence import Trade logger = logging.getLogger('freqtrade') @@ -76,7 +76,7 @@ def _process(dynamic_whitelist: Optional[bool] = False) -> bool: 'Checked all whitelisted currencies. ' 'Found no suitable entry positions for buying. Will keep looking ...' ) - except FreqtradeException as e: + except DependencyException as e: logger.warning('Unable to create trade: %s', e) for trade in trades: @@ -97,12 +97,12 @@ def _process(dynamic_whitelist: Optional[bool] = False) -> bool: error ) time.sleep(30) - except RuntimeError: - rpc.send_msg('*Status:* Got RuntimeError:\n```\n{traceback}```{hint}'.format( + except OperationalException: + rpc.send_msg('*Status:* Got OperationalException:\n```\n{traceback}```{hint}'.format( traceback=traceback.format_exc(), hint='Issue `/start` if you think it is safe to restart.' )) - logger.exception('Got RuntimeError. Stopping trader ...') + logger.exception('Got OperationalException. Stopping trader ...') update_state(State.STOPPED) return state_changed @@ -185,7 +185,7 @@ def create_trade(stake_amount: float) -> Optional[Trade]: whitelist = copy.deepcopy(_CONF['exchange']['pair_whitelist']) # Check if stake_amount is fulfilled if exchange.get_balance(_CONF['stake_currency']) < stake_amount: - raise FreqtradeException( + raise DependencyException( 'stake amount is not fulfilled (currency={})'.format(_CONF['stake_currency']) ) @@ -195,7 +195,7 @@ def create_trade(stake_amount: float) -> Optional[Trade]: whitelist.remove(trade.pair) logger.debug('Ignoring %s in pair whitelist', trade.pair) if not whitelist: - raise FreqtradeException('No pair in whitelist') + raise DependencyException('No pair in whitelist') # Pick pair based on StochRSI buy signals for _pair in whitelist: @@ -248,10 +248,6 @@ def init(config: dict, db_url: Optional[str] = None) -> None: else: update_state(State.STOPPED) - # Register signal handlers - for sig in (SIGINT, SIGTERM, SIGABRT): - signal(sig, cleanup) - @cached(TTLCache(maxsize=1, ttl=1800)) def gen_pair_whitelist(base_currency: str, topn: int = 20, key: str = 'BaseVolume') -> List[str]: @@ -270,7 +266,7 @@ def gen_pair_whitelist(base_currency: str, topn: int = 20, key: str = 'BaseVolum return [s['MarketName'].replace('-', '_') for s in summaries[:topn]] -def cleanup(*args, **kwargs) -> None: +def cleanup() -> None: """ Cleanup the application state und finish all pending tasks :return: None @@ -283,7 +279,7 @@ def cleanup(*args, **kwargs) -> None: exit(0) -def main(): +def main() -> None: """ Loads and validates the config and handles the main loop :return: None @@ -311,24 +307,33 @@ def main(): # Initialize all modules and start main loop if args.dynamic_whitelist: logger.info('Using dynamically generated whitelist. (--dynamic-whitelist detected)') - init(_CONF) - old_state = None - while True: - new_state = get_state() - # Log state transition - if new_state != old_state: - rpc.send_msg('*Status:* `{}`'.format(new_state.name.lower())) - logger.info('Changing state to: %s', new_state.name) - if new_state == State.STOPPED: - time.sleep(1) - elif new_state == State.RUNNING: - throttle( - _process, - min_secs=_CONF['internals'].get('process_throttle_secs', 10), - dynamic_whitelist=args.dynamic_whitelist, - ) - old_state = new_state + try: + init(_CONF) + old_state = None + while True: + new_state = get_state() + # Log state transition + if new_state != old_state: + rpc.send_msg('*Status:* `{}`'.format(new_state.name.lower())) + logger.info('Changing state to: %s', new_state.name) + + if new_state == State.STOPPED: + time.sleep(1) + elif new_state == State.RUNNING: + throttle( + _process, + min_secs=_CONF['internals'].get('process_throttle_secs', 10), + dynamic_whitelist=args.dynamic_whitelist, + ) + old_state = new_state + + except RuntimeError: + logger.exception('Got fatal exception!') + except KeyboardInterrupt: + logger.info('Got SIGINT, aborting ...') + finally: + cleanup() if __name__ == '__main__': diff --git a/freqtrade/misc.py b/freqtrade/misc.py index 2bd983137..abd9581e2 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -15,10 +15,6 @@ from freqtrade import __version__ logger = logging.getLogger(__name__) -class FreqtradeException(BaseException): - pass - - class State(enum.Enum): RUNNING = 0 STOPPED = 1 diff --git a/freqtrade/tests/test_exchange.py b/freqtrade/tests/test_exchange.py index d0083af6a..93a394de1 100644 --- a/freqtrade/tests/test_exchange.py +++ b/freqtrade/tests/test_exchange.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import pytest from freqtrade.exchange import validate_pairs +from freqtrade.misc import OperationalException def test_validate_pairs(default_conf, mocker): @@ -21,7 +22,7 @@ def test_validate_pairs_not_available(default_conf, mocker): api_mock.get_markets = MagicMock(return_value=[]) mocker.patch('freqtrade.exchange._API', api_mock) mocker.patch.dict('freqtrade.exchange._CONF', default_conf) - with pytest.raises(RuntimeError, match=r'not available'): + with pytest.raises(OperationalException, match=r'not available'): validate_pairs(default_conf['exchange']['pair_whitelist']) @@ -31,5 +32,5 @@ def test_validate_pairs_not_compatible(default_conf, mocker): default_conf['stake_currency'] = 'ETH' mocker.patch('freqtrade.exchange._API', api_mock) mocker.patch.dict('freqtrade.exchange._CONF', default_conf) - with pytest.raises(RuntimeError, match=r'not compatible'): + with pytest.raises(OperationalException, match=r'not compatible'): validate_pairs(default_conf['exchange']['pair_whitelist']) diff --git a/freqtrade/tests/test_main.py b/freqtrade/tests/test_main.py index 3de35ff46..6154c45d4 100644 --- a/freqtrade/tests/test_main.py +++ b/freqtrade/tests/test_main.py @@ -6,11 +6,12 @@ import pytest import requests from sqlalchemy import create_engine +from freqtrade import DependencyException, OperationalException from freqtrade.exchange import Exchanges from freqtrade.analyze import SignalType from freqtrade.main import create_trade, handle_trade, init, \ get_target_bid, _process -from freqtrade.misc import get_state, State, FreqtradeException +from freqtrade.misc import get_state, State from freqtrade.persistence import Trade @@ -59,7 +60,7 @@ def test_process_exchange_failures(default_conf, ticker, health, mocker): assert sleep_mock.has_calls() -def test_process_runtime_error(default_conf, ticker, health, mocker): +def test_process_operational_exception(default_conf, ticker, health, mocker): msg_mock = MagicMock() mocker.patch.dict('freqtrade.main._CONF', default_conf) mocker.patch.multiple('freqtrade.rpc', init=MagicMock(), send_msg=msg_mock) @@ -68,14 +69,14 @@ def test_process_runtime_error(default_conf, ticker, health, mocker): validate_pairs=MagicMock(), get_ticker=ticker, get_wallet_health=health, - buy=MagicMock(side_effect=RuntimeError)) + buy=MagicMock(side_effect=OperationalException)) init(default_conf, create_engine('sqlite://')) assert get_state() == State.RUNNING result = _process() assert result is False assert get_state() == State.STOPPED - assert 'RuntimeError' in msg_mock.call_args_list[-1][0][0] + assert 'OperationalException' in msg_mock.call_args_list[-1][0][0] def test_process_trade_handling(default_conf, ticker, limit_buy_order, health, mocker): @@ -141,7 +142,7 @@ def test_create_trade_no_stake_amount(default_conf, ticker, mocker): get_ticker=ticker, buy=MagicMock(return_value='mocked_limit_buy'), get_balance=MagicMock(return_value=default_conf['stake_amount'] * 0.5)) - with pytest.raises(FreqtradeException, match=r'.*stake amount.*'): + with pytest.raises(DependencyException, match=r'.*stake amount.*'): create_trade(default_conf['stake_amount']) @@ -154,7 +155,7 @@ def test_create_trade_no_pairs(default_conf, ticker, mocker): get_ticker=ticker, buy=MagicMock(return_value='mocked_limit_buy')) - with pytest.raises(FreqtradeException, match=r'.*No pair in whitelist.*'): + with pytest.raises(DependencyException, match=r'.*No pair in whitelist.*'): conf = copy.deepcopy(default_conf) conf['exchange']['pair_whitelist'] = [] mocker.patch.dict('freqtrade.main._CONF', conf)